本文译自 Building Blockchain in Go. Part 3: Persistence and CLI
简介
到目前为止,我们已经构建了一个具有工作量证明系统的区块链,这使得挖矿成为可能。我们的实现越来越接近一个完全功能的区块链,但仍然缺少一些重要的功能。今天我们将开始将区块链存储在数据库中,之后我们将创建一个简单的命令行界面来执行与区块链相关的操作。在本质上,区块链是一个分布式数据库。我们将暂时省略“分布式”部分,专注于“数据库”部分。
数据库选择
目前,我们的实现中没有数据库;相反,每次运行程序时,我们都会创建新的区块并将其存储在内存中。我们无法重复使用区块链,也无法与他人共享,因此我们需要将其存储在磁盘上。
我们需要哪种数据库?实际上,任何数据库都可以。在原始的比特币论文中,并未提及使用特定的数据库,因此由开发人员决定使用哪个数据库。比特币核心(最初由中本聪发布,目前是比特币的参考实现)使用LevelDB(尽管它仅在2012年才引入到客户端)。而我们将使用…
BoltDB
因为:
- 它简单而简约。
- 它是用Go语言实现的。
- 它不需要运行服务器。
- 它允许构建我们想要的数据结构。
从BoltDB在GitHub上的README中:
Bolt是一种纯Go键/值存储,灵感来自Howard Chu的LMDB项目。该项目的目标是为那些不需要像Postgres或MySQL这样的完整数据库服务器的项目提供一个简单、快速且可靠的数据库。
由于Bolt旨在作为这样一个低级功能来使用,简单性至关重要。API 将很小,并且仅关注获取值和设置值。就是这样。
听起来完全符合我们的需求!让我们花一分钟来了解一下。
BoltDB是一种键/值存储,这意味着没有像 SQL 关系型数据库管理系统(MySQL、PostgreSQL等)中的表,也没有行、列。相反,数据存储为键-值对(类似于 Golang 中的映射)。键-值对存储在桶中,桶旨在组织类似的键-值对(这类似于关系型数据库管理系统中的表)。因此,为了获取一个值,您需要知道一个桶和一个键。
关于BoltDB的一件重要的事情是它没有数据类型:键和值都是字节数组。由于我们将在其中存储Go结构体(特别是Block),我们需要对它们进行序列化,即实现一种将Go结构体转换为字节数组并从字节数组还原的机制。我们将使用 encoding/gob 进行此操作,但也可以使用 JSON、XML、Protocol Buffers 等。我们使用 encoding/gob 是因为它简单且是Go标准库的一部分。
数据库结构
在开始实现持久性逻辑之前,我们首先需要决定如何在数据库中存储数据。为此,我们将参考比特币核心的存储方式。
简单来说,比特币核心使用两个“buckets(桶)”来存储数据:
blocks
存储描述链中所有区块的元数据。
chainstate
存储链的状态,即所有当前未花费的交易输出和一些元数据。
此外,区块以单独的文件形式存储在磁盘上。这是出于性能考虑:读取单个区块不需要将所有(或一些)区块加载到内存中。我们不会实现这一点。
在 blocks 中,键值对如下:
- ‘b’ + 32字节的区块哈希 -> 区块索引记录
- ‘f’ + 4字节的文件号 -> 文件信息记录
- ’l’ -> 4字节的文件号:最后一个使用的区块文件号
- ‘R’ -> 1字节的布尔值:是否正在重新索引的过程中
- ‘F’ + 1字节的标志名长度 + 标志名字符串 -> 1字节的布尔值:各种可能打开或关闭的标志
- ’t’ + 32字节的交易哈希 -> 交易索引记录
在 chainstate 中,键值对如下:
由于我们还没有交易,我们将只使用 blocks 桶。此外,如上所述,我们将整个数据库存储为单个文件,而不是将区块存储在单独的文件中。因此,我们将不需要与文件号相关的任何内容。因此,这些是我们将使用的键值对:
- 32字节的区块哈希 -> 区块结构(序列化)
- ’l’ -> 链中最后一个区块的哈希
这就是我们开始实现持久性机制所需要知道的一切。
序列化
如前所述,在BoltDB中,值只能是[]byte类型,而我们想要在数据库中存储Block结构体。我们将使用 encoding/gob 来序列化结构体。
让我们实现Block的Serialize方法(为简洁起见,省略了错误处理):
1
2
3
4
5
6
7
8
|
func (b *Block) Serialize() []byte {
var result bytes.Buffer
encoder := gob.NewEncoder(&result)
err := encoder.Encode(b)
return result.Bytes()
}
|
这一部分很简单:首先,我们声明一个缓冲区,用于存储序列化数据;然后,我们初始化一个gob编码器并对区块进行编码;最后,将结果作为字节数组返回。
接下来,我们需要一个反序列化函数,它将接收一个字节数组作为输入,并返回一个Block。这不会是一个方法,而是一个独立的函数:
1
2
3
4
5
6
7
8
|
func DeserializeBlock(d []byte) *Block {
var block Block
decoder := gob.NewDecoder(bytes.NewReader(d))
err := decoder.Decode(&block)
return &block
}
|
这就是系列化的内容!
持久性
让我们从NewBlockchain函数开始。目前,它创建了一个Blockchain的新实例并将创世块添加到其中。我们希望它执行以下操作:
- 打开一个DB文件。
- 检查是否有存储在其中的区块链。
- 如果存在区块链:
- 创建一个新的Blockchain实例。
- 将Blockchain实例的尖端设置为存储在DB中的最后一个区块哈希。
- 如果没有现有的区块链:
- 创建创世块。
- 存储在DB中。
- 将创世块的哈希保存为最后一个区块哈希。
- 创建一个新的Blockchain实例,其尖端指向创世块。
在代码中,它看起来像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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
}
|
让我们一块一块地回顾一下。
1
|
db, err := bolt.Open(dbFile, 0600, nil)
|
这是打开 BoltDB 文件的标准方法。请注意,如果没有这样的文件,它不会返回错误。
1
2
3
|
err = db.Update(func(tx *bolt.Tx) error {
...
})
|
在 BoltDB 中,数据库操作在事务内运行。事务有两种类型:只读和读写。在这里,我们打开一个读写事务(db.Update(…)),因为我们希望将创世块放入数据库中。
1
2
3
4
5
6
7
8
9
10
11
|
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 键。
另外,请注意创建区块链的新方法:
1
|
bc := Blockchain{tip, db}
|
我们不再将所有区块都存储在其中,而只存储链的尖端。此外,我们存储了一个数据库连接,因为我们希望在程序运行时只打开一次,并保持其打开状态。因此,Blockchain结构现在如下所示:
1
2
3
4
|
type Blockchain struct {
tip []byte
db *bolt.DB
}
|
接下来我们要更新的是AddBlock方法:现在添加区块到链上不再像在数组中添加元素那样简单。从现在开始,我们将在数据库中存储区块:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
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
})
}
|
让我们逐条回顾一下:
1
2
3
4
5
6
|
err := bc.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(blocksBucket))
lastHash = b.Get([]byte("l"))
return nil
})
|
这是 BoltDB 事务的另一种(只读)类型。在这里,我们从数据库中获取最后一个块哈希,并用它来挖掘新的块哈希。
1
2
3
4
5
|
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允许在一个桶中迭代所有键,但这些键是按字节排序的,而我们希望按照它们在区块链中的顺序打印出区块。此外,由于我们不想将所有区块加载到内存中(我们的区块链数据库可能非常庞大!..或者我们就假装它可能非常庞大),我们将逐个读取它们。为此,我们将需要一个区块链迭代器:
1
2
3
4
|
type BlockchainIterator struct {
currentHash []byte
db *bolt.DB
}
|
每当我们想要在区块链中迭代区块时,都将创建一个迭代器,它将存储当前迭代的区块哈希和到DB的连接。由于后者,迭代器在逻辑上与区块链相关联(它是一个存储DB连接的Blockchain实例),因此它是在Blockchain方法中创建的:
1
2
3
4
5
|
func (bc *Blockchain) Iterator() *BlockchainIterator {
bci := &BlockchainIterator{bc.tip, bc.db}
return bci
}
|
注意,迭代器最初指向区块链的尖端,因此区块将从顶部到底部获取,从最新到最旧。实际上,选择一个尖端意味着“投票”支持一个区块链。一个区块链可能有多个分支,其中最长的被认为是主分支。在获取到一个尖端(它可以是区块链中的任何一个块)之后,我们可以重建整个区块链,找到它的长度以及构建它所需的工作量。这也意味着尖端是区块链的一种标识符。
BlockchainIterator 只会做一件事:从区块链返回下一个区块。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
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
到目前为止,我们的实现还没有提供任何与程序交互的接口:我们只是在主函数中执行了NewBlockchain、bc.AddBlock。是时候改进一下了!我们希望有以下这些命令:
blockchain_go addblock "为一杯咖啡支付 0.031337"
blockchain_go printchain
所有与命令行相关的操作将由CLI结构处理:
1
2
3
|
type CLI struct {
bc *Blockchain
}
|
它的“入口点”是 Run 函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
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包来解析命令行参数。
1
2
3
|
addBlockCmd := flag.NewFlagSet("addblock", flag.ExitOnError)
printChainCmd := flag.NewFlagSet("printchain", flag.ExitOnError)
addBlockData := addBlockCmd.String("data", "", "Block data")
|
首先,我们创建两个子命令,addblock 和 printchain,然后为前者添加 -data 标志。 printchain 不会有任何标志。
1
2
3
4
5
6
7
8
9
|
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子命令。
1
2
3
4
5
6
7
8
9
10
11
|
if addBlockCmd.Parsed() {
if *addBlockData == "" {
addBlockCmd.Usage()
os.Exit(1)
}
cli.addBlock(*addBlockData)
}
if printChainCmd.Parsed() {
cli.printChain()
}
|
接下来我们检查哪些子命令被解析并运行相关函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
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 函数:
1
2
3
4
5
6
7
|
func main() {
bc := NewBlockchain()
defer bc.db.Close()
cli := CLI{bc}
cli.Run()
}
|
请注意,无论提供什么命令行参数,都会创建一个新的区块链。
就是这样!让我们检查一切是否按预期工作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
$ 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
|
下次我们将实现地址、钱包和(可能)交易。所以敬请期待!
结论
我们的区块链离实际架构更近了一步:现在添加区块需要进行艰苦工作,因此挖矿成为可能。但它仍然缺少一些关键功能:区块链数据库不是持久化的,没有钱包、地址、交易,也没有共识机制。所有这些内容我们将在未来的文章中实现,目前祝您挖矿愉快!