etcd系列-----raft保數據一致性手段之WAL日誌

WAL (Write-ahead logging)是etcd實現一致性的重要手段之一。一條Entry記錄的大致流程:
    (1)當客戶端向etcd 集羣發送了一次請求之後,請求中的封裝Entry記錄會先被交給raft模塊進行處理,raft模塊會先將Entry記錄保存到raftLog.unstable中。
    (2)raft模塊將該Entry記錄封裝到前面介紹的Ready實例中,返回給上層模塊進行持久化。
    (3)當上層模塊收到待持久化的Entry記錄之後,會先將其記錄到WAL日誌文件中,然後進行持久化操作,最後通知raft模塊進行處理。
    (4)此時raft模塊就會將該Entry記錄從unstable移動到storage中保存。
    (5)待該Entry記錄被複制到集羣中的半數以上節點時,該Entry記錄會被Leader節點確認爲己提交(committed),並封裝進Ready實例返回給上層模塊。
    (6)上層模塊即可將該Ready實例中攜帶的待應用Entry記錄應用到狀態機中。

在wal 模塊中,首先需要介紹的就是結構體WAL,它對外提供了WAL日誌文件管理的核心API。在操作WAL日誌時,對應的WAL實例有read和append兩種模式,新創建的WAL實例處於append模式,該模式下只能向WAL中追加日誌。 當恢復一個節點時(例如,宕機節點的重啓),就需要讀取WAL日誌的內容,此時剛打開的WAL實例處於read模式,它只能讀取日誌記錄, 當讀取完全部的日誌之後,WAL實例轉換成append模式,可以繼續向其追加日誌記錄。

在WAL日誌文件中,日誌記錄是通過Record表示的,該結構體通過Protocol Buffers生成,主要用於序列化和反序列化日誌記錄,其中各個字段的含義如下。
    Type字段(int64類型):表示該Record實例的類型。
    Crc字段(uint32類型):記錄該Record實例的校驗碼。
    Data字段([]byte 類型):記錄真正的日誌數據,根據日誌的類型不同,Data字段中保存的數據也有所相同。

Record 結構體還提供了一些簡單的方法,後面遇到時會進行簡單說明。根據Record.Type字段值,可以將日誌記錄分爲如下幾種類型。
    metadataType: 該類型日誌記錄的Data字段中保存了一些元數據,在每個WAL文件的開頭,都會記錄一條metadataType類型的日誌記錄。
    entry Type: 該類型日誌記錄的Data字段中保存的是Entry記錄,也就是客戶端發送給服務端處理的數據,例如,raftexample示例中客戶端發送的鍵值對數據。
    state Type:該類型日誌記錄的Data字段中保存了當前集羣的狀態信息(即HardState),在每次批量寫入entry Type類型日誌記錄之前,都會先寫入一條stateType類型的日誌記錄。
    crc Type: 該類型的日誌記錄主要用於數據校驗。
    snapshotType: 該類型的日誌記錄中保存了快照數據的相關信息(即walpb.Snapshot,注意,其中不包含完整的快照數據)。

WAL結構體核心字段的含義:
    dir (string類型):存放WAL日誌文件的目錄路徑。
    dirFile ( *as.File類型):根據dir路徑創建的File實例。
    metadata ( []byte類型): 在每個WAL日誌文件的頭部,都會寫入metadata元數據。
    state ( raftpb.HardState 類型): WAL日誌記錄的追加是批量的,在每次批量寫入entryType類型的日誌之後,都會再追加一條stateType類型的日誌記錄,在HardState中記錄了當前的Term、當前節點的投票結果和己提交日誌的位置。
    start(walpb.Snapshot類型):每次讀取WAL日誌時,並不會每次都從頭開始讀取,而是通過這裏的start宇段指定具體的起始位置。walpb.Snapshot中的Index字段記錄了對應快照數據所涵蓋的最後一條Entry 記錄的索引值, Term字段則記錄了對應Entry記錄的Term值。 在讀取WAL日誌文件時,我們就可以根據這些信息,找到合適的位置並開始讀取記錄。
    decoder ( *decode「類型): 負責在讀取WAL日誌文件時,將二進制數據反序列化成Record實例。
    encoder( *encode「類型):負責將寫入WAL日誌文件的Record實例進行序列化成二進制數據。
    mu ( sync.Mutex類型):讀寫WAL日誌時需要加鎖同步。
    enti ( uint64類型):WAL中最後一條Entry記錄的索引值。
    locks ( []*fileutil.LockedFile類型): 當前WAL實例管理的所有WAL日誌文件對應的句柄。
    fp  ( *filePipeline類型): filePipeline實例負責創建新的臨時文件。

filePipeline,它負責預創建日誌文件併爲日誌文件預分配空間。在filePipeline 中會啓動一個獨立的後臺goroutine來創建".tmp”結尾的臨時文件,當進行日誌文件切換時, 直接將臨時文件進行重命名即可使用。newFilePipeline()方法中,除了創建filePipeline實例,還會啓動一個後臺goroutine來執行filePipeline.run()方法,該後臺goroutine 中會創建新的臨時文件並將其句柄傳遞到filec通道中。在WAL切換日誌文件時會調用filePipeline. Open()方法,從filec通道中獲取之前創建好的臨時文件

1、初始化

wal.Create()方法,該方法不僅會創建WAL實例,而是做了很多初始化工作:
    (1)創建臨時目錄,並在臨時目錄中創建編號爲“0-0”的WAL日誌文件, WAL日誌文件名由兩部分組成,一部分是seq(單調遞增),另一部分是該日誌文件中的第一條日誌記錄的索引值。
    (2)嘗試爲該WAL日誌文件預分配磁盤空間。
    (3) 向該WAL日誌文件中寫入一條crcType類型的日誌記錄、一條metadataType類型的日誌記錄及一條snapshotType類型的日誌記錄。
    (4)創建WAL實例關聯的filePipeline實例。
    (5)將臨時目錄重命名爲WAL.dir字段指定的名稱。

這裏之所以先使用臨時目錄完成初始化操作再將其重命名的方式,主要是爲了讓整個初始化過程看上去是一個原子操作。wal.Create()方法的具體實現如下:

func Create(dirpath string, metadata []byte) (*WAL, error) {
	if Exist(dirpath) {
		return nil, os.ErrExist
	}

	// keep temporary wal directory so WAL initialization appears atomic
	tmpdirpath := filepath.Clean(dirpath) + ".tmp"//得到臨時目錄的路徑
	if fileutil.Exist(tmpdirpath) {
		if err := os.RemoveAll(tmpdirpath); err != nil {
			return nil, err
		}
	}
	if err := fileutil.CreateDirAll(tmpdirpath); err != nil {//創建臨時文件夾
		return nil, err
	}

	p := filepath.Join(tmpdirpath, walName(0, 0))//第一個WAL日誌文件的路徑(文件名爲0-0)
	f, err := fileutil.LockFile(p, os.O_WRONLY|os.O_CREATE, fileutil.PrivateFileMode)
	if err != nil {
		return nil, err
	}
	//移動臨時文件的offset到文件結尾處,注意Seek()方法的第二個參數(0是相對文件開頭,1是相對當前offset, 2是相對文件結尾)
	if _, err = f.Seek(0, io.SeekEnd); err != nil {
		return nil, err
	}
	//對新建的臨時文件進行空間預分配,默認值是64MB(SegmentSizeBytes)
	if err = fileutil.Preallocate(f.File, SegmentSizeBytes, true); err != nil {
		return nil, err
	}
    //創建WAL實例
	w := &WAL{
		dir:      dirpath,//存放WAL日誌文件的目錄的路徑
		metadata: metadata,
	}
	w.encoder, err = newFileEncoder(f.File, 0)//創建寫WAL日誌文件的encoder
	if err != nil {
		return nil, err
	}
	w.locks = append(w.locks, f)//將WAL日誌文件對應的LockedFile實例記錄到locks字段中,表示當前WAL實例正在管理該日誌文件
	if err = w.saveCrc(0); err != nil {//創建一條crcType類型的日誌寫入WAL日誌文件
		return nil, err
	}
	//創建一條metadataType類型的日誌寫入WAL日誌文件
	if err = w.encoder.encode(&walpb.Record{Type: metadataType, Data: metadata}); err != nil {
		return nil, err
	}
	if err = w.SaveSnapshot(walpb.Snapshot{}); err != nil {//創建一條空的snapshotType類型的日誌記錄寫入臨時文件
		return nil, err
	}
    //將臨時目錄重命名,並創建WAL實例關聯的filePipline實例
	if w, err = w.renameWal(tmpdirpath); err != nil {
		return nil, err
	}

	// directory was renamed; sync parent dir to persist rename
	pdir, perr := fileutil.OpenDir(filepath.Dir(w.dir))
	if perr != nil {
		return nil, perr
	}
	//同步磁盤的操作
	if perr = fileutil.Fsync(pdir); perr != nil {
		return nil, perr
	}
	if perr = pdir.Close(); err != nil {
		return nil, perr
	}

	return w, nil
}

2、日誌打開

wal 模塊提供了Open()和OpenForRead()兩個函數,兩者的區別在於:使用Open()函數創建的WAL實例讀取完全部日誌後,可以繼續追加日誌:而OpenForRead()函數創建的WAL實例只能用於讀取日誌,不能追加日誌.Open()函數或OpenForRead()函數創建WAL實例之後,就可以調用其ReadAll()方法讀取日誌了。WAL.ReadAll()方法首先從WAL.start 字段指定的位置開始讀取日誌記錄,讀取完畢之後,會根據讀取的情況進行一系列異常處理。然後根據當前WAL實例的模式進行不同的處理:如果處於讀寫模式,則需要先對後續的WAL日誌文件進行填充並初始化WAL.encoder字段,爲後面寫入日誌做準備;如果處於只讀模式下,則需要關閉所有的日誌文件。

func (w *WAL) ReadAll() (metadata []byte, state raftpb.HardState, ents []raftpb.Entry, err error) {
	w.mu.Lock()
	defer w.mu.Unlock()

	rec := &walpb.Record{}//創建Record
	decoder := w.decoder//解碼器,負責讀取日誌文件,並將日誌數據反序列化成Record實例

	var match bool     //標識是否找到了start字段對應的日誌記錄
	//循環讀取WAL日誌文件中的數據,多個WAL日誌文件的切纔是是在decoder中完成的,後面會詳細分析其實現
	for err = decoder.decode(rec); err == nil; err = decoder.decode(rec) {
		switch rec.Type {
		case entryType:
			e := mustUnmarshalEntry(rec.Data)//反序列化Record.Data中記錄的數據,得到Entry
			if e.Index > w.start.Index {//將start之後的Entry記錄添加到ents中保存
				ents = append(ents[:e.Index-w.start.Index-1], e)
			}
			w.enti = e.Index//記錄讀取到的最後一條Entry記錄的索引值
		case stateType:
			state = mustUnmarshalState(rec.Data)
		case metadataType:
		    //檢測metadata數據是否發生衝突,如果衝突,則拋出異常
			if metadata != nil && !bytes.Equal(metadata, rec.Data) {
				state.Reset()
				return nil, state, nil, ErrMetadataConflict
			}
			metadata = rec.Data
		case crcType:
			crc := decoder.crc.Sum32()
			if crc != 0 && rec.Validate(crc) != nil {
				state.Reset()
				return nil, state, nil, ErrCRCMismatch
			}
			decoder.updateCRC(rec.Crc)//更新deeodr.crc字段
		case snapshotType:
			var snap walpb.Snapshot
			pbutil.MustUnmarshal(&snap, rec.Data)//解析快照相關的數據
			if snap.Index == w.start.Index {
				if snap.Term != w.start.Term {
					state.Reset()
					return nil, state, nil, ErrSnapshotMismatch
				}
				match = true//mateh
			}
		default:
			state.Reset()
			return nil, state, nil, fmt.Errorf("unexpected block type %d", rec.Type)
		}
	}
    //根據WAL.locks字段是否有位判斷當前WAL是什麼模式
	switch w.tail() {
	case nil:
	   //對於只讀模式,並不需妥將全部的日誌都讀出來,因爲以只讀模式打開WAL日誌文件時,並沒有加鎖,所以最後一條日誌記錄可能只寫了一半,從而導致io.ErrUnexpectedEOF異常
		if err != io.EOF && err != io.ErrUnexpectedEOF {
			state.Reset()
			return nil, state, nil, err
		}
	default:
		// 對於讀寫模式,則需將日誌記錄全部讀出來,所以此處不是EOF異常,則報錯,將文件指針移動到讀取結束的位置,並將文件後續部分全部填充爲0
		if err != io.EOF {
			state.Reset()
			return nil, state, nil, err
		}
		if _, err = w.tail().Seek(w.decoder.lastOffset(), io.SeekStart); err != nil {
			return nil, state, nil, err
		}
		if err = fileutil.ZeroToEnd(w.tail().File); err != nil {
			return nil, state, nil, err
		}
	}

	err = nil
	if !match {//如採在讀取過程中沒有找到與start對應的日誌記錄, 則拋出異常
		err = ErrSnapshotNotFound
	}

	// close decoder, disable reading
	if w.readClose != nil {//如採是隻讀模式,則關閉所有日誌文件
		w.readClose()//WAL.readClose實際指向的是WAL.CloseAll()方法
		w.readClose = nil
	}
	w.start = walpb.Snapshot{}   //清空start字段

	w.metadata = metadata

	if w.tail() != nil {    //如採是讀寫模式,則初始化WAL.encoder字段, 爲後面寫入日誌做準備
		// create encoder (chain crc with the decoder), enable appending
		w.encoder, err = newFileEncoder(w.tail().File, w.decoder.lastCRC())
		if err != nil {
			return
		}
	}
	w.decoder = nil    //清空WAL.decoder字段,後續不能再用該WAL實例進行讀取了

	return metadata, state, ents, err
}

//brs ( []*bufio.Reader類型): 該decoder實例通過該字段中記錄的Reader實例讀取相應的日誌文件,這些日誌文件就是wal.openAtlndex()方法中打開的日誌文件。
//lastValidOff ( int64類型):讀取日誌記錄的指針。
func (d *decoder) decodeRecord(rec *walpb.Record) error {
	if len(d.brs) == 0 {//檢測brs字段長度, 決定是否還有日誌文件需要讀取
		return io.EOF
	}
    //讀取第一個日誌文件中的第一個日誌記錄的長度
	l, err := readInt64(d.brs[0])
	//是否讀到文件尾, 或是讀取到了預分目己的部分, 這都表示讀取操作結束
	if err == io.EOF || (err == nil && l == 0) {
		// hit end of file or preallocated space
		d.brs = d.brs[1:]//更新brs字段,將其中第一個日誌文件對應的Reader清除掉
		if len(d.brs) == 0 {//如果後面沒有其他日誌文件可讀則返回EOF異常,表示讀取正常結束
			return io.EOF
		}
		d.lastValidOff = 0//若後續還有其他日誌文件待讀取,則需換文件這裏重直lastValidOff
		return d.decodeRecord(rec)//遞歸調用decodeRecord()方法
	}
	if err != nil {
		return err
	}

	//計算當前日誌記錄的實際長度及填無數據的長度,並創建相應的data切片
	recBytes, padBytes := decodeFrameSize(l)

	data := make([]byte, recBytes+padBytes)
	if _, err = io.ReadFull(d.brs[0], data); err != nil {//從日誌文件中讀取指定長度的字節數如讀取不到指定的字節數, 則會返回EOF異常,此時返回ErrUnexpectedEOF異常
		// ReadFull returns io.EOF only if no bytes were read
		// the decoder should treat this as an ErrUnexpectedEOF instead.
		if err == io.EOF {
			err = io.ErrUnexpectedEOF
		}
		return err
	}
	//將0-recBytes反序列化成Record
	if err := rec.Unmarshal(data[:recBytes]); err != nil {
		if d.isTornEntry(data) {
			return io.ErrUnexpectedEOF
		}
		return err
	}

	// skip crc checking if the record type is crcType
	if rec.Type != crcType {
		d.crc.Write(rec.Data)
		if err := rec.Validate(d.crc.Sum32()); err != nil {//進行crc校驗
			if d.isTornEntry(data) {
				return io.ErrUnexpectedEOF
			}
			return err
		}
	}
	// record decoded as valid; point last valid offset to end of record
	d.lastValidOff += frameSizeBytes + recBytes + padBytes//將lastValidOff後移,準備讀取下一條日誌記錄
	return nil
}

3、追加日誌

WAL對外提供了追加日誌的方法,分別是Save()方法和SaveSnapshot()方法。WAL.Save()方法先將待寫入的Entry記錄封裝成entryType類型的Record實例,然後將其序列化並追加到日誌段文件中,之後將HardState封裝成stateType類型的Record實例,並序列化寫入日誌段文件中,最後將這些日誌記錄同步刷新到磁盤。WAL.Save()方法的具體實現如下:

func (w *WAL) Save(st raftpb.HardState, ents []raftpb.Entry) error {
	w.mu.Lock()
	defer w.mu.Unlock()

	//邊界檢查,如果待寫入的HardState和Entry數組都爲空,則直接返回;否則就需要將修改同步到磁盤上
	if raft.IsEmptyHardState(st) && len(ents) == 0 {
		return nil
	}

	mustSync := raft.MustSync(st, w.state, len(ents))

	// 遍歷待寫入的Entry數生且,將每個Entry實例序列化並封裝entryType類型的日誌記錄,寫入日誌文件
	for i := range ents {
		if err := w.saveEntry(&ents[i]); err != nil {
			return err
		}
	}
	//將狀態信息(HardState) 序列化並封裝成stateType類型的日誌記錄,寫入日誌文件
	if err := w.saveState(&st); err != nil {
		return err
	}
    //獲取當前日誌段文件的文件指針的位置
	curOff, err := w.tail().Seek(0, io.SeekCurrent)
	if err != nil {
		return err
	}
	//如未寫滿預分畫己的空間, 將新日誌刷新到磁盤後,即可返回
	if curOff < SegmentSizeBytes {
		if mustSync {
			return w.sync()
		}
		return nil
	}
    //當前文件大小已超出了預分配的空間, 則需進行日誌文件的切換
	return w.cut()
}
func (w *WAL) saveEntry(e *raftpb.Entry) error {
	// TODO: add MustMarshalTo to reduce one allocation.
	b := pbutil.MustMarshal(e)//將Entry記錄序列化
	//將序列化後的數據封裝成entryType類型的Record記錄
	rec := &walpb.Record{Type: entryType, Data: b}
	//通過encoder.encode()方法追加日誌記錄
	if err := w.encoder.encode(rec); err != nil {
		return err
	}
	w.enti = e.Index //更新WAL.enti字段, 其中保存了最後一條Entry記錄的索引位
	return nil
}
func (w *WAL) sync() error {
	if w.encoder != nil {
		if err := w.encoder.flush(); err != nil {//先使用encoder.flush() 方法進行同步刷新
			return err
		}
	}
	start := time.Now()
	err := fileutil.Fdatasync(w.tail().File)//使用操作系統的fdatasync將數據真正刷新到磁盤上

	duration := time.Since(start)
	if duration > warnSyncDuration {//這裏會對該刷新操作的執行時間進行監控, 如採刷新操作執行的時間長於指定的時間(默認值是ls),則輸出警告日誌
		plog.Warningf("sync duration of %v, expected less than %v", duration, warnSyncDuration)
	}
	syncDurations.Observe(duration.Seconds())

	return err
}
//bw ( *iouti l.PageW「ite「類型):PageWriter是帶有緩衝區的Writer,在寫入時,每寫滿一個Page大小的緩衝區,就會自動觸發一次Flush 操作,將數據同步刷新到磁盤上。每個Page的大小是由walPageBytes常量指定的。
//buf ( []byte類型): 日誌序列化之後,會暫存在該緩衝區中, 該緩衝區會被複用, 這就防止了每次序列化創建緩衝區帶來的開銷。
//uint64buf ( []byte類型):在寫入一條日誌記錄時, 該緩衝區用來暫存一個Frame的長度的數據(Frame 由日誌數據和填充數據構成)。

func (e *encoder) encode(rec *walpb.Record) error {
	e.mu.Lock()
	defer e.mu.Unlock()

	e.crc.Write(rec.Data)
	rec.Crc = e.crc.Sum32()//計算crc校驗碼(
	var (
		data []byte
		err  error
		n    int
	)
    //將待寫入到日誌記錄進行序列化
	if rec.Size() > len(e.buf) {//如果日誌記錄太大,無法複用eneoder.buf這個緩衝區, 則直接序列化
		data, err = rec.Marshal()
		if err != nil {
			return err
		}
	} else {//複用eneoder.buf這個緩衝區
		n, err = rec.MarshalTo(e.buf)
		if err != nil {
			return err
		}
		data = e.buf[:n]
	}
    //計算序列化之後的數據長度,在eneodeFrarneSize()方法中會完成8字節對齊,這裏將真正的數據和填充數據看作一個Frame, 返回值分別是整個Frame的長度,以及其中填充數據的長度
	lenField, padBytes := encodeFrameSize(len(data))
	//將Frame的長度序列化到eneoder.uint64buf數組中,然後寫入文件
	if err = writeUint64(e.bw, lenField, e.uint64buf); err != nil {
		return err
	}

	if padBytes != 0 {
		data = append(data, make([]byte, padBytes)...)//向data中寫入填充字節
	}
	_, err = e.bw.Write(data)//將data中的序列化數據寫入文件
	return err
}

4、文件切換

隨着WAL日誌文件的不斷寫入, 單個日誌文件會不斷變大。在前面提到過,每個日誌文件的大小是有上限的,該閥值由SegmentSizeBytes指定(默認值是64MB), 該值也是日誌文件預分配磁盤空間的大小。當單個日誌文件的大小超過該值時, 就會觸發日誌文件的切換,該切換過程是在WAL.cut()方法中實現的。WAL.cut()方法首先通過filePipeline 獲取一個新建的臨時文件,然後寫入crcType類型、metaType類型、stateType類型等必要日誌記錄(這個步驟與前面介紹的Create()方法類似),然後將臨時文件重命名成符合WAL日誌命名規範的新日誌文件,並創建對應的encoder實例更新到WAL.encoder字段。

5、SnapShoter

隨着節點的運行,會處理客戶端和集羣中其他節點發來的大量請求,相應的WAL日誌量會不斷增加,會產生大量的WAL日誌文件,另外etcd-raft模塊中的raftLog中也會存儲大量的Entry記錄,這就會導致資源浪費。當節點宕機之後,如果要恢復其狀態,則需要從頭讀取全部的WAL日誌文件,這顯然是非常耗時的。 爲了解決這些問題,etcd會定期創建快照並將其保存到本地磁盤中,在恢復節點狀態時會先加載快照文件,使用該快照數據將節點恢復到對應的狀態,之後從快照數據之後的相應位置開始讀取WAL日誌文件,最終將節點恢復到正確的狀態。與WAL日誌的管理類似,快照管理是snap模塊。其中SnapShotter 通過文件的方式管理快照數據,它是snapshot模塊的核心。在SnapShoter結構體中只有一個dir宇段(string類型),該字段指定了存儲快照文件的目錄位置。Snapshotter.SaveSnap()方法的主要功能就是將快照數據保存到快照文件中,其底層是通過調用save()方法實現的。save()方法的具體實現如下:

func (s *Snapshotter) save(snapshot *raftpb.Snapshot) error {
	start := time.Now()
    //創建快照文件名,快照、文件的名稱由三部分組成,分別是快照所涵蓋的最後一條Entry記錄的Term、Index和.snap文件
	fname := fmt.Sprintf("%016x-%016x%s", snapshot.Metadata.Term, snapshot.Metadata.Index, snapSuffix)
	b := pbutil.MustMarshal(snapshot)//將快照數據進行序列化
	crc := crc32.Update(0, crcTable, b)//計算crc
	//將序列化後的數據和校驗碼封裝成snappb.Snapshot實例,
    //這裏簡單瞭解一下raftpb.Snapshot和snappb.Snapshot的區別,前者包含了Snapshot數據及一些元數據(例如,該快照數據所涵蓋的最後一條Entry記錄的Term和Index);後者則是在前者序列化之後的封笨,其中還記錄了相應的校驗碼等信息
	snap := snappb.Snapshot{Crc: crc, Data: b}
	d, err := snap.Marshal()
	if err != nil {
		return err
	} else {
		marshallingDurations.Observe(float64(time.Since(start)) / float64(time.Second))
	}
    //將snappb.Snapshot序列化後的數據寫入文件,並同步刷新到磁盤
	err = pioutil.WriteAndSyncFile(filepath.Join(s.dir, fname), d, 0666)
	if err == nil {
		saveDurations.Observe(float64(time.Since(start)) / float64(time.Second))
	} else {
		err1 := os.Remove(filepath.Join(s.dir, fname))
		if err1 != nil {
			plog.Errorf("failed to remove broken snapshot file %s", filepath.Join(s.dir, fname))
		}
	}
	return err
}

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章