在Go中构建区块链-3-持久性和CLI

使用golang构建简单的区块链

本文译自 Building Blockchain in Go. Part 3: Persistence and CLI

简介

到目前为止,我们已经构建了一个具有工作量证明系统的区块链,这使得挖矿成为可能。我们的实现越来越接近一个完全功能的区块链,但仍然缺少一些重要的功能。今天我们将开始将区块链存储在数据库中,之后我们将创建一个简单的命令行界面来执行与区块链相关的操作。在本质上,区块链是一个分布式数据库。我们将暂时省略“分布式”部分,专注于“数据库”部分。

数据库选择

目前,我们的实现中没有数据库;相反,每次运行程序时,我们都会创建新的区块并将其存储在内存中。我们无法重复使用区块链,也无法与他人共享,因此我们需要将其存储在磁盘上。

我们需要哪种数据库?实际上,任何数据库都可以。在原始的比特币论文中,并未提及使用特定的数据库,因此由开发人员决定使用哪个数据库。比特币核心(最初由中本聪发布,目前是比特币的参考实现)使用LevelDB(尽管它仅在2012年才引入到客户端)。而我们将使用…

BoltDB

因为:

  1. 它简单而简约。
  2. 它是用Go语言实现的。
  3. 它不需要运行服务器。
  4. 它允许构建我们想要的数据结构。

从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(桶)”来存储数据:

  1. blocks 存储描述链中所有区块的元数据。
  2. chainstate 存储链的状态,即所有当前未花费的交易输出和一些元数据。

此外,区块以单独的文件形式存储在磁盘上。这是出于性能考虑:读取单个区块不需要将所有(或一些)区块加载到内存中。我们不会实现这一点。

在 blocks 中,键值对如下:

  • ‘b’ + 32字节的区块哈希 -> 区块索引记录
  • ‘f’ + 4字节的文件号 -> 文件信息记录
  • ’l’ -> 4字节的文件号:最后一个使用的区块文件号
  • ‘R’ -> 1字节的布尔值:是否正在重新索引的过程中
  • ‘F’ + 1字节的标志名长度 + 标志名字符串 -> 1字节的布尔值:各种可能打开或关闭的标志
  • ’t’ + 32字节的交易哈希 -> 交易索引记录

在 chainstate 中,键值对如下:

  • ‘c’ + 32字节的交易哈希 -> 该交易的未花费交易输出记录

  • ‘B’ -> 32字节的区块哈希:数据库表示未花费交易输出的区块哈希

    (详细的解释可以在这里找到)

由于我们还没有交易,我们将只使用 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的新实例并将创世块添加到其中。我们希望它执行以下操作:

  1. 打开一个DB文件。
  2. 检查是否有存储在其中的区块链。
  3. 如果存在区块链:
    • 创建一个新的Blockchain实例。
    • 将Blockchain实例的尖端设置为存储在DB中的最后一个区块哈希。
  4. 如果没有现有的区块链:
    • 创建创世块。
    • 存储在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

下次我们将实现地址、钱包和(可能)交易。所以敬请期待!

结论

我们的区块链离实际架构更近了一步:现在添加区块需要进行艰苦工作,因此挖矿成为可能。但它仍然缺少一些关键功能:区块链数据库不是持久化的,没有钱包、地址、交易,也没有共识机制。所有这些内容我们将在未来的文章中实现,目前祝您挖矿愉快!

本博客已稳定运行 小时 分钟
共发表 31 篇文章 · 总计 82.93 k 字
本站总访问量
Built with Hugo
主题 StackJimmy 设计