原地址
原文地址 https://jeiwan.cc/posts/building-blockchain-in-go-part-3/ 簡介
目前為止,我們做了帶有工作量證明系統的區塊鏈,因此它是可以挖礦的。我們的實現與全功能的區塊鏈越來越接近了,但還缺乏一些重要特性。今天我們將把區塊鏈存到一個資料庫中,並在那之後做個簡單的命令列工具來對它進行操作。本質上,區塊鏈是分散式資料庫。我們暫時忽略“分布式”部分專註於“資料庫”部分。 資料庫選擇
我們當前的實現中還沒有資料庫;而是在每次執行程式的時候建立區塊鏈並儲存在記憶體中。我們無法複用一個區塊鏈,也不能與他人分享,因此需要把它存在磁碟上。
我們需要哪個資料庫。事實上任意一個都可以。在比特幣白皮書中沒有提到具體資料庫的使用,因此使用什麼資料庫取決於開發人員。現在普遍的比特幣實現參考是Satoshi Nakamoto最初發布的Bitcoin Core,它用的LevelDB(儘管2012年才發布)。我們將要用的是…
BoltDB
因為:
它是極簡的並便於使用。
用go實現。 不需要運行伺服器。 允許建立我們想要的資料結構。
以下摘自BoltDB在Github上的README
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,不過JSON、XML、ProtocolBuffers等都是可以的。我們用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 printchainNo existing blockchain found. Creating a new one...Mining the block containing "Genesis Block"000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109bPrev. hash:Data: Genesis BlockHash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109bPoW: true$ blockchain_go addblock -data "Send 1 BTC to Ivan"Mining the block containing "Send 1 BTC to Ivan"000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13Success!$ blockchain_go addblock -data "Pay 0.31337 BTC for a coffee"Mining the block containing "Pay 0.31337 BTC for a coffee"000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148Success!$ blockchain_go printchainPrev. hash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13Data: Pay 0.31337 BTC for a coffeeHash: 000000aa0748da7367dec6b9de5027f4fae0963df89ff39d8f20fd7299307148PoW: truePrev. hash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109bData: Send 1 BTC to IvanHash: 000000d7b0c76e1001cdc1fc866b95a481d23f3027d86901eaeb77ae6d002b13PoW: truePrev. hash:Data: Genesis BlockHash: 000000edc4a82659cebf087adee1ea353bd57fcd59927662cd5ff1c4f618109bPoW: true
(此處有開啤酒的聲音) 結語
下次我們將實現地址、錢包、交易(可能)。所以請繼續關注。
連結: 完整源碼 Bitcoin Core資料存放區 boltdb encoding/gob flag