Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)

交易(二)

在这个系列文章的一开始,我们就提到了,区块链是一个分布式数据库。不过在之前的文章中,我们选择性地跳过了“分布式”这个部分,而是将注意力都放到了“数据库”部分。到目前为止,我们几乎已经实现了一个区块链数据库的所有元素。今天,我们将会分析之前跳过的一些机制。而在本篇文章中,我们将会开始讨论区块链的分布式特性。

奖励

在上一篇文章中,我们略过的一个小细节是挖矿奖励。现在,我们已经可以来完善这个细节了。
挖矿奖励,实际上就是一笔 coinbase 交易。当一个挖矿节点开始挖出一个新块时,它会将交易从队列中取出,并在前面附加一笔 coinbase 交易。coinbase 交易只有一个输出,里面包含了矿工的公钥哈希。

实现奖励,非常简单,更新 send 即可:

func (cli *CLI) send(from, to string, amount int) {
if !ValidateAddress(from) {
		log.Panic("ERROR: Sender address is not valid")
	}
	if !ValidateAddress(to) {
		log.Panic("ERROR: Recipient address is not valid")
	}

	bc := NewBlockchain()
	UTXOSet := UTXOSet{bc}
	defer bc.db.Close()

	tx := NewUTXOTransaction(from, to, amount, &UTXOSet)
	cbTx := NewCoinbaseTX(from, "") //创建一笔coinbase交易
	txs := []*Transaction{cbTx, tx} //把coinbase交易添加到交易前面

	newBlock := bc.MineBlock(txs)

	fmt.Println("Success!")
}

在我们的实现中,创建交易的人同时挖出了新块,所以会得到一笔奖励。

UTXO集

在 Part 1: 持久化和命令行接口 中,我们研究了 Bitcoin Core 是如何在一个数据库中存储块的,并且了解到区块被存储在 blocks 数据库,交易输出被存储在 chainstate 数据库。回顾一下 chainstate 的结构:

  • c + 32 字节的交易哈希 -> 该笔交易的未花费交易输出记录
  • B + 32 字节的块哈希 -> 未花费交易输出的块哈希
    在之前那篇文章中,虽然我们已经实现了交易,但是并没有使用 chainstate 来存储交易的输出。所以,接下来我们继续完成这部分。

chainstate 不存储交易。它所存储的是 UTXO 集,也就是未花费交易输出的集合。除此以外,它还存储了“数据库表示的未花费交易输出的块哈希”,不过我们会暂时略过块哈希这一点,因为我们还没有用到块高度(但是我们会在接下来的文章中继续改进)。

那么,我们为什么需要 UTXO 集呢?
来思考一下我们早先实现的 Blockchain.FindUnspentTransactions 方法:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第1张图片
这个函数找到有未花费输出的交易。由于交易被保存在区块中,所以它会对区块链里面的每一个区块进行迭代,检查里面的每一笔交易。截止 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(高速缓存)!来开始写代码吧。


// UTXOSet结构表示UTXO集
type UTXOSet struct {
	Blockchain *Blockchain
}

我们将会使用一个单一数据库,但是我们会将 UTXO 集从存储在不同的 bucket 中。因此,UTXOSet 是与Blockchain 一起的。

// Reindex 初始化UTXO集
func (u UTXOSet) Reindex() {
	db := u.Blockchain.db
	bucketName := []byte(utxoBucket)

	err := db.Update(func(tx *bolt.Tx) error {
		err := tx.DeleteBucket(bucketName)//如果bucket存在就先移除
		if err != nil && err != bolt.ErrBucketNotFound {
			log.Panic(err)
		}

		_, err = tx.CreateBucket(bucketName)
		if err != nil {
			log.Panic(err)
		}

		return nil
	})
	if err != nil {
		log.Panic(err)
	}

	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)
			if err != nil {
				log.Panic(err)
			}

			err = b.Put(key, outs.Serialize())//最终将输出保存在bucket中
			if err != nil {
				log.Panic(err)
			}
		}

		return nil
	})
}

这个方法初始化了 UTXO 集。首先,如果 bucket 存在就先移除,然后从区块链中获取所有的未花费输出,最终将输出保存到 bucket 中。

Blockchain.FindUTXO 几乎跟 Blockchain.FindUnspentTransactions 一模一样,但是现在它返回了一个 TransactionID -> TransactionOutputs 的 map。

func (bc *Blockchain) FindUTXO() map[string]TXOutputs {
	UTXO := make(map[string]TXOutputs)
	spentTXOs := make(map[string][]int)
	bci := bc.Iterator()

	//...

	return UTXO
}

现在,UTXO 集可以用于发送币:

// FindSpendableOutputs 找到并返回未花费的输出
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 nil
	})
	if err != nil {
		log.Panic(err)
	}

	return accumulated, unspentOutputs
}

或者检查余额:

// FindUTXO 检查属于这个公钥哈希的余额
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
	})
	if err != nil {
		log.Panic(err)
	}

	return UTXOs
}

这是 Blockchain 方法的简单修改后的版本。这个 Blockchain 方法已经不再需要了。

有了 UTXO 集,也就意味着我们的数据(交易)现在已经被分开存储:实际交易被存储在区块链中,未花费输出被存储在 UTXO 集中。这样一来,我们就需要一个良好的同步机制,因为我们想要 UTXO 集时刻处于最新状态,并且存储最新交易的输出。但是我们不想每生成一个新块,就重新生成索引,因为这正是我们要极力避免的频繁区块链扫描。因此,我们需要一个机制来更新 UTXO 集:

// Update 从区块链中更新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)
						if err != nil {
							log.Panic(err)
						}
					} else {
						err := b.Put(vin.Txid, updatedOuts.Serialize())
						if err != nil {
							log.Panic(err)
						}
					}

				}
			}

			newOutputs := TXOutputs{}
			for _, out := range tx.Vout {
				newOutputs.Outputs = append(newOutputs.Outputs, out)
			}

			err := b.Put(tx.ID, newOutputs.Serialize())
			if err != nil {
				log.Panic(err)
			}
		}

		return nil
	})
	if err != nil {
		log.Panic(err)
	}
}

虽然这个方法看起来有点复杂,但是它所要做的事情非常直观。当挖出一个新块时,应该更新 UTXO 集。**更新意味着移除已花费输出,并从新挖出来的交易中加入未花费输出。**如果一笔交易的输出被移除,并且不再包含任何输出,那么这笔交易也应该被移除。相当简单!

现在让我们在必要的时候使用 UTXO 集:

func (cli *CLI) createBlockchain(address string) {
	if !ValidateAddress(address) {
		log.Panic("ERROR: Address is not valid")
	}
	bc := CreateBlockchain(address)
	defer bc.db.Close()

	UTXOSet := UTXOSet{bc}
	UTXOSet.Reindex()

	fmt.Println("Done!")
}

当一个新的区块链被创建以后,就会立刻进行重建索引。目前,这是 Reindex 唯一使用的地方,即使这里看起来有点“杀鸡用牛刀”,因为一条链开始的时候,只有一个块,里面只有一笔交易,Update 已经被使用了。不过我们在未来可能需要重建索引的机制。
在这里插入图片描述
当挖出一个新块时,UTXO 集就会进行更新。
让我们来检查一下如否如期工作:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第2张图片
很好!1NQ8HQjUv7Xvzbo9wZFs7pNwW2dG6jC7uy 地址接收到了 3 笔奖励:
1.一次是挖出创世块
2.一次是挖出块 000068b1b5fbdb932029a07efa26578115cf2249f727ab839159f81e38eaa9f1
3.一个是挖出块 000028902eaa65d298908a42fa3fe089774afc4a2e9ac0ed90e2322a7c52a061

Merkle树

在这篇文章中,我还想要再讨论一个优化机制。
上如上面所提到的,完整的比特币数据库(也就是区块链)需要超过 140 Gb 的磁盘空间。因为比特币的去中心化特性,网络中的每个节点必须是独立,自给自足的,也就是每个节点必须存储一个区块链的完整副本。随着越来越多的人使用比特币,这条规则变得越来越难以遵守:因为不太可能每个人都去运行一个全节点。并且,由于节点是网络中的完全参与者,它们负有相关责任:节点必须验证交易和区块。另外,要想与其他节点交互和下载新块,也有一定的网络流量需求。

在中本聪的 比特币原始论文 中,对这个问题也有一个解决方案:简易支付验证(Simplified Payment Verification, SPV)。SPV 是一个比特币轻节点,它不需要下载整个区块链,也不需要验证区块和交易。相反,它会在区块链查找交易(为了验证支付),并且需要连接到一个全节点来检索必要的数据。这个机制允许在仅运行一个全节点的情况下有多个轻钱包。

为了实现 SPV,需要有一个方式来检查是否一个区块包含了某笔交易,而无须下载整个区块。这就是 Merkle 树所要完成的事情。

比特币用 Merkle 树来获取交易哈希,哈希被保存在区块头中,并会用于工作量证明系统。到目前为止,我们只是将一个块里面的每笔交易哈希连接了起来,将在上面应用了 SHA-256 算法。虽然这是一个用于获取区块交易唯一表示的一个不错的途径,但是它没有利用到 Merkle 树。

来看一下 Merkle 树:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第3张图片
每个块都会有一个 Merkle 树,它从叶子节点(树的底部)开始,一个叶子节点就是一个交易哈希(比特币使用双 SHA256 哈希)。叶子节点的数量必须是双数,但是并非每个块都包含了双数的交易。因为,如果一个块里面的交易数为单数,那么就将最后一个叶子节点(也就是 Merkle 树的最后一个交易,不是区块的最后一笔交易)复制一份凑成双数。

从下往上,两两成对,连接两个节点哈希,将组合哈希作为新的哈希。新的哈希就成为新的树节点。重复该过程,直到仅有一个节点,也就是树根。根哈希然后就会当做是整个块交易的唯一标示,将它保存到区块头,然后用于工作量证明。

Merkle 树的好处就是一个节点可以在不下载整个块的情况下,验证是否包含某笔交易。并且这些只需要一个交易哈希,一个 Merkle 树根哈希和一个 Merkle 路径。

最后,来写代码:

// MerkleTree 表示一颗Merkle 树
type MerkleTree struct {
	RootNode *MerkleNode //连接到下个结点的根节点
}

// MerkleNode 代表一个Merkle树的节点
type MerkleNode struct {
	Left  *MerkleNode  //指向左分支
	Right *MerkleNode  //指向右分支
	Data  []byte  //数据
}

让我们首先来创建一个新的节点:


// NewMerkleNode 创建一个新的Merkle tree 节点
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
}

当生成一棵新树时,要确保的第一件事就是叶子节点必须是双数。然后,数据(也就是一个序列化后交易的数组)被转换成树的叶子,从这些叶子再慢慢形成一棵树。

// NewMerkleTree 创建一棵新的merkle树
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
}

btcsuite/btcd 是用数组实现的 merkle 树,因为这么做可以减少一半的内存使用。

现在,让我们来修改 Block.HashTransactions,它用于在工作量证明系统中获取交易哈希:

// HashTransactions 返回区块中交易的哈希
func (b *Block) HashTransactions() []byte {
	var transactions [][]byte

	for _, tx := range b.Transactions {
		transactions = append(transactions, tx.Serialize())//使用序列化后的交易构建一个merkle树
	}
	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 是操作符。脚本代码从左到右执行:将数据依次放入栈内,当遇到操作符时,就从栈内取出数据,并将操作符作用于数据,然后将结果作为栈顶元素。脚本的栈,实际上就是一个先进后出的内存存储:栈里的第一个元素最后一个取出,后面的每一个元素都会放到前一个元素之上。
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第4张图片
OP_ADD 从栈内取两个元素,将这两个元素进行相加,然后将结果重新放回栈内。OP_EQUAL 从栈内取两个元素,然后对这两个元素进行比较:如果它们相等,就在栈上放一个 true,否则放一个 false。脚本执行的结果就是栈顶元素:在我们的案例中,如果是 true,那么表明脚本执行成功。
现在来看一下在比特币中,是如何用脚本执行支付的:
OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG
这个脚本叫做 Pay to Public Key Hash(P2PKH),这是比特币最常用的一个脚本。它所做的事情就是向一个公钥哈希支付,也就是说,用某一个公钥锁定一些币。这是比特币支付的核心:没有账户,没有资金转移;只有一个脚本检查提供的签名和公钥是否正确。

这个脚本实际存储为两个部分:
1.第一个部分, ,存储在输入的 ScriptSig 字段。
2.第二部分,OP_DUP OP_HASH160 OP_EQUALVERYFY OP_CHECKSIG 存储在输出的 ScriptPubKey 里面。
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第5张图片

因此,输出定了解锁的逻辑,输入提供解锁输出的“钥匙”。然我们来执行一下这个脚本:
OP_DUP 对栈顶元素进行复制。OP_HASH160 取栈顶元素,然后用 RIPEMD160 对它进行哈希,再将结果送回到栈上。OP_EQUALVERIFY 将栈顶的两个元素进行比较,如果它们不相等,终止脚本。OP_CHECKSIG 通过对交易进行哈希,并使用 和 pubKey 来验证一笔交易的签名。最后的操作符有点复杂:它生成了一个修剪后的交易副本,对它进行哈希(因为它是一个被签名后的交易哈希),然后使用提供的 和 pubKey 检查签名是否正确。
有了一个这样的脚本语言,实际上也可以让比特币成为一个智能合约平台:除了将一个单一的公钥转移资金,这个语言还使得一些其他的支付方案成为可能。

总结

我们已经实现了一个基于区块链的加密货币的几乎所有关键特性。我们已经有了区块链,地址,挖矿和交易。但是要想给这些所有的机制赋予生命,让比特币成为一个全球系统,还有一个不可或缺的环节:共识(consensus)。在下一节的文章中,我们将会开始实现区块链的“去中心化(decenteralized)”。

网络

到目前为止,我们所构建的原型已经具备了区块链所有的关键特性:匿名,安全,随机生成的地址;区块链数据存储;工作量证明系统;可靠地存储交易。尽管这些特性都不可或缺,但是仍有不足。能够使得这些特性真正发光发热,使得加密货币成为可能的,是网络(network)。如果实现的这样一个区块链仅仅运行在单一节点上,有什么用呢?如果只有一个用户,那么这些基于密码学的特性,又有什么用呢?正是由于网络,才使得整个机制能够运转和发光发热。

你可以将这些区块链特性认为是规则(rule),类似于人类在一起生活,繁衍生息建立的规则,一种社会安排。区块链网络就是一个程序社区,里面的每个程序都遵循同样的规则,正是由于遵循着同一个规则,才使得网络能够长存。类似的,当人们都有着同样的想法,就能够将拳头攥在一起构建一个更好的生活。如果有人遵循着不同的规则,那么他们就将生活在一个分裂的社区(州,公社,等等)中。同样的,如果有区块链节点遵循不同的规则,那么也会形成一个分裂的网络(例如比特币的分叉)。

重点在于:如果没有网络,或者大部分节点都不遵守同样的规则,那么规则就会形同虚设,毫无用处!

声明:不幸的是,我并没有足够的时间来实现一个真实的 P2P 网络原型。本文我会展示一个最常见的场景,这个场景涉及不同类型的节点。继续改进这个场景,将它实现为一个 P2P网络,对你来说是一个很好的挑战和实践!除了本文的场景,我也无法保证在其他场景将会正常工作。抱歉!

区块链网络

区块链网络是去中心化的,这意味着没有服务器,客户端也不需要依赖服务器来获取或处理数据。在区块链网络中,有的是节点,每个节点是网络的一个完全(full-fledged)成员。节点就是一切:它既是一个客户端,也是一个服务器。这一点需要牢记于心,因为这与传统的网页应用非常不同。
区块链网络是一个 P2P(Peer-to-Peer,端到端)的网络,即节点直接连接到其他节点。它的拓扑是扁平的,因为在节点的世界中没有层级之分。下面是它的示意图:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第6张图片
要实现这样一个网络节点更加困难,因为它们必须执行很多操作。每个节点必须与很多其他节点进行交互,它必须请求其他节点的状态,与自己的状态进行比较,当状态过时的时候进行更新。

节点角色

管节点具有完备成熟的属性,但是它们也可以在网络中扮演不同角色。比如:

  • 矿工 这样的节点运行于强大或专用的硬件(比如
    ASIC)之上,它们唯一的目标是,尽可能快地挖出新块。矿工是区块链中唯一可能会用到工作量证明的角色,因为挖矿实际上意味着解决 PoW
    难题。在权益证明 PoS 的区块链中,没有挖矿。
  • 全节点
    这些节点验证矿工挖出来的块的有效性,并对交易进行确认。为此,他们必须拥有区块链的完整拷贝。同时,全节点执行路由操作,帮助其他节点发现彼此。对于网络来说,非常重要的一段就是要有足够多的全节点。因为正是这些节点执行了决策功能:他们决定了一个块或一笔交易的有效性。
  • SPV SPV 表示 Simplified Payment
    Verification,简单支付验证。这些节点并不存储整个区块链副本,但是仍然能够对交易进行验证(不过不是验证全部交易,而是一个交易子集,比如,发送到某个指定地址的交易)。一个
    SPV 节点依赖一个全节点来获取数据,可能有多个 SPV 节点连接到一个全节点。SPV
    使得钱包应用成为可能:一个人不需要下载整个区块链,但是仍能够验证他的交易。

网络简化

为了在目前的区块链原型中实现网络,我们不得不简化一些事情。因为我们没有那么多的计算机来模拟一个多节点的网络。当然,我们可以使用虚拟机或是 Docker 来解决这个问题,但是这会使一切都变得更复杂:你将不得不先解决可能出现的虚拟机或 Docker 问题,而我的目标是将全部精力都放在区块链实现上。所以,我们想要在一台机器上运行多个区块链节点,同时希望它们有不同的地址。为了实现这一点,我们将使用端口号作为节点标识符,而不是使用 IP 地址,比如将会有这样地址的节点:127.0.0.1:3000,127.0.0.1:3001,127.0.0.1:3002 等等。我们叫它端口节点(port node) ID,并使用环境变量 NODE_ID 对它们进行设置。故而,你可以打开多个终端窗口,设置不同的 NODE_ID 运行不同的节点。

实现

所以,当你下载 Bitcoin Core 并首次运行时,到底发生了什么呢?它必须连接到某个节点下载最新状态的区块链。考虑到你的电脑并没有意识到所有或是部分的比特币节点,那么连接到的“某个节点”到底是什么?
在 Bitcoin Core 中硬编码一个地址,已经被证实是一个错误:因为节点可能会被攻击或关机,这会导致新的节点无法加入到网络中。在 Bitcoin Core 中,硬编码了 DNS seeds。虽然这些并不是节点,但是 DNS 服务器知道一些节点的地址。当你启动一个全新的 Bitcoin Core 时,它会连接到一个种子节点,获取全节点列表,随后从这些节点中下载区块链。
不过在我们目前的实现中,无法做到完全的去中心化,因为会出现中心化的特点。我们会有三个节点:

  • 一个中心节点。所有其他节点都会连接到这个节点,这个节点会在其他节点之间发送数据。
  • 一个矿工节点。这个节点会在内存池中存储新的交易,当有足够的交易时,它就会打包挖出一个新块。
  • 一个钱包节点。这个节点会被用作在钱包之间发送币。但是与 SPV 节点不同,它存储了区块链的一个完整副本。

场景

本文的目标是实现如下场景:

  • 1.中心节点创建一个区块链。
  • 2.一个其他(钱包)节点连接到中心节点并下载区块链。
  • 3.另一个(矿工)节点连接到中心节点并下载区块链。
  • 4.钱包节点创建一笔交易。
  • 5.矿工节点接收交易,并将交易保存到内存池中。
  • 6.当内存池中有足够的交易时,矿工开始挖一个新块。
  • 7.当挖出一个新块后,将其发送到中心节点。
  • 8.钱包节点与中心节点进行同步。
  • 9.钱包节点的用户检查他们的支付是否成功。
    这就是比特币中的一般流程。尽管我们不会实现一个真实的 P2P 网络,但是我们会实现一个比较真实、也是比特币最常见最重要的用户场景。

版本

节点通过消息(message)进行交流。当一个新的节点开始运行时,它会从一个 DNS 种子获取几个节点,给它们发送 version 消息,在我们的实现看起来就像是这样:

//新的节点会给其它几个节点发送一下消息:
type verzion struct {
	Version    int //此处只有一个版本
	BestHeight int //节点高度
	AddrFrom   string //发送者的地址
}

由于我们仅有一个区块链版本,所以 Version 字段实际并不会存储什么重要信息。BestHeight 存储区块链中节点的高度。AddFrom 存储发送者的地址。
接收到 version 消息的节点应该做什么呢?它会响应自己的 version 消息。这是一种握手:如果没有事先互相问候,就不可能有其他交流。不过,这并不是出于礼貌:version 用于找到一个更长的区块链。当一个节点接收到 version 消息,它会检查本节点的区块链是否比 BestHeight 的值更大。如果不是,节点就会请求并下载缺失的块。

为了接收消息,我们需要一个服务器:

// StartServer 启动一个节点
func StartServer(nodeID, minerAddress string) { //minerAddress这个参数指定了接受挖矿奖励的地址
	nodeAddress = fmt.Sprintf("localhost:%s", nodeID)
	miningAddress = minerAddress 
	ln, err := net.Listen(protocol, nodeAddress)
	if err != nil {
		log.Panic(err)
	}
	defer ln.Close()

	bc := NewBlockchain(nodeID)
	//如果当前节点不是中心节点,必须向中心节点发送 version消息来查询是否自己的区块链已经过时
	if nodeAddress != knownNodes[0] {
		sendVersion(knownNodes[0], bc)
	}

	for {
		conn, err := ln.Accept()
		if err != nil {
			log.Panic(err)
		}
		go handleConnection(conn, bc)
	}
}

首先,我们对中心节点的地址进行硬编码:因为每个节点必须知道从何处开始初始化。minerAddress 参数指定了接收挖矿奖励的地址。

下面是发送version消息的函数:

//发送version消息来查询是否过期
func sendVersion(addr string, bc *Blockchain) {
	bestHeight := bc.GetBestHeight()
	payload := gobEncode(verzion{nodeVersion, bestHeight, nodeAddress})//后面的字节包含了gob编码的信息结构

	request := append(commandToBytes("version"), payload...)//前12个字节指定了命令名

	sendData(addr, request)
}

我们的消息,在底层就是字节序列。commandToBytes 看起来是这样:

//消息在底层就是用字节序列来实现
func commandToBytes(command string) []byte {
	var bytes [commandLength]byte
	//它创建一个 12 字节的缓冲区,并用命令名进行填充,将剩下的字节置为空
	for i, c := range command {
		bytes[i] = byte(c)
	}

	return bytes[:]
}

下面一个相反的函数:

//从字节序列提取出命令名
func bytesToCommand(bytes []byte) string {
	var command []byte

	for _, b := range bytes {
		if b != 0x0 {
			command = append(command, b)
		}
	}

	return fmt.Sprintf("%s", command)
}

当一个节点接收到一个命令,它会运行 bytesToCommand 来提取命令名,并选择正确的处理器处理命令主体:

//这个函数用来处理节点接收到的命令
func handleConnection(conn net.Conn, bc *Blockchain) {
	request, err := ioutil.ReadAll(conn)
	if err != nil {
		log.Panic(err)
	}
	command := bytesToCommand(request[:commandLength])
	fmt.Printf("Received %s command\n", command)

	switch command {
	...
	case "version":
		handleVersion(request, bc)
	default:
		fmt.Println("Unknown command!")
	}

	conn.Close()
}

下面是 version 命令处理器:

//version命令处理器
func handleVersion(request []byte, bc *Blockchain) {
	var buff bytes.Buffer
	var payload verzion

	buff.Write(request[commandLength:])
	dec := gob.NewDecoder(&buff)
	err := dec.Decode(&payload)//对请求进行解码,提取有效信息
	if err != nil {
		log.Panic(err)
	}

	myBestHeight := bc.GetBestHeight()
	foreignerBestHeight := payload.BestHeight //这是从节点中提取的
	//节点将从消息中提取的 BestHeight 与自身进行比较
	if myBestHeight < foreignerBestHeight {
		sendGetBlocks(payload.AddrFrom)//如果自身节点的区块链比较短,那么发送getblocks消息
	} else if myBestHeight > foreignerBestHeight {
		sendVersion(payload.AddrFrom, bc) //如果自身节点的区块链比较长,那么回复version消息
	}

	// sendAddr(payload.AddrFrom)
	if !nodeIsKnown(payload.AddrFrom) {
		knownNodes = append(knownNodes, payload.AddrFrom)
	}
}

首先,我们需要对请求进行解码,提取有效信息。所有的处理器在这部分都类似。
所以后面相类似的代码片段中,我们将省略这部分。
然后节点将从消息中提取的 BestHeight 与自身进行比较。如果自身节点的区块链更长,它会回复 version 消息;否则,它会发送 getblocks 消息。

getblocks

//给我看一下你有什么区块
type getblocks struct {
	AddrFrom string
}

getblocks 意为 “给我看一下你有什么区块”(在比特币中,这会更加复杂)。注意,它并没有说“把你全部的区块给我”,而是请求了一个块哈希的列表。这是为了减轻网络负载,因为区块可以从不同的节点下载,并且我们不想从一个单一节点下载数十 GB 的数据。
处理命令十分简单:

//用来处理getblocks命令的函数
func handleGetBlocks(request []byte, bc *Blockchain) {
	...

	blocks := bc.GetBlockHashes()
	sendInv(payload.AddrFrom, "block", blocks)
}

在我们简化版的实现中,它会返回 所有块哈希

inv

//使用inv来向其他节点展示当前节点有什么块和交易
type inv struct {
	AddrFrom string
	Type     string//表明类型:这是块还是交易
	Items    [][]byte
}

比特币使用 inv 来向其他节点展示当前节点有什么块和交易。再次提醒,它没有包含完整的区块链和交易,仅仅是哈希而已。Type 字段表明了这是块还是交易。
处理 inv 稍显复杂:

func handleInv(request []byte, bc *Blockchain) {
	...

	fmt.Printf("Recevied inventory with %d %s\n", len(payload.Items), payload.Type)

	if payload.Type == "block" {
		blocksInTransit = payload.Items//如果收到了块哈希,保存在这个变量中(二维数组的形式),来跟踪已下载的块。这是为了从不同节点下载块。

		blockHash := payload.Items[0]
		sendGetData(payload.AddrFrom, "block", blockHash)//在将块置于传送状态时,我们给 inv 消息的发送者发送 getdata 命令并更新 blocksInTransit

		newInTransit := [][]byte{}
		for _, b := range blocksInTransit {
			if bytes.Compare(b, blockHash) != 0 {
				newInTransit = append(newInTransit, b)
			}
		}
		blocksInTransit = newInTransit
	}

	if payload.Type == "tx" {
		txID := payload.Items[0]//只拿到第一个哈希,因为这里不会发送有多重哈希的inv。

		if mempool[hex.EncodeToString(txID)].ID == nil {//如果内存池中没有这个哈希,就发送getdata消息
			sendGetData(payload.AddrFrom, "tx", txID)
		}
	}
}

如果收到块哈希,我们想要将它们保存在 blocksInTransit 变量来跟踪已下载的块。这能够让我们从不同的节点下载块。在将块置于传送状态时,我们给 inv 消息的发送者发送 getdata 命令并更新 blocksInTransit。在一个真实的 P2P 网络中,我们会想要从不同节点来传送块。
在我们的实现中,我们永远也不会发送有多重哈希的 inv。这就是为什么当 payload.Type == “tx” 时,只会拿到第一个哈希。然后我们检查是否在内存池中已经有了这个哈希,如果没有,发送 getdata 消息。

getdata


//用于某个块或交易的请求,它可以仅包含一个块或交易的 ID
type getdata struct {
	AddrFrom string
	Type     string
	ID       []byte
}

getdata 用于某个块或交易的请求,它可以仅包含一个块或交易的 ID。


//GetData的处理器
func handleGetData(request []byte, bc *Blockchain) {
	...

	if payload.Type == "block" { //如果请求块,则返回一个块
		block, err := bc.GetBlock([]byte(payload.ID))
		if err != nil {
			return
		}

		sendBlock(payload.AddrFrom, &block)
	}

	if payload.Type == "tx" {//如果它们请求一笔交易,则返回交易
		txID := hex.EncodeToString(payload.ID)
		tx := mempool[txID]

		sendTx(payload.AddrFrom, &tx)
		// delete(mempool, txID)
	}
}

这个处理器比较地直观:如果它们请求一个块,则返回块;如果它们请求一笔交易,则返回交易。注意,我们并不检查实际上是否已经有了这个块或交易。这是一个缺陷 ~

block和tx


type block struct {
	AddrFrom string
	Block    []byte
}

type tx struct {
	AddFrom     string
	Transaction []byte
}

实际完成数据转移的正是这些消息。
处理 block 消息十分简单:


func handleBlock(request []byte, bc *Blockchain) {
	...

	blockData := payload.Block
	block := DeserializeBlock(blockData)

	fmt.Println("Recevied a new block!")
	bc.AddBlock(block)//当接收到一个新块时,我们把它放到区块链里面

	fmt.Printf("Added block %x\n", block.Hash)

	if len(blocksInTransit) > 0 {
		blockHash := blocksInTransit[0]
		sendGetData(payload.AddrFrom, "block", blockHash)

		blocksInTransit = blocksInTransit[1:] //如果还有更多的区块需要下载,我们继续从上一个下载的块的那个节点继续请求
	} else {
		UTXOSet := UTXOSet{bc}
		UTXOSet.Reindex() //当最后把所有块都下载完后,对 UTXO 集进行重新索引
	}
}

当接收到一个新块时,我们把它放到区块链里面。如果还有更多的区块需要下载,我们继续从上一个下载的块的那个节点继续请求。当最后把所有块都下载完后,对 UTXO 集进行重新索引。

TODO:并非无条件信任,我们应该在将每个块加入到区块链之前对它们进行验证。 TODO: 并非运行 UTXOSet.Reindex(),
而是应该使用 UTXOSet.Update(block),因为如果区块链很大,它将需要很多时间来对整个 UTXO 集重新索引。

处理 tx 消息是最困难的部分!

func handleTx(request []byte, bc *Blockchain) {
...

	txData := payload.Transaction
	tx := DeserializeTransaction(txData)
	mempool[hex.EncodeToString(tx.ID)] = tx //将新交易放到内存池中

	if nodeAddress == knownNodes[0] { //检查当前节点是否是中心节点
		for _, node := range knownNodes {
			if node != nodeAddress && node != payload.AddFrom {
				sendInv(node, "tx", [][]byte{tx.ID}) //将新的交易推送给网络中的其他节点
			}
		}
	} else {
		if len(mempool) >= 2 && len(miningAddress) > 0 {//矿工代码块
		//如果当前节点(矿工)的内存池中有两笔或更多的交易,开始挖矿
		MineTransactions:
			var txs []*Transaction

			for id := range mempool {
				tx := mempool[id]
				if bc.VerifyTransaction(&tx) {//内存池中所有交易都是通过验证的
					txs = append(txs, &tx)
				}
			}

			if len(txs) == 0 {//如果没有有效交易,则挖矿中断
				fmt.Println("All transactions are invalid! Waiting for new ones...")
				return
			}

			cbTx := NewCoinbaseTX(miningAddress, "")//附带奖励的 coinbase 交易
			txs = append(txs, cbTx)//验证后的交易被放到一个块里

			newBlock := bc.MineBlock(txs)
			UTXOSet := UTXOSet{bc}
			UTXOSet.Reindex()//当块被挖出来以后,UTXO 集会被重新索引。

			fmt.Println("New block is mined!")

			for _, tx := range txs {
				txID := hex.EncodeToString(tx.ID)
				delete(mempool, txID)//当一笔交易被挖出来以后,就会被从内存池中移除
			}

			for _, node := range knownNodes {
				if node != nodeAddress {
					sendInv(node, "block", [][]byte{newBlock.Hash})//前节点所连接到的所有其他节点,接收带有新块哈希的 inv 消息
				}
			}

			if len(mempool) > 0 {
				goto MineTransactions
			}
		}//矿工代码块
	}
}

首先要做的事情是将新交易放到内存池中(再次提醒,在将交易放到内存池之前,必要对其进行验证)。下个片段:检查当前节点是否是中心节点。在我们的实现中,中心节点并不会挖矿。它只会将新的交易推送给网络中的其他节点。
下一个很大的代码片段是矿工节点“专属”。让我们对它进行一下分解:
在这里插入图片描述

miningAddress 只会在矿工节点上设置。如果当前节点(矿工)的内存池中有两笔或更多的交易,开始挖矿:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第7张图片
首先,内存池中所有交易都是通过验证的。无效的交易会被忽略,如果没有有效交易,则挖矿中断。
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第8张图片
验证后的交易被放到一个块里,同时还有附带奖励的 coinbase 交易。当块被挖出来以后,UTXO 集会被重新索引。
TODO: 提醒,应该使用 UTXOSet.Update 而不是 UTXOSet.Reindex.

Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第9张图片
当一笔交易被挖出来以后,就会被从内存池中移除。当前节点所连接到的所有其他节点,接收带有新块哈希的 inv 消息。在处理完消息后,它们可以对块进行请求。

结果

让我们来回顾一下上面定义的场景。
首先,在第一个终端窗口中将 NODE_ID 设置为 3000(export NODE_ID=3000)。为了让你知道什么节点执行什么操作,我会使用像 NODE 3000 或 NODE 3001 进行标识。在windows环境下,可以使用set NODE_ID 3000命令。

NODE 3000

创建一个钱包和一个新的区块链,然后,会生成一个仅包含创世块的区块链。我们需要保存块,并在其他节点使用。创世块承担了一条链标识符的角色(在 Bitcoin Core 中,创世块是硬编码的)
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第10张图片
cp的命令用来复制文件。在windows下用“copy”命令来代替。

NODE 3001

接下来,打开一个新的终端窗口,将 node ID 设置为 3001。这会作为一个钱包节点。通过 blockchain_go createwallet 生成一些地址,我们把这些地址叫做 WALLET_1, WALLET_2, WALLET_3.
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第11张图片

NODE 3000

向钱包地址发送一些币:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第12张图片
-mine 标志指的是块会立刻被同一节点挖出来。我们必须要有这个标志,因为初始状态时,网络中没有矿工节点。
启动节点:
在这里插入图片描述
这个节点会持续运行,直到本文定义的场景结束。

NODE 3001

启动上面保存创世块节点的区块链,然后运行节点:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第13张图片
它会从中心节点下载所有区块。为了检查一切正常,暂停节点运行并检查余额:
在这里插入图片描述
你还可以检查 CENTRAL_NODE 地址的余额,因为 node 3001 现在有它自己的区块链:
在这里插入图片描述

NODE 3002

打开一个新的终端窗口,将它的 ID 设置为 3002,然后生成一个钱包。这会是一个矿工节点。初始化区块链,启动节点:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第14张图片

NODE 3001

发送一些币:
在这里插入图片描述

NODE 3002

迅速切换到矿工节点,你会看到挖出了一个新块!同时,检查中心节点的输出。
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第15张图片

NODE 3001

切换到钱包节点并启动节点,它会下载最近挖出来的块:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第16张图片
暂停节点并检查余额:
Go语言实现区块链与加密货币-Part3(交易优化,单机模拟多节点通信)_第17张图片
就是这么多了!

你可能感兴趣的:(区块链,golang)