[譯]用go進行區塊鏈開發3:持久化與CLI
簡介
目前為止,我們做了帶有工作量證明系統的區塊鏈,因此它是可以挖礦的。我們的實現與全功能的區塊鏈越來越接近了,但還缺乏一些重要特性。今天我們將把區塊鏈存到一個數據庫中,並在那之後做個簡單的命令列工具來對它進行操作。本質上,區塊鏈是分散式資料庫。我們暫時忽略“分散式”部分專注於“資料庫”部分。
資料庫選擇
我們當前的實現中還沒有資料庫;而是在每次執行程式的時候建立區塊鏈並儲存在記憶體中。我們無法複用一個區塊鏈,也不能與他人分享,因此需要把它存在磁碟上。
我們需要哪個資料庫?事實上任意一個都可以。在比特幣白皮書中沒有提到具體資料庫的使用,因此使用什麼資料庫取決於開發者。現在普遍的比特幣實現參考是Satoshi Nakamoto最初發布的
BoltDB
因為:
- 它是極簡的並易於使用。
- 用go實現。
- 不需要執行伺服器。
- 允許建立我們想要的資料結構。
Bolt is a pure Go key/value store inspired by Howard Chu’s LMDB project. The goal of the project is to provide a simple, fast, and reliable database for projects that don’t require a full database server such as Postgres or MySQL.
Since Bolt is meant to be used as such a low-level piece of functionality, simplicity is key. The API will be small and only focus on getting values and setting values. That’s it.
聽起來非常符合我們的需求。花一分鐘來仔細看一下。
BoltDB是一個鍵值對儲存的資料庫,這意味著它沒有SQL RDBMS(如MySQL,PostgreSql等)中的表,沒有行,沒有列。取而代之的是,資料以鍵值對(類似golang中的map)的形式儲存。鍵值對存放在桶中(bucket
),意在給類似的鍵值對分組(跟RDBMS中的表類似)。因此,獲取一個值需要知道它的key以及所在的桶。
關於BoltDB的一個重點是沒有資料型別:鍵和值都是位元組陣列。由於我們要儲存go的結構體(尤其是Block
),需要對它進行序列化,例如實現一個把go結構體轉換成位元組陣列並能把它從位元組資料恢復的機制。對於這個我們將用encoding/gob
是因為它簡單並且是go標準庫中的。
資料庫結構
在我們開始實現持久化邏輯之前,先定下來如何在資料庫中儲存資料。我們將參考Bitcoin Core的方式。
簡單地說,Bitcoin Core用了兩個“桶”來存資料:
block
中存放描述鏈中所有區塊的元資料。chainstate
存放鏈的狀態,即當前所有未完成交易的輸出及一些元資料。
另外,區塊存放在磁碟上的獨立檔案中。這麼做是出於效能目的:讀取單個區塊不需要把所有(或多個)載入到記憶體中。我們將不實現這個。
block
中有如下鍵值對:
- ‘b’ + 32位元組的區塊雜湊 -> 區塊索引記錄
- ‘f’ + 4位元組的檔案號 -> 檔案資訊記錄
- ‘l’ + 4位元組的檔案號 -> 最後一個區塊用的檔案號
- ‘R’ + 1位元組的布林值 -> 是否正在重建索引
- ‘F’ + 1位元組的標識名 + 標識名字串 -> 1位元組的布林值:可以置為開和關的各種標識
- ‘t’ + 32位元組的交易雜湊 -> 交易索引記錄
在chainstate
中的鍵值對如下:
- ‘c’ + 32位元組的交易雜湊 -> 該交易未處理完的輸出記錄
- ‘B’ -> 32位元組的區塊雜湊:此雜湊取決於描述未處理完交易輸出的資料庫
(詳細資訊在這裡)
由於我們還沒有交易,現在只用一個blocks
桶。另外,如剛才所說,我們將把整個資料庫存到單個檔案中而不是把每個區塊存到獨立檔案中。因此與檔案號相關的內容都不需要了。現在我們用的key->value
對是這些:
- 32位元組的區塊雜湊 -> 區塊結構體(序列化後的)
- ‘l’ -> 鏈中最後一個區塊的雜湊值
這就是我們實現持久化機制需要的所有東西。
序列化
如之前所說,在BoltDB中只能用位元組陣列,我們要往資料庫中儲存Block
結構體。我們採用encoding/gob來對結構體進行序列化操作。
我們來實現Block
的Serialize
方法(為了簡潔或略掉了錯誤處理):
func (b *Block) Serialize() []byte {
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
err := encoder.Encode(b)
return result.Bytes()
}
這段程式碼很直觀:先宣告一個緩衝區用來存放序列化後的資料;然後初始化一個gob
編碼器並對區塊進行編碼;結果以位元組陣列的形式返回。
接下來,我們需要一個接收位元組陣列並返回一個Block
的反序列化方法。這不是方法而是一個獨立的函式:
func DeserializeBlock(d []byte) *Block {
var block Block
decoder := gob.NewDecoder(bytes.NewReader(d))
err := decoder.Decode(&block)
return &block
}
這就是序列化部分。
持久化
我們從NewBlockchain
方法開始看。目前它是建立一個Blockchain
例項並加上創世區塊。我們想要的是這樣:
- 開啟一個數據庫檔案。
- 檢查裡面是否存了區塊鏈。
- 如果裡面有:
- 建立一個新的
Blockchain
例項。 - 把
Blockchain
例項的末端設為資料庫中儲存的最後一個區塊
- 建立一個新的
- 如果沒有區塊鏈:
- 建立創世區塊
- 存入資料庫
- 把創世區塊的雜湊存作為最後區塊雜湊儲存
- 建立一個尾部指向創世區塊的
Blockchain
例項
程式碼如下:
func NewBlockchain() *Blockchain {
var tip []byte
db, err := bolt.Open(dbFile, 0600, nil)
err = db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
return nil
})
bc := Blockchain{tip, db}
return &bc
}
我們來重新審視一下這段程式碼。
db, err := bolt.Open(dbFile, 0600, nil)
這是開啟BoltDB檔案的標準方式。值得注意的是,檔案不存在時它不會返回錯誤。
err = db.Update(func(tx *bolt.Tx) error {
...
})
在BoltDB中,對資料庫的操作在事務中進行。事務分兩種:只讀的和讀寫的。因為我們希望吧創世區塊存入資料庫,這裡我們開啟了一個讀寫事務(db.Update(...)
)。
b := tx.Bucket([]byte(blocksBucket))
if b == nil {
genesis := NewGenesisBlock()
b, err := tx.CreateBucket([]byte(blocksBucket))
err = b.Put(genesis.Hash, genesis.Serialize())
err = b.Put([]byte("l"), genesis.Hash)
tip = genesis.Hash
} else {
tip = b.Get([]byte("l"))
}
上面的程式碼是這個函式的核心。我們獲取到存放區塊的桶:如果存在就讀取鍵l
;如果不存在就生成創世區塊,建立桶,並把創世區塊存進去,然後更新存放鏈中最後區塊雜湊值的l
鍵。
另外,注意我們建立Blockchain
的新方式:
bc := Blockchain{tip, db}
我們不再把所有區塊存進去了,而是隻存已儲存區塊鏈的末端。另外還儲存了一個數據庫連結,因為我們希望一旦開啟它就讓它隨著程式的執行一直處於開啟的狀態。因此現在的Blockchain
結構體是這樣的:
type Blockchain struct {
tip []byte
db *bolt.DB
}
下一個要更新的是AddBlock
方法:現在新增區塊不再像往數組裡新增元素那麼簡單了。從現在起要把區塊存到資料庫:
func (bc *Blockchain) AddBlock(data string) {
var lastHash []byte
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
newBlock := NewBlock(data, lastHash)
err = bc.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
return nil
})
}
一點一點地看一下:
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
這是另一種型別(只讀)的BoltDB事務。我們存資料庫中獲取最後區塊的雜湊值用來挖掘一個新的區塊雜湊。
newBlock := NewBlock(data, lastHash)
b := tx.Bucket([]byte(blocksBucket))
err := b.Put(newBlock.Hash, newBlock.Serialize())
err = b.Put([]byte("l"), newBlock.Hash)
bc.tip = newBlock.Hash
挖到新區塊後,我們把序列化後的資料存放到資料庫並更新l
鍵存的雜湊值。
完成!並不難,不是嗎
檢查區塊鏈
現在所有區塊都存在了資料庫中,因此我們可以重新開啟一條區塊鏈並給它新增新區塊。但是實現這個之後我們失去了一個不錯的特性:由於我們不再把區塊存到陣列中,不能列印區塊資訊了。我們來修復這個瑕疵!
BoltDB允許迭代一個桶中的所有鍵,但是鍵是以位元組順序儲存,我們想以區塊在區塊鏈中的順序來列印。另外,由於我們不想把所有區塊載入到記憶體(我們的區塊鏈資料庫可能很大…我們假裝它很大),我們將逐個讀取區塊。為此,需要一個區塊鏈迭代器:
type BlockchainIterator struct {
currentHash []byte
db *bolt.DB
}
我們想迭代區塊鏈中所有區塊時會建立一個迭代器,它儲存當前迭代到的區塊雜湊和一個數據庫連結。鑑於後者,一個迭代器在邏輯上依附於一個區塊鏈(儲存一個數據庫連結的Blockchain
例項),因此,它在一個Blockchain
的方法中建立:
func (bc *Blockchain) Iterator() *BlockchainIterator {
bci := &BlockchainIterator{bc.tip, bc.db}
return bci
}
注意,迭代器初始指向區塊鏈的末端,因此區塊是從上而下、從新到舊的順序獲取。事實上選擇末端意味著為一條區塊鏈“投票”。一條區塊鏈可以有多個分支,其中最長的認為是主分支。取得一個末端後(可以是區塊鏈中任意一個區塊)我們可以重建整條區塊鏈並得到它的長度以及建造它需要的工作。這也意味著一個末端是一條區塊鏈的識別符號。
BlockchainIterator
只做一件事:返回區塊鏈上的下一個區塊。
func (i *BlockchainIterator) Next() *Block {
var block *Block
err := i.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
encodedBlock := b.Get(i.currentHash)
block = DeserializeBlock(encodedBlock)
return nil
})
i.currentHash = block.PrevBlockHash
return block
}
以上就是資料庫部分!
CLI
到現在為止我們的實現沒有提供任何與程式互動的介面:我們只是在main
函式簡單地執行了NewBlockchain
、bc.AddBlock
。是時候改進這個了。我們想要如下命令:
blockchain_go addblock "Pay 0.031337 for a coffee"
blockchain_go printchain
所有命令列相關的操作將由CLI
結構體處理:
type CLI struct {
bc *Blockchain
}
它的“入口”是Run
函式:
func (cli *CLI) Run() {
cli.validateArgs()
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
}
我們用標準庫中的flag包來解析命令列引數:
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
首先,鍵兩條子命令,addblock
和printchain
,然後為前者新增-data
標識。printchain
沒有標識。
switch os.Args[1] {
case "addblock":
err := addBlockCmd.Parse(os.Args[2:])
case "printchain":
err := printChainCmd.Parse(os.Args[2:])
default:
cli.printUsage()
os.Exit(1)
}
接下來檢查使用者提供的命令並解析相關的flag
子命令。
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
然後檢查解析的是哪個子命令並執行相關的函式。
func (cli *CLI) addBlock(data string) {
cli.bc.AddBlock(data)
fmt.Println("Success!")
}
func (cli *CLI) printChain() {
bci := cli.bc.Iterator()
for {
block := bci.Next()
fmt.Printf("Prev. hash: %x\n", block.PrevBlockHash)
fmt.Printf("Data: %s\n", block.Data)
fmt.Printf("Hash: %x\n", block.Hash)
pow := NewProofOfWork(block)
fmt.Printf("PoW: %s\n", strconv.FormatBool(pow.Validate()))
fmt.Println()
if len(block.PrevBlockHash) == 0 {
break
}
}
}
這段程式碼跟我們之前寫的很像。唯一不同是現在我們用一個BlockchainIterator
來迭代區塊鏈中的區塊。
另外不要忘了照著修改main
函式:
func main() {
bc := NewBlockchain()
defer bc.db.Close()
cli := CLI{bc}
cli.Run()
}
值得一提的是,無論命令列引數是什麼都會建立一個新的Blockchain
。
就這麼多了!檢查一下是否一切正常:
$ blockchain_go printchain
No existing blockchain found. Creating a new one...
Mining the block containing "Genesis Block"
000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
$ blockchain_go addblock -data "Send 1 BTC to Ivan"
Mining the block containing "Send 1 BTC to Ivan"
000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Success!
$ blockchain_go addblock -data "Pay 0.31337 BTC for a coffee"
Mining the block containing "Pay 0.31337 BTC for a coffee"
000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
Success!
$ blockchain_go printchain
Prev. hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
Data: Pay 0.31337 BTC for a coffee
Hash: 000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148
PoW: true
Prev. hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
Data: Send 1 BTC to Ivan
Hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13
PoW: true
Prev. hash:
Data: Genesis Block
Hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109b
PoW: true
(此處有開啤酒的聲音)
結語
下次我們將實現地址、錢包、交易(可能)。所以請繼續關注!
連結: