摘要:到目前為止,我們幾乎已經實現了一個區塊鏈數據庫的所有元素。使用根據在區塊鏈中找到一筆交易。是一個比特幣輕節點,它不需要下載整個區塊鏈,也不需要驗證區塊和交易。到目前為止,我們只是將一個塊里面的每筆交易哈希連接了起來,將在上面應用了算法。
翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼,也可以 clone GitHub 上的教程倉庫,進入 src 目錄執行 make 即可。
在這個系列文章的一開始,我們就提到了,區塊鏈是一個分布式數據庫。不過在之前的文章中,我們選擇性地跳過了“分布式”這個部分,而是將注意力都放到了“數據庫”部分。到目前為止,我們幾乎已經實現了一個區塊鏈數據庫的所有元素。今天,我們將會分析之前跳過的一些機制。而在下一篇文章中,我們將會開始討論區塊鏈的分布式特性。
之前的系列文章:
基本原型
工作量證明
持久化和命令行接口
交易(1)
地址
本文的代碼實現變化很大,請點擊 這里 查看所有的代碼更改。獎勵
在上一篇文章中,我們略過的一個小細節是挖礦獎勵。現在,我們已經可以來完善這個細節了。
挖礦獎勵,實際上就是一筆 coinbase 交易。當一個挖礦節點開始挖出一個新塊時,它會將交易從隊列中取出,并在前面附加一筆 coinbase 交易。coinbase 交易只有一個輸出,里面包含了礦工的公鑰哈希。
實現獎勵,非常簡單,更新 send 即可:
func (cli *CLI) send(from, to string, amount int) { ... bc := NewBlockchain() UTXOSet := UTXOSet{bc} defer bc.db.Close() tx := NewUTXOTransaction(from, to, amount, &UTXOSet) cbTx := NewCoinbaseTX(from, "") txs := []*Transaction{cbTx, tx} newBlock := bc.MineBlock(txs) fmt.Println("Success!") }
在我們的實現中,創建交易的人同時挖出了新塊,所以會得到一筆獎勵。
UTXO 集在 Part 3: 持久化和命令行接口 中,我們研究了 Bitcoin Core 是如何在一個數據庫中存儲塊的,并且了解到區塊被存儲在 blocks 數據庫,交易輸出被存儲在 chainstate 數據庫。會回顧一下 chainstate 的機構:
c + 32 字節的交易哈希 -> 該筆交易的未花費交易輸出記錄
B + 32 字節的塊哈希 -> 未花費交易輸出的塊哈希
在之前那篇文章中,雖然我們已經實現了交易,但是并沒有使用 chainstate 來存儲交易的輸出。所以,接下來我們繼續完成這部分。
chainstate 不存儲交易。它所存儲的是 UTXO 集,也就是未花費交易輸出的集合。除此以外,它還存儲了“數據庫表示的未花費交易輸出的塊哈希”,不過我們會暫時略過塊哈希這一點,因為我們還沒有用到塊高度(但是我們會在接下來的文章中繼續改進)。
那么,我們為什么需要 UTXO 集呢?
來思考一下我們早先實現的 Blockchain.FindUnspentTransactions 方法:
func (bc *Blockchain) FindUnspentTransactions(pubKeyHash []byte) []Transaction { ... bci := bc.Iterator() for { block := bci.Next() for _, tx := range block.Transactions { ... } if len(block.PrevBlockHash) == 0 { break } } ... }
這個函數找到有未花費輸出的交易。由于交易被保存在區塊中,所以它會對區塊鏈里面的每一個區塊進行迭代,檢查里面的每一筆交易。截止 2017 年 9 月 18 日,在比特幣中已經有 485,860 個塊,整個數據庫所需磁盤空間超過 140 Gb。這意味著一個人如果想要驗證交易,必須要運行一個全節點。此外,驗證交易將會需要在許多塊上進行迭代。
整個問題的解決方案是有一個僅有未花費輸出的索引,這就是 UTXO 集要做的事情:這是一個從所有區塊鏈交易中構建(對區塊進行迭代,但是只須做一次)而來的緩存,然后用它來計算余額和驗證新的交易。截止 2017 年 9 月,UTXO 集大概有 2.7 Gb。
好了,讓我們來想一下實現 UTXO 集的話需要作出哪些改變。目前,找到交易用到了以下一些方法:
Blockchain.FindUnspentTransactions - 找到有未花費輸出交易的主要函數。也是在這個函數里面會對所有區塊進行迭代。
Blockchain.FindSpendableOutputs - 這個函數用于當一個新的交易創建的時候。如果找到有所需數量的輸出。使用 Blockchain.FindUnspentTransactions.
Blockchain.FindUTXO - 找到一個公鑰哈希的未花費輸出,然后用來獲取余額。使用 Blockchain.FindUnspentTransactions.
Blockchain.FindTransation - 根據 ID 在區塊鏈中找到一筆交易。它會在所有塊上進行迭代直到找到它。
可以看到,所有方法都對數據庫中的所有塊進行迭代。但是目前我們還沒有改進所有方法,因為 UTXO 集沒法存儲所有交易,只會存儲那些有未花費輸出的交易。因此,它無法用于 Blockchain.FindTransaction。
所以,我們想要以下方法:
Blockchain.FindUTXO - 通過對區塊進行迭代找到所有未花費輸出。
UTXOSet.Reindex - 使用 UTXO 找到未花費輸出,然后在數據庫中進行存儲。這里就是緩存的地方。
UTXOSet.FindSpendableOutputs - 類似 Blockchain.FindSpendableOutputs,但是使用 UTXO 集。
UTXOSet.FindUTXO - 類似 Blockchain.FindUTXO,但是使用 UTXO 集。
Blockchain.FindTransaction 跟之前一樣。
因此,從現在開始,兩個最常用的函數將會使用 cache!來開始寫代碼吧。
type UTXOSet struct { Blockchain *Blockchain }
我們將會使用一個單一數據庫,但是我們會將 UTXO 集從存儲在不同的 bucket 中。因此,UTXOSet 跟 Blockchain 一起。
func (u UTXOSet) Reindex() { db := u.Blockchain.db bucketName := []byte(utxoBucket) err := db.Update(func(tx *bolt.Tx) error { err := tx.DeleteBucket(bucketName) _, err = tx.CreateBucket(bucketName) }) UTXO := u.Blockchain.FindUTXO() err = db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketName) for txID, outs := range UTXO { key, err := hex.DecodeString(txID) err = b.Put(key, outs.Serialize()) } }) }
這個方法初始化了 UTXO 集。首先,如果 bucket 存在就先移除,然后從區塊鏈中獲取所有的未花費輸出,最終將輸出保存到 bucket 中。
Blockchain.FindUTXO 幾乎跟 Blockchain.FindUnspentTransactions 一模一樣,但是現在它返回了一個 TransactionID -> TransactionOutputs 的 map。
現在,UTXO 集可以用于發送幣:
func (u UTXOSet) FindSpendableOutputs(pubkeyHash []byte, amount int) (int, map[string][]int) { unspentOutputs := make(map[string][]int) accumulated := 0 db := u.Blockchain.db err := db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(utxoBucket)) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { txID := hex.EncodeToString(k) outs := DeserializeOutputs(v) for outIdx, out := range outs.Outputs { if out.IsLockedWithKey(pubkeyHash) && accumulated < amount { accumulated += out.Value unspentOutputs[txID] = append(unspentOutputs[txID], outIdx) } } } }) return accumulated, unspentOutputs }
或者檢查余額:
func (u UTXOSet) FindUTXO(pubKeyHash []byte) []TXOutput { var UTXOs []TXOutput db := u.Blockchain.db err := db.View(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(utxoBucket)) c := b.Cursor() for k, v := c.First(); k != nil; k, v = c.Next() { outs := DeserializeOutputs(v) for _, out := range outs.Outputs { if out.IsLockedWithKey(pubKeyHash) { UTXOs = append(UTXOs, out) } } } return nil }) return UTXOs }
這是 Blockchain 方法的簡單修改后的版本。這個 Blockchain 方法已經不再需要了。
有了 UTXO 集,也就意味著我們的數據(交易)現在已經被分開存儲:實際交易被存儲在區塊鏈中,未花費輸出被存儲在 UTXO 集中。這樣一來,我們就需要一個良好的同步機制,因為我們想要 UTXO 集時刻處于最新狀態,并且存儲最新交易的輸出。但是我們不想每生成一個新塊,就重新生成索引,因為這正是我們要極力避免的頻繁區塊鏈掃描。因此,我們需要一個機制來更新 UTXO 集:
func (u UTXOSet) Update(block *Block) { db := u.Blockchain.db err := db.Update(func(tx *bolt.Tx) error { b := tx.Bucket([]byte(utxoBucket)) for _, tx := range block.Transactions { if tx.IsCoinbase() == false { for _, vin := range tx.Vin { updatedOuts := TXOutputs{} outsBytes := b.Get(vin.Txid) outs := DeserializeOutputs(outsBytes) for outIdx, out := range outs.Outputs { if outIdx != vin.Vout { updatedOuts.Outputs = append(updatedOuts.Outputs, out) } } if len(updatedOuts.Outputs) == 0 { err := b.Delete(vin.Txid) } else { err := b.Put(vin.Txid, updatedOuts.Serialize()) } } } newOutputs := TXOutputs{} for _, out := range tx.Vout { newOutputs.Outputs = append(newOutputs.Outputs, out) } err := b.Put(tx.ID, newOutputs.Serialize()) } }) }
雖然這個方法看起來有點復雜,但是它所要做的事情非常直觀。當挖出一個新塊時,應該更新 UTXO 集。更新意味著移除已花費輸出,并從新挖出來的交易中加入未花費輸出。如果一筆交易的輸出被移除,并且不再包含任何輸出,那么這筆交易也應該被移除。相當簡單!
現在讓我們在必要的時候使用 UTXO 集:
func (cli *CLI) createBlockchain(address string) { ... bc := CreateBlockchain(address) defer bc.db.Close() UTXOSet := UTXOSet{bc} UTXOSet.Reindex() ... }
當一個新的區塊鏈被創建以后,就會立刻進行重建索引。目前,這是 Reindex 唯一使用的地方,即使這里看起來有點“殺雞用牛刀”,因為一條鏈開始的時候,只有一個塊,里面只有一筆交易,Update 已經被使用了。不過我們在未來可能需要重建索引的機制。
func (cli *CLI) send(from, to string, amount int) { ... newBlock := bc.MineBlock(txs) UTXOSet.Update(newBlock) }
當挖出一個新塊時,UTXO 集就會進行更新。
讓我們來檢查一下如否如期工作:
$ blockchain_go createblockchain -address 1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 00000086a725e18ed7e9e06f1051651a4fc46a315a9d298e59e57aeacbe0bf73 Done! $ blockchain_go send -from 1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 -to 12DkLzLQ4B3gnQt62EPRJGZ38n3zF4Hzt5 -amount 6 0000001f75cb3a5033aeecbf6a8d378e15b25d026fb0a665c7721a5bb0faa21b Success! $ blockchain_go send -from 1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 -to 12ncZhA5mFTTnTmHq1aTPYBri4jAK8TacL -amount 4 000000cc51e665d53c78af5e65774a72fc7b864140a8224bf4e7709d8e0fa433 Success! $ blockchain_go getbalance -address 1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 Balance of "1F4MbuqjcuJGymjcuYQMUVYB37AWKkSLif": 20 $ blockchain_go getbalance -address 12DkLzLQ4B3gnQt62EPRJGZ38n3zF4Hzt5 Balance of "1XWu6nitBWe6J6v6MXmd5rhdP7dZsExbx": 6 $ blockchain_go getbalance -address 12ncZhA5mFTTnTmHq1aTPYBri4jAK8TacL Balance of "13UASQpCR8Nr41PojH8Bz4K6cmTCqweskL": 4
很好!1JnMDSqVoHi4TEFXNw5wJ8skPsPf4LHkQ1 地址接收到了 3 筆獎勵:
一次是挖出創世塊
一次是挖出塊 0000001f75cb3a5033aeecbf6a8d378e15b25d026fb0a665c7721a5bb0faa21b
一個是挖出塊 000000cc51e665d53c78af5e65774a72fc7b864140a8224bf4e7709d8e0fa433
Merkle 樹在這篇文章中,我還想要再討論一個優化機制。
上如上面所提到的,完整的比特幣數據庫(也就是區塊鏈)需要超過 140 Gb 的磁盤空間。因為比特幣的去中心化特性,網絡中的每個節點必須是獨立,自給自足的,也就是每個節點必須存儲一個區塊鏈的完整副本。隨著越來越多的人使用比特幣,這條規則變得越來越難以遵守:因為不太可能每個人都去運行一個全節點。并且,由于節點是網絡中的完全參與者,它們負有相關責任:節點必須驗證交易和區塊。另外,要想與其他節點交互和下載新塊,也有一定的網絡流量需求。
在中本聰的 比特幣原始論文 中,對這個問題也有一個解決方案:簡易支付驗證(Simplified Payment Verification, SPV)。SPV 是一個比特幣輕節點,它不需要下載整個區塊鏈,也不需要驗證區塊和交易。相反,它會在區塊鏈查找交易(為了驗證支付),并且需要連接到一個全節點來檢索必要的數據。這個機制允許在僅運行一個全節點的情況下有多個輕錢包。
為了實現 SPV,需要有一個方式來檢查是否一個區塊包含了某筆交易,而無須下載整個區塊。這就是 Merkle 樹所要完成的事情。
比特幣用 Merkle 樹來獲取交易哈希,哈希被保存在區塊頭中,并會用于工作量證明系統。到目前為止,我們只是將一個塊里面的每筆交易哈希連接了起來,將在上面應用了 SHA-256 算法。雖然這是一個用于獲取區塊交易唯一表示的一個不錯的途徑,但是它沒有利用到 Merkle 樹。
來看一下 Merkle 樹:
每個塊都會有一個 Merkle 樹,它從葉子節點(樹的底部)開始,一個葉子節點就是一個交易哈希(比特幣使用雙 SHA256 哈希)。葉子節點的數量必須是雙數,但是并非每個塊都包含了雙數的交易。因為,如果一個塊里面的交易數為單數,那么就將最后一個葉子節點(也就是 Merkle 樹的最后一個交易,不是區塊的最后一筆交易)復制一份湊成雙數。
從下往上,兩兩成對,連接兩個節點哈希,將組合哈希作為新的哈希。新的哈希就成為新的樹節點。重復該過程,直到僅有一個節點,也就是樹根。根哈希然后就會當做是整個塊交易的唯一標示,將它保存到區塊頭,然后用于工作量證明。
Merkle 樹的好處就是一個節點可以在不下載整個塊的情況下,驗證是否包含某筆交易。并且這些只需要一個交易哈希,一個 Merkle 樹根哈希和一個 Merkle 路徑。
最后,來寫代碼:
type MerkleTree struct { RootNode *MerkleNode } type MerkleNode struct { Left *MerkleNode Right *MerkleNode Data []byte }
先從結構體開始。每個 MerkleNode 包含數據和指向左右分支的指針。MerkleTree 實際上就是連接到下個節點的根節點,然后依次連接到更遠的節點,等等。
讓我們首先來創建一個新的節點:
func NewMerkleNode(left, right *MerkleNode, data []byte) *MerkleNode { mNode := MerkleNode{} if left == nil && right == nil { hash := sha256.Sum256(data) mNode.Data = hash[:] } else { prevHashes := append(left.Data, right.Data...) hash := sha256.Sum256(prevHashes) mNode.Data = hash[:] } mNode.Left = left mNode.Right = right return &mNode }
每個節點包含一些數據。當節點在葉子節點,數據從外界傳入(在這里,也就是一個序列化后的交易)。當一個節點被關聯到其他節點,它會將其他節點的數據取過來,連接后再哈希。
func NewMerkleTree(data [][]byte) *MerkleTree { var nodes []MerkleNode if len(data)%2 != 0 { data = append(data, data[len(data)-1]) } for _, datum := range data { node := NewMerkleNode(nil, nil, datum) nodes = append(nodes, *node) } for i := 0; i < len(data)/2; i++ { var newLevel []MerkleNode for j := 0; j < len(nodes); j += 2 { node := NewMerkleNode(&nodes[j], &nodes[j+1], nil) newLevel = append(newLevel, *node) } nodes = newLevel } mTree := MerkleTree{&nodes[0]} return &mTree }
當生成一棵新樹時,要確保的第一件事就是葉子節點必須是雙數。然后,數據(也就是一個序列化后交易的數組)被轉換成樹的葉子,從這些葉子再慢慢形成一棵樹。
現在,讓我們來修改 Block.HashTransactions,它用于在工作量證明系統中獲取交易哈希:
func (b *Block) HashTransactions() []byte { var transactions [][]byte for _, tx := range b.Transactions { transactions = append(transactions, tx.Serialize()) } mTree := NewMerkleTree(transactions) return mTree.RootNode.Data }
首先,交易被序列化(使用 encoding/gob),然后使用序列后的交易構建一個 Mekle 樹。樹根將會作為塊交易的唯一標識符。
P2PKH還有一件事情,我想要再談一談。
大家應該還記得,在比特幣中有一個 腳本(Script)編程語言,它用于鎖定交易輸出;交易輸入提供了解鎖輸出的數據。這個語言非常簡單,用這個語言寫的代碼其實就是一系列數據和操作符而已。比如如下示例:
5 2 OP_ADD 7 OP_EQUAL
5, 2, 和 7 是數據,OP_ADD 和 OP_EQUAL 是操作符。腳本代碼從左到右執行:將數據依次放入棧內,當遇到操作符時,就從棧內取出數據,并將操作符作用于數據,然后將結果作為棧頂元素。腳本的棧,實際上就是一個先進后出的內存存儲:棧里的第一個元素最后一個取出,后面的每一個元素都會放到前一個元素之上。
讓我們來對上面的腳本分部執行:
步驟 | 棧 | 腳本 | 說明 |
---|---|---|---|
1 | 空 | 5 2 OP_ADD 7 OP_EQUAL | 一開始棧為空 |
2 | 5 | 2 OP_ADD 7 OP_EQUAL | 從腳本里面取出 5 放入棧上 |
3 | 5 2 | OP_ADD 7 OP_EQUAL | 從腳本里面取出 2 放入棧上 |
4 | 7 | 7 OP_EQUAL | 遇到操作符 OP_ADD, 從棧里取出兩個操作數 5 和 2,相加后將結果放回棧上 |
5 | 7 7 | OP_EQUAL | 從腳本里面取出 7 放到棧上 |
6 | true | 空 | 遇到操作符 OP_EQUAL,從棧里取出兩個操作數并比較,將比較的結果放回棧內,腳本執行完畢,為空 |
OP_ADD 從棧內取兩個元素,將這兩個元素進行相加,然后將結果重新放回棧內。OP_EQUAL 從棧內取兩個元素,然后對這兩個元素進行比較:如果它們相等,就在棧上放一個 true,否則放一個 false。腳本執行的結果就是棧頂元素:在我們的案例中,如果是 true,那么表明腳本執行成功。
現在來看一下在比特幣中,是如何用腳本執行支付的:
OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG
這個腳本叫做 Pay to Public Key Hash(P2PKH),這是比特幣最常用的一個腳本。它所做的事情就是向一個公鑰哈希支付,也就是說,用某一個公鑰鎖定一些幣。這是比特幣支付的核心:沒有賬戶,沒有資金轉移;只有一個腳本檢查提供的簽名和公鑰是否正確。
這個腳本實際存儲為兩個部分:
第一個部分,
第二部分,OP_DUP OP_HASH160
因此,輸出定了解鎖的邏輯,輸入提供解鎖輸出的“鑰匙”。然我們來執行一下這個腳本:
步驟 | 棧 | 腳本 |
---|---|---|
1 | 空 | |
2 | ||
3 | OP_DUP OP_HASH160 |
|
4 | OP_HASH160 |
|
5 | ||
6 | OP_EQUALVERIFY OP_CHECKSIG | |
7 | OP_CHECKSIG | |
8 | true 或 false | 空 |
OP_DUP 對棧頂元素進行復制。OP_HASH160 取棧頂元素,然后用 RIPEMD160 對它進行哈希,再將結果送回到棧上。OP_EQUALVERIFY 將棧頂的兩個元素進行比較,如果它們不相等,終止腳本。OP_CHECKSIG 通過對交易進行哈希,并使用
有了一個這樣的腳本語言,實際上也可以讓比特幣成為一個智能合約平臺:除了將一個單一的公鑰轉移資金,這個語言還使得一些其他的支付方案成為可能。
總結這就是今天的全部內容了!我們已經實現了一個基于區塊鏈的加密貨幣的幾乎所有關鍵特性。我們已經有了區塊鏈,地址,挖礦和交易。但是要想給這些所有的機制賦予生命,讓比特幣成為一個全球系統,還有一個不可或缺的環節:共識(consensus)。在下一篇文章中,我們將會開始實現區塊鏈的“去中心化(decenteralized)”。敬請收聽!
鏈接:
Full source codes
The UTXO Set:_Data_Storage#The_UTXO_set_.28chainstate_leveldb.29)
Merkle Tree
Script
“Ultraprune” Bitcoin Core commit
UTXO set statistics
Smart contracts and Bitcoin
Why every Bitcoin user should understand “SPV security”
原文鏈接:Building Blockchain in Go. Part 6: Transactions 2
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/23936.html
摘要:引言交易是比特幣的核心所在,而區塊鏈的唯一目的,也正是為了能夠安全可靠地存儲交易。比特幣使用了一個更加復雜的技術它將一個塊里面包含的所有交易表示為一個,然后在工作量證明系統中使用樹的根哈希。 翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼,也可以 clone GitHu...
摘要:在區塊鏈中,存儲有效信息的是區塊。存儲的是前一個塊的哈希。正是由于這個特性,才使得區塊鏈是安全的。這樣的結構,能夠讓我們快速地獲取鏈上的最新塊,并且高效地通過哈希來檢索一個塊。 翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼,也可以 clone GitHub 上的教程倉...
摘要:盡管我們不會實現一個真實的網絡,但是我們會實現一個真是,也是比特幣最常見最重要的用戶場景。不過,這并不是處于禮貌用于找到一個更長的區塊鏈。意為給我看一下你有什么區塊在比特幣中,這會更加復雜。 翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼,也可以 clone GitHu...
摘要:比特幣地址這就是一個真實的比特幣地址。這是史上第一個比特幣地址,據說屬于中本聰。當你安裝一個錢包應用,或是使用一個比特幣客戶端來生成一個新地址時,它就會為你生成一對密鑰。在被放入到一個塊之前,必須要對每一筆交易進行驗證。 翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼,...
摘要:引言到目前為止,我們已經構建了一個有工作量證明機制的區塊鏈。在今天的內容中,我們會將區塊鏈持久化到一個數據庫中,然后會提供一個簡單的命令行接口,用來完成一些與區塊鏈的交互操作。這同樣也意味著,一個也就是區塊鏈的一種標識符。 翻譯的系列文章我已經放到了 GitHub 上:blockchain-tutorial,后續如有更新都會在 GitHub 上,可能就不在這里同步了。如果想直接運行代碼...
閱讀 3429·2023-04-25 22:44
閱讀 933·2021-11-15 11:37
閱讀 1636·2019-08-30 15:55
閱讀 2649·2019-08-30 15:54
閱讀 1087·2019-08-30 13:45
閱讀 1435·2019-08-29 17:14
閱讀 1857·2019-08-29 13:50
閱讀 3415·2019-08-26 11:39