在之前的课程内容中,我们的关注点集中在单一节点上的状态机逻辑实现上,这是因为 状态机复制的问题,是由tendermint负责完成的:我们在任何一个节点提交的交易请求, tendermint可以透明地帮我们在多个节点间实现同步。
在这一章,我们将通过前面已经学习过的简单的ABCI计数应用来学习如何部署多个tendermint节点, 并进一步理解tendermint共识的建立过程。为了简化操作,我们将首先了解如何将ABCI 应用与tendermint库集成为单一可执行文件,然后利用这个单一完整的程序来部署4个 节点旳区块链:
在tendermint节点组成的区块链中有两种节点:验证节点和观察节点,只有验证节点 参与共识的建立。在这一章,我们将同时学习如何部署验证节点和观察节点。
tendermint是拜占庭容错的共识机制,因此在3f+1个验证节点中,允许不超过f个节点 出现拜占庭错误。在活性与可靠性中tendermint选择了可靠性,因此当3f+1个节点中 超过f个节点出现拜占庭错误后,整个系统将停机,我们也将通过实验观察到这一点。
使用go语言开发tendermint链上应用有一个额外的优势,就是可以将tendermint节点 与ABCI应用集成在单一程序内发布。
我们还是使用计数状态机作为ABCI应用示例,在多个节点之间维护单一的计数值:
为简化问题,我们仅实现三个主要的ABCI接口 —— CheckTx、DeliverTx和Query, 同时,使用0x01~0x03分别表示递增、递减和复位交易:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24type App struct {
types.BaseApplication
Value int
}
func (app *App) CheckTx(tx []byte) (rsp types.ResponseCheckTx) {
if tx[0] < 0x04 { return }
rsp.Code = 1
return
}
func (app *App) DeliverTx(tx []byte) (rsp types.ResponseDeliverTx) {
switch tx[0] {
case 0x01 : app.Value += 1
case 0x02 : app.Value -= 1
case 0x03 : app.Value = 0
}
return
}
func (app *App) Query(req types.RequestQuery) (rsp types.ResponseQuery) {
rsp.Log = fmt.Sprintf("counter: %d",app.Value)
return
}
你可以查看~/repo/go/src/hubwiz.com/c8/smr.go中的完整示例代码。
Tendermint的命令行程序是基于spf13/cobra开发包开发的,每个子命令都对应着 一个cobra/Command结构的实例,例如,初始化节点子命令init对应着InitFilesCmd这个 对象,而RootCmd则对应着根命令:
因此我们可以利用这些封装好的cobra命令,非常快速地生成一个具有节点同样功能 的命令行程序:1
2
3
4
5
6
7
8
9func main(){
root := commands.RootCmd
root.AddCommand(commands.InitFilesCmd)
root.AddCommand(commands.ResetAllCmd)
exec := cli.PrepareBaseCmd(root,"WIZ",".")
exec.Execute()
}
PrepareBaseCmd()方法将指定的Command实例转换为一个Executor对象,它进行 必要的环境变量设置(根据第二个参数指定的前缀),并确定命令行的默认工作目录 (第三个参数)。
在上面代码中,我们将默认工作目录设置为当前目录,这意味着在默认情况下, 当执行init子命令时,将在当前目录下生成data和config子目录。不过在RootCmd 中定义了全局标志–home,可以在命令行指定其他目录为节点工作目录。例如, 下面的代码将在当前目录下的n1子目录中执行初始化命令:
~/repo/go/src/hubwiz.com/c8$ go run smr.go init –home n1
与其他cobra命令不同,用于启动节点旳node子命令对应于一个NewRunNodeCmd()方法, 它是一个构建cobra/Command实例的工厂方法,需要传入一个NodeProvider类型的变量:
NodeProvider其实是返回节点实例的函数,其原型定义如下:1
type NodeProvider func(config *cfg.Config, logger log.Logger) (*node.Node, error)
其中,参数config(节点配置)和logger(日志)都是由tendermint框架在启动时传入的参数。
NodeProvider是基于具体的abci应用创建的。例如,下面的代码利用abci应用实例创建一个 NodeProvider实例,并最终得到cobra/Command实例添加到根命令中:1
2
3app := NewApp()
nodeProvider := makeNodeProvider(app)
root.AddCommand(commands.NewRunNodeCmd(nodeProvider))
makeNodeProvider()函数返回NodeProvider实例,其实现代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15func makeNodeProvider(app types.Application) node.NodeProvider {
return func(config *cfg.Config, logger log.Logger) (*node.Node, error) {
nodeKey, err := p2p.LoadOrGenNodeKey(config.NodeKeyFile())
if err != nil { return nil, err }
return node.NewNode(config,
privval.LoadOrGenFilePV(config.PrivValidatorFile()),
nodeKey,
proxy.NewLocalClientCreator(app),
node.DefaultGenesisDocProviderFunc(config),
node.DefaultDBProvider,
node.DefaultMetricsProvider(config.Instrumentation),
logger)
}
}
编译安装
在整合了abci应用和cobra命令代码后,为了简化后续的键盘输入,我们将其 编译安装:
~/repo/go/src/hubwiz.com/c8$ go install smr.go
现在可以很简单地执行节点程序:
~/repo/go/hubwiz.com/c8$ smr
Tendermint支持两种类型的节点:验证节点(Validator)和观察节点(Observer)。显然,对于 网络中的第一个节点而言,必须是一个验证节点 —— 否则没有办法出块了:
第一个节点旳基本参数是:
P2P通信端口:26656
RPC服务端口:26657
节点目录:n1
节点初始化与启动
进入1#终端,执行init子命令初始化第一个节点,注意使用–home标志指定节点目录:
~/repo/go/src/hubwiz.com/c8$ smr init –home n1
执行show_node_id子命令显示并记录第一个节点旳ID:
~/repo/go/src/hubwiz.com/c8$ smr show_node_id –home n1
输出结果为节点旳ID,我们后续会用到,请记下来(你的ID应该与此不同):
fd8debec2b97adfa0f6e8bae939c22a69cda9741
然后执行node子命令启动第一个节点:
~/repo/go/src/hubwiz.com/c8$ smr node –home n1
测试状态机交易
进入2#终端,执行如下交易并分别查询:
增加:
~$ curl localhost:26657/broadcast_tx_commit?tx=0x0101
~$ curl localhost:26657/abci_query
减少:
~$ curl localhost:26657/broadcast_tx_commit?tx=0x0201
~$ curl localhost:26657/abci_query
复位:
~$ curl localhost:26657/broadcast_tx_commit?tx=0x0301
~$ curl localhost:26657/abci_query
现在我们在网络中添加一个观察节点,它不会参与到共识的建立:
第二个节点旳基本参数是:
P2P通信端口:36656
RPC服务端口:36657
节点目录:n2
节点初始化配置与启动
进入2#终端,执行init子命令初始化第二个节点:
~/repo/go/src/hubwiz.com/c8$ smr init –home n2
由于这个节点是观察节点,因此我们可以直接使用验证节点旳创世文件:
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n2/config/genesis.json
同时修改n2/config/config.toml,设置其rpc监听端口为36657,p2p监听端口为36656, 并使其主动连接第一个节点,其中fd8d…41为第一个节点旳ID(使用show_node_id 子命令获取):
[rpc]
laddr = “tcp://0.0.0.0:36657”
[p2p]
laddr = “tcp://0.0.0.0:36656”
persistent_peers = “fd8debec2b97adfa0f6e8bae939c22a69cda9741@127.0.0.1:26656”
现在2#终端启动第二个节点:
~/repo/go/src/hubwiz.com/c8$ smr node –home n2
交易测试
无论利用哪个节点提交交易,都可以保证所有的节点状态同步。
首先在5#终端分别查看节点一和节点二的状态:
~/repo/go/src/hubwiz.com/c8$ curl localhost:26657/abci_query
~/repo/go/src/hubwiz.com/c8$ curl localhost:36657/abci_query
然后利用节点一的rpc接口提交交易:
~/repo/go/src/hubwiz.com/c8$ curl localhost:26657/broadcast_tx_commit?tx=0x01
再次查看节点一和节点二的状态,你可以观察到同样的最新状态
停机测试
由于我们只有一个验证节点,显然当该节点停机时,整个集群将无法达成共识。
首先在1#终端按ctrl+c停止smr的运行,然后在5#终端通过节点二提交交易:
~/repo/go/src/hubwiz.com/c8$ curl localhost:26657/broadcast_tx_commit?tx=0x01
该命令会一直挂起直至超时,因为交易始终无法确认。
现在我们继续向网络中添加第三个节点,这次是一个验证节点:
第三个节点旳基本参数是:
P2P通信端口:46656
RPC服务端口:46657
节点目录:n3
节点初始化配置与启动
进入3#终端,执行init子命令初始化节点三的目录n3:
~/repo/go/src/hubwiz.com/c8$ smr init –home n3
由于这个节点是验证节点,因此我们需要在节点一现有的创世文件中添加该节点 的priv_validator.json文件中的公钥和地址,其中每个验证节点旳power值用来 表征其代表的权益:
在上面的配置中,由于每个节点旳power都是10,因此每个节点都有50%(10/(10+10)) 的机率出块。
然后将新的创世文件分发给节点二和节点三:
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n2/config/genesis.json
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n3/config/genesis.json
同时修改n3/config/config.toml,设置其rpc监听端口为46657,p2p监听端口为46656, 并使其主动连接第一个节点,其中fd8d…41为第一个节点旳ID(使用show_node_id 子命令获取):
[rpc]
laddr = “tcp://0.0.0.0:46657”
[p2p]
laddr = “tcp://0.0.0.0:46656”
persistent_peers = “fd8debec2b97adfa0f6e8bae939c22a69cda9741@127.0.0.1:26656”
现在3#终端启动第二个节点:
~/repo/go/src/hubwiz.com/c8$ smr node –home n3
交易测试
无论利用哪个节点提交交易,都可以保证所有的节点状态同步。
首先在5#终端分别查看三个节点的状态:
~/repo/go/src/hubwiz.com/c8$ curl localhost:26657/abci_query
~/repo/go/src/hubwiz.com/c8$ curl localhost:36657/abci_query
然后利用节点一的rpc接口提交交易:
~/repo/go/src/hubwiz.com/c8$ curl localhost:26657/broadcast_tx_commit?tx=0x0101
再次查看三个节点的状态,你可以观察到同样的最新状态
停机测试
由于3f+1个验证节点中,才允许f个发生拜占庭故障,因此两个验证节点旳任一个 出现故障,整个集群都将停止共识。
首先在3#终端按ctrl+c停止smr的运行,然后在5#终端通过节点二提交交易:
~/repo/go/src/hubwiz.com/c8$ curl localhost:36657/broadcast_tx_commit?tx=0x0102
该命令会一直挂起直至超时,因为交易始终无法确认。
但是观测节点旳停机不会影响共识的达成。
现在我们添加第四个节点,并将全部节点都设置为验证节点,来测试当其中某个 节点故障时,系统的容错能力:
第四个节点旳基本参数是:
P2P通信端口:56656
RPC服务端口:56657
节点目录:n4
节点初始化配置与启动
进入1#终端,首先初始化第四个节点目录:
~/repo/go/src/hubwiz.com/c8$ smr init –home n4
修改节点一的创世文件,将其他三个节点的priv_validator.json文件中的公钥和地址 添加进去,然后将新的创世文件分发给其他节点
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n2/config/genesis.json
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n3/config/genesis.json
~/repo/go/src/hubwiz.com/c8$ cp n1/config/genesis.json n4/config/genesis.json
同时修改节点2/3/4的config/config.toml,设置其rpc监听端口分别为36657/46657/56657, p2p监听端口分别为36656/46656/56656,并使其主动连接第一个节点,其中fd8d…41为第一 个节点旳ID(使用show_node_id子命令获取)。以节点2为例:
[rpc]
laddr = “tcp://0.0.0.0:56657”
[p2p]
laddr = “tcp://0.0.0.0:56656”
persistent_peers = “fd8debec2b97adfa0f6e8bae939c22a69cda9741@127.0.0.1:26656”
分别在四个终端启动四个目录对应的节点,例如,在2#终端启动节点2:
~/repo/go/src/hubwiz.com/c8$ smr node –home n2
容错测试
由于3f+1个验证节点中,允许f个发生拜占庭故障,因此我们的四个节点旳小集群, 允许任一个出现拜占庭错误。
首先在1#终端按ctrl+c停止smr的运行,然后在5#终端通过节点二提交交易:
~/repo/go/src/hubwiz.com/c8$ curl localhost:36657/broadcast_tx_commit?tx=0x0101
可以观察到交易正常完成。
在之前的课程中,我们简单地使用磁盘文件来保存状态,对于简单的学习或验证 而言没有问题,但在生产环境中,tendermint推荐我们使用其基于avl树实现的 多版本状态库。
avl树得名于发明者G. M. Adelson-Velsky和Evgenii Landis,它是一种 自平衡二叉检索树,这包括两个核心的思想:二叉、平衡。
二叉是指整棵树中每个节点最多有两个子节点,左侧的子节点值一定小于父节点值,而 右侧的子节点值一定大于父节点值,二叉树的主要用途是进行数据检索:当查找指定的数值时, 只需要逐层与节点值比较即可快速定位,因此被称为二叉检索树。
例如,下图就是一个典型的二叉检索树,每个节点列出了其表示的值:
当我们需要在树中定位值为19的节点时,从根节点出发,只需要三次对比就可以定位:
10 < 50,因此进入50的左侧子树继续搜索
19 > 17,因此进入17的右侧子树继续搜索
19 < 23,因此进入23的左侧子树继续搜索
19 == 19,定位成功
平衡是指树中任一节点旳左右两棵子树的高度差不超过1。例如,上面的树就不是平衡的, 该数据集对应的平衡树如下图所示:
自平衡指的是树的形成算法:当一个新的节点加入树树中时,算法将通过旋转等手段使整棵树 始终处于平衡状态,因此看起来就树就是靠自己找到了平衡状态。
为了快速计算状态集合的哈希以及进行默克尔验证,基于avl树和merkle树,tendermint实现了 多版本状态库iavl,它提供了类似于key/value数据库的操作接口:
为了便于计算默克尔哈希,在tendermint的avl树实现中,只有在叶节点中才会保存实际的状态值, 中间节点仅用于key的比较和哈希的计算。由于在所有节点中已经预存了左右子节点的哈希,因此可以 快速获取整棵树的根节点哈希,即状态集合的哈希。
iavl支持同一个key值的多个版本,这通过在节点结构中引入version项来实现:当一个节点被新版本 的数据更新后,iavl会同时保留其历史版本,因此使用iavl可以快速回溯到任何状态的任意历史版本。
安装iavl:
~$ go get github.com/tendermint/iavl
tendermint/iavl
软件包的主要模型包括可修改树(MutableTree)、只读树(ImmutableTree) 以及状态证据(RangeProof)等,其关系如下图所示:
ImmutableTree是一个只读的二叉平衡哈希树,而MutableTree则提供了Set()方法来修改树 的节点构成并保证其处于平衡状态,RangeProof则是默克尔证据的封装结构。
加载状态库
iavl使用leveldb数据库保存节点以及其关系,例如,下面的代码从当前目录下的 counter数据库加载状态库,并使用Load()方法将载入最后版本的状态:1
2
3gdb,_ := db.NewGoLevelDB("counter",".")
tree := iavl.NewMutableTree(gdb,128)
tree.Load()
工作区
类似于git,iavl也有一个工作区的概念 —— 所有的修改操作都是在工作区完成的,而不是 直接操作状态库。可以使用Load()方法载入最后(新)版本的状态库到工作区,也可以使用 LoadVersion()方法载入指定版本的状态库到工作区。
一旦将状态载入工作区,我们就可以利用Set()方法设置指定的键/值对了。例如,下面的 代码设置键name的值为tommy:
tree.Set([]byte("name"),[]byte("Tommy"))
当我们调用Get()方法时,是从当前工作区中读取指定键的值,例如:
idx,val := tree.Get([]byte("name"))
其中,返回的idx表示该键对应的叶节点在集合中的先后序号,val表示键对应的值。
如果需要从状态库中指定版本读取键值,可以使用GetVersioned()方法。例如, 下面的代码读取版本2的指定键值:
idx,val := tree.GetVersioned([]byte{"name"},2)
提交新版本
所有的修改完毕后,使用SaveVersion()方法将工作区的变更提交到库中,这将返回根节点 哈希和新的版本号:
hash,ver,err := tree.SaveVersion()
iavl库的版本号是从0开始,每个版本加1。
为了简化iavl的操作,我们将编解码等繁琐的操作封装到一个单独的结构Store里:
Store的结构声明如下,除了iavl库,额外的两个成员分别记录状态的最后版本号以及最后 状态的默克尔哈希:1
2
3
4
5type Store struct {
tree *iavl.MutableTree //iavl库
LastVersion int64 //状态的最后版本号
LastHash []byte //最后版本状态的根哈希
}
GetBalance()方法获取指定地址的当前(最后版本)余额:
1 | func (store *Store) GetBalance(addr crypto.Address) (int,error) { |
GetBalanceVersoined()方法获取特定版本的指定地址余额:1
2
3
4
5
6
7
8func (store *Store) GetBalanceVersioned(addr crypto.Address,version int64) (int,error) {
_,bz := store.tree.GetVersioned(addr,version)
if bz == nil { return 0,errors.New("account not found on this version") }
var val int
err := codec.UnmarshalBinary(bz,&val)
if err !=nil { return 0,errors.New("decode error")}
return val,nil
}
SetBalance()方法修改指定地址的余额:1
2
3
4
5
6func (store *Store) SetBalance(addr crypto.Address,value int) error {
bz,err := codec.MarshalBinary(value)
if err != nil { return err }
store.tree.Set(addr,bz)
return nil
}
Commit()提交当前工作区的修改:1
2
3
4
5
6func (store *Store) Commit() {
hash,ver,err := store.tree.SaveVersion()
if err != nil { panic(err) }
store.LastVersion = ver
store.LastHash = hash
}
基于Store的封装,很容易为代币状态机加入iavl的支持:
1 | type TokenApp struct { |
转账交易1
2
3
4
5
6
7
8func (app *TokenApp) transfer(from,to crypto.Address,value int) error {
fromBalance,_ := app.store.GetBalance(from)
if fromBalance < value {return errors.New("no enough balance")}
toBalance,_ := app.GetBalance(to)
app.SetBalance(from,fromBalance - val)
app.SetBalance(to,toBalance + val)
return nil
}
发行交易1
2
3
4
5
6func (app *TokenApp) issue(issuer,to crypto.Address,value int) error {
if !bytes.Equal(issuer,SYSTEM_ISSUER) return { errors.New("invalid issuer") }
toBalance,_ := app.store.GetBalance(to)
app.SetBalance(to,toBalance + val)
return nil
}
实现Commit()1
2
3
4
5
6func (app *TokenApp) Commit() types.ResponseCommit{
hash,ver,_ := app.store.Commit()
app.Version = ver
app.Hash = hash
return types.ResponseCommit{Data:hash}
}
查询状态1
2
3
4
5
6
7
8func (app *AccountApp) Query(req types.RequestQuery) types.ResponseQuery{
if len(req.Data) == 0 {
return types.ResponseQuery{Code:1,Info:"no address specified"}
}
addr := cryto.Address(req.Data)
val,_:= app.store.GetBalance(addr)
return types.ResponseQuery{Key:addr,Value:val}
}
在ABCI应用响应Commit请求消息时,需要计算并返回当前状态的哈希,以便Tendermint 将其打包到下一个区块头里(app_hash字段)。
但是,如果我们还按原来的方法计算一个总体哈希,例如:hash: = tmhash.Sum(state), 就存在一个问题 —— 当查询某个特定账户的状态数据时,如何验证该状态是未被篡改的?
显然,单独返回整个状态的哈希是不够的,但是我们也不可能将整个账户表提供给 客户端以便其重算哈希进行验证,因为其中包含了其他用户的账户信息。
这就是默克尔树(Merkle Tree)的用武之地:不需要提供完整的数据(例如整个账户表), 就可以验证某个数据(例如账户A的状态)是否属于该数据集。
在默克尔树中,叶节点对应于各状态的哈希值,根节点则对应于整个状态的哈希, 中间各层的节点则分别由前一层节点两两结对后计算哈希得到。
例如,下图给出了4个账户状态时的默克尔树的构成:
基于默克尔树的生成过程,我们只需要这个树的一部分节点,就可以验证某个状态(例如账户A 的状态)与整体哈希(Hash(ABCD))的对应关系,这部分节点就被称为默克尔证据/Merkle Proot。 例如上图中,对于账户A的状态而言,Hash(B)和Hash(CD)就是其证据 —— 因为利用账户A本身的数据, 以及证据节点,就可以重算出根节点,从而确认指定账户状态与给定状态哈希的对应关系。
在tendermint中内置了默克尔树的一个简单实现,可以计算有序集合(切片)和无序集合(映射表) 的默克尔树根哈希:
Hasher接口
使用crypto/merkle
包计算数据集合的默克尔哈希,要求集合中的成员实现Hasher接口。例如, 我们使用下面的结构来满足这一需求:1
2
3
4
5
6
7type sh struct{
value string
}
func (h sh) Hash() []byte {
return tmhash.Sum([]byte(h.value))
}
现在,我们可以根据集合是有序还是无序,来分别计算其默克尔哈希了。
有序集合的哈希计算
有序集合(例如:切片)中各成员有确定性的先后顺序,因此可以直接使用SimpleHashFromHashers()
方法进行计算。例如:1
2
3data := []merkle.Hasher{ &sh{"one"},&sh{"two"},&sh{"three"},&sh{"four"} }
hash := merkle.SimpleHashFromHashers(data)
fmt.Printf("root hash => %x\n",hash)
无序集合的哈希计算
无序集合(例如:映射表)的各成员没有确定性的先后顺序,因此需要首先进行确定排序,重组为有序 集合后才能使用SimpleHashFromHashers()方法计算该集合的默克尔哈希。对于键类型为string、值类型 为Hasher的映射表而言,可以直接使用SimpleHashFromMap()方法。例如:1
2
3
4
5
6
7data := map[string]merkle.Hasher{
"tom":&sh{"actor"},
"mary":&sh{"teacher"},
"linda":&sh{"scientist"},
"luke":&sh{"fisher"}}
hash := merkle.SimpleHashFromMap(data)
fmt.Printf("root hash => %x\n",hash)
在使用默克尔树时,如果需要验证某个数据是否属于一个特定的集合,除了待验证的数据自身, 还需要以下数据:
数据集合的根哈希:表征特定的数据集合
数据的默克尔证据:配合待验证数据重算根哈希,以便于给定的根哈希比较
tendermint的crypto/merkle
包提供了简单的方法返回集合中每个成员对应的默克尔 证据以及集合的根哈希:
数据聚合中每个成员对应的默克尔证据就是一个SimpleProof实例,因此可以直接调用 其Verify()方法进行验证。
同样,获取数据成员的默克尔证据也分为有序集合与无序集合两种情况。
有序集合成员的默克尔证据
使用SimpleProofsFromHashers()方法获取有序集合(例如:切片)中各成员的默克尔证据, 成员必须实现Hasher接口,该方法返回两个值:根哈希以及默克尔证据数组。
还是利用前一节的sh类型,下面的代码展示了如何获取切片中各成员的默克尔证据:1
2
3data := []sh{sh("one"),sh("two"),sh("three"),sh("four")}
root,proofs := merkle.SimpleProofsFromHashers(data)
fmt.Printf("proof for one => %+v\n",proofs[0])
在返回结果中的默克尔证据数组,其成员顺序与输入数据一致。
一旦获取了某个数据的默克尔证据、结合数据集合的根哈希,就可以验证这个数据是否属于 给定的根哈希对应的数据集合了。例如:1
2
3
4
5
6valid := proofs[0].Verify(
0, // 要验证的数据在集合中的序号
4, // 集合成员总数
data[0].Hash(), // 要验证的数据的哈希
root // 集合的根哈希)
fmt.Printf("data[0] is valid? => %t\n",valid)
无序集合成员的默克尔证据
同样,没有确定性成员顺序的映射表,需要使用SimpleProofsFromMap()方法计算每个 成员的默克尔证据,其返回结果是三个值:根哈希、成员默克尔证据映射表和排序后的 成员键。例如:1
2
3
4
5
6
7data := map[string]sh{
"tom":sh("actor"),
"mary":sh("teacher"),
"linda":sh("scientist"),
"luke":sh("fisher")}
root,proofs,keys := merkle.SimpleProofsFromMap(data)
fmt.Printf("proof for tom => %+v\n",proofs["tom"])
由于在计算映射表的默克尔证据时首先将无序的键值对转化为了KVPair结构并 进行排序,因此其成员时,也需要首先将其转换为KVPair类型,而不能仅使用键值对 中的值部分:
kvpair := merkle.KVPair{Key:[]byte("tom"),Value: data["tom"].Hash()}
然后根据该成员在排序后的顺序号(keys中的位置),进行验证,例如:1
2
3
4
5
6valid := proofs[key].Verify(
3, // 要验证的数据键在keys中的序号
4, // 集合成员总数
kvpair.Hash(), // 要验证的数据的哈希
root // 集合的根哈希)
fmt.Printf("data["tom"] is valid? => %t\n",valid)
基于默克尔树,我们可以升级代币状态机,在hash.go代码中实现与默克尔计算相关 的三个方法:
首先扩展int类型,实现Hasher接口:1
2
3
4
5
6type Balance int
func (b Balance) Hasher() []byte {
v,_ := codec.MarshalBinary(b)
return tmhash.Sum(v)
}
stateToHasherMap()方法将应用的状态集合转换为Hasher映射表,以便进行默克尔计算:1
2
3
4
5
6
7
8func (app *TokenApp) stateToHasherMap() map[string]merkle.Hasher {
hashers := map[string]merkle.Hasher{}
for addr,val := range app.Accounts {
balance := Balance(val)
hashers[addr] = &balance
}
return hashers
}
getRootHash()方法计算应用状态的默克尔哈希:1
2
3
4func (app *TokenApp) getRootHash() []byte {
hashers := app.stateToHasherMap()
return merkle.SimpleHashFromMap(hashers)
}
getProofBytes()
方法获取指定地址状态的默克尔证据:
1 | func (app *TokenApp) getProofBytes(addr string) []byte { |
当提交状态修改时,我们可以利用这些方法向tendermint返回根哈希:1
2
3
4func (app *TokenApp) Commit() (rsp types.ResponseCommit){
rsp.Data = app.getRootHash()
return
}
当查询状态时,也可以利用这些方法返回状态的证据:1
2
3
4
5
6
7func (app *TokenApp) Query(req types.RequestQuery) (rsp types.ResponseQuery) {
addr := crypto.Address(req.Data)
rsp.Key = req.Data
rsp.Value,_ = codec.MarshalBinary(app.Accounts[addr.String()])
rsp.Proof = app.getProofBytes(addr.String())
return
}
状态机模型是一种图灵完备的计算模型,理论上你可以用它来实现任何应用, 代币也不例外。例如,我们可以借鉴以太坊的账户模型,设计出自己的账户 状态机:
出于简化问题考虑,我们假设系统只发行一种代币,因此在账户模型中只需要 记录每个账户的余额即可。所有账户及其余额是整个系统的状态,只有当交易 发生时,这一状态才会发生变化。
例如,假设账户tommy有1000个代币,那么当发生一笔从mary到tommy 的500个币的转账交易后,tommy的余额将增加500个代币,同时mary的余额将 减少500个代币,这意味着整个系统在这笔交易后进入了一个新的状态。
基于我们之前的学习,很容易将账户采用哈希地址来表示,同时通过非对称 加密技术进行身份验证,从而实现一个去中心化的代币账户状态机。
账户状态机的主要成员,包括记录系统状态的成员变量Accounts,以及表征交易的 成员函数issue()和transfer():
TokenApp的结构声明如下:1
2
3
4type TokenApp struct {
types.BaseApplication
Accounts map[string]int
}
我们使用一个映射来表示系统的整个状态,其中键为哈希地址,值为账户余额。 由于crypto.Address类型其实是一个字节切片,因此我们采用其16进制表示 作为账户映射表的键。
发行交易将向指定的地址发行一定数量的代币,显然,只允许系统设定的发行人 SYSTEM_ISSUER执行该交易:1
2
3
4
5func (app *TokenApp) issue(issuer,to crypto.Address,value int) error {
if !bytes.Equal(issuer,SYSTEM_ISSUER) return { errors.New("invalid issuer") }
app.accounts[to] += value
return nil
}
转账交易从转出账户减去一定数量的代币,再向转入账户增加一定数量的代币, 因此我们需要先保证转出账户有足量的余额:1
2
3
4
5
6func (app *TokenApp) transfer(from,to crypto.Address,value int) error {
if app.accounts[from] < value {return errors.New("no enough balance")}
app.accounts[from] -= value
app.accounts[to] += value
return nil
}
为了避免琐碎的密钥/地址管理,我们使用一个简单的钱包结构来管理一组私钥:
为了避免输入冗长难记的地址,我们使用字符串标识钱包中的不同私钥,因此得到 如下的结构定义:1
2
3type Wallet struct {
Keys map[string]crypto.PrivKey
}
钱包应该可以随时创建新的私钥,因此我们实现GenPrivKey()方法:1
2
3
4
5func (wallet *Wallet) GenPrivKey(label string) crypto.PrivKey {
priv := kf.GenPrivKey()
wallet.Keys[label] = priv
return priv
}
GenPrivKey()方法需要传入一个字符串作为私钥的标识,以便我们可以在 以后使用该标识获取该私钥,或者该私钥对应的公钥或地址:1
2
3func (wallet *Wallet) GetPrivKey(label string) crypto.PrivKey {
return wallet.Keys[label]
}
当然,还需要提供一个方法将钱包保存到硬盘上:1
2
3
4
5func (wallet *Wallet) Save(wfn string){
bz,err := codec.MarshalJSON(wallet)
if err != nil { panic(err) }
ioutil.WriteFile(wfn,bz,0644)
}
或者载入硬盘的钱包文件获得一个钱包实例:1
2
3
4
5
6
7
8func LoadWallet(wfn string) *Wallet{
var wallet Wallet
bz,err := ioutil.ReadFile(wfn)
if err != nil { panic(err) }
err = codec.UnmarshalJSON(bz,&wallet)
if err != nil { panic(err) }
return &wallet
}
由于我们使用非对称密钥进行身份标识,因此在交易中需要包含身份校验所需要的 信息,例如签名、公钥和消息序列号:
我们使用一个统一的Tx结构来表示所有的交易,其中交易载荷指向一个Payload接口的 实现,该接口的三个方法可用于接收方的签名验证与交易路由:
GetSigner():获取交易发起方地址
GetSignBytes():获取交易载荷中用于签名的数据
GetType():获取交易类别,状态机根据该调用返回值执行相应的动作
在账户状态机应用中,我们需要两种类型的交易:发行交易、转账交易,分别用于 向指定账户发行代币,以及在指定账户之间转移代币。不同的交易对应不同的Payload 接口实现,例如对于转账交易,其对应的TransferPayload结构的接口实现如下:1
2
3func (p *TransferPayload) GetSigner() crypto.Address{ return p.From }
func (p *TranferPayload) GetSignBytes() []byte { return json.Marshal(p) }
func (p *TransferPayload) GetType() string{ return "transfer" }
交易核验
在接收端,首先应当检查交易结构中公钥的有效性,这通过校验公钥与交易发起方地址 是否一致来实现,然后则通过重算交易签名来确认签名的有效性,只有有效的交易,我们 才进行后续处理。例如,下面的代码展示了交易的验证逻辑:1
2
3
4
5
6
7func (app *AccountApp) validateTx(tx *Tx) error {
addr := tx.PubKey.Address()
if !bytes.Equals(addr,tx.Payload.GetSigner()) { return errors.New("pubkey / signer mismatch") }
valid := tx.PubKey.VerifyBytes(tx.Payload.GetSignBytes(),tx.Signature)
if !valid { errors.New("bad signature") }
return nil
}
交易路由
一旦交易验证有效,状态就可以根据交易类别进行分别处理了。例如:1
2
3
4
5
6
7
8switch tx.Payload.GetType(){
case "transfer":
pld := tx.Payload.(TransferPayload)
app.transfer(pld.From,pld.To,pld.Value)
case "issue":
pld := tx.Payload.(IssuePayload)
app.issue(pld.Issuer,pld.To,pld.Value)
}
根据我们定义的交易结构,显然在RPC客户端提交交易之前,需要首先串行化为 16进制码流,在ABCI应用中同时也需要相应的解码:
tendermint官方推荐的编解码器是其自产的go-amino,它类似于protobuf,最大的特点是支持 解码到接口类型 —— 这就是我们可以在Tx结构中使用接口类型的原因。
amino通过在编码码流中加入标识序列来区分不同的接口实现结构,因此解码接口之前,首先 需要注册接口以及对应的实现结构及标识名,例如,在下面的代码中,我们注册Payload接口, 然后注册其两个实现结构TransferPayload和IssuePayload,并分别使用tx/transfer 和tx/issue来标识这两个Payload接口的实现:1
2
3
4codec := amino.NewCodec()
codec.RegisterInterface((*Payload)(nil),nil)
codec.RegisterConcrete(*TransferPayload{},"tx/transfer",nil)
codec.RegisterConcrete(*IssuePayload{},"tx/issue",nil)
需要指出的是,当你使用amino时,并不是所有的自定义类型都需要在codec中注册,只有那些 需要解码到接口类型的结构,才需要进行注册。
现在,接收端可以对接收到的二进制码流bz进行解码了:1
2
3
4
5func (app *AccountApp) decodeTx(bz []byte) (*Tx,error){
var tx Tx
err := codec.UnmarshalBinary(bz,&tx)
return &tx,err
}
有了基本的状态机、钱包、交易结构以及序列化手段,现在我们可以实现状态机的ABCI接口了:
交易检查:CheckTx
在CheckTx()方法实现中检查交易的有效性,只有解码正确并且检查有效的交易才允许 进入交易内存池:
1 | func (app *TokenApp) CheckTx(bz []byte) types.ResponseCheckTx { |
交易执行:DeliverTx
在DeliverTx()方法中判断交易类型,然后执行相应的状态迁移:
1 | func (app *TokenApp) DeliverTx(bz []byte) (rsp types.ResponseDeliverTx){ |
状态查询
在Query()方法中返回指定账户的余额:
1 | func (app *TokenApp) Query(req types.RequestQuery) types.ResponseQuery{ |
tendermint内置了RPC开发接口的API封装包rpc/client,极大地简化了客户端的 开发难度:
使用rpc/client包的NewHTTP()方法,我们可以得到一个HTTP实例:
cli := client.NewHTTP("http://localhost:26657","")
HTTP结构实现了tendermint中所有的RPC客户端接口,例如ABCI客户端接口 (ABCIClient)、历史数据访问接口(HistoryClient)等,因此我们可以直接 利用其方法向abci应用提交交易。
首先构造一个发行交易,并利用发行人私钥签名交易:1
2
3paylod := NewIssuePayload(issuerAddr,receiverAddr,value)
tx := NewTx(payload)
tx.Sign(issuerPrivKey)
然后将交易实例序列化:
rawtx,_ := codec.MarshalBinary(tx)
最后使用HTTP实例的BroadcastTxCommit()方法提交给节点,并 打印输出响应结果:1
2ret,_ := cli.BroadcastTxCommit(rawtx)
fmt.Printf("ret => %+v\n",ret)
无论是中心化系统,还是去中心化系统,都有一个基本的问题:如何表征与验证用户的身份。
在中心化系统中,这一问题是基于统一存储的用户表来实现的:每个用户在表中都有 一条对应的记录,而系统则通过验证用户输入的用户名和口令是否与用户表中的记录一致来识别 用户的身份:
区块链则采用了另外一种不需要集中存储的方案来解决这一问题:每个用户由一对公/私钥来 标识,可以将公钥视为用户名,而私钥视为用户的口令。当用户提交数据时,必须使用自己 的私钥进行签名,这样其他人就可以利用其公钥验证签名数据是否真的来自于该用户。
不过由于公钥比较长,通常会对公钥进行一定的哈希计算,并进行必要的截短,作为区块链上 用户的标识,即我们通常所说的地址。
tendermint提供了两种非对称加密算法的实现:比特币/以太坊采用的secp256k1椭圆曲线算法, 以及tendermint推荐的相对较新的ed25519加密算法,在我们的应用中都可以用来实现身份识别。
Secp256k1是指比特币中使用的ECDSA(椭圆曲线数字签名算法)曲线的参数,公/私钥就对应于 该曲线上的点。
在比特币流行之前secp256k1几乎无人使用,但现在已经是无人不知了。secp256k1的参数由于 是精心选择的,因此它的计算会比随机参数的曲线快30%,具有较短的密钥,同时也能显著降低 算法中存在后门的风险。
tendermint内置了secp256k1的实现包crypto/secp256k1,其中的PrivKeySecp256k1和 PubKeySecp256k1分别实现了私钥和公钥接口:
私钥对应于曲线上点的X坐标。使用secp256k1包的GenPrivKey()方法可以生成一个32 字节长的随机私钥,例如:1
2priv := secp256k1.GenPrivKey()
fmt.Printf("private key => %v\n",priv)
或者将一个特定的密文转换为私钥,例如:1
2priv := secp256k1.GenPrivKeySecp256k1([]byte("your secret byte slice"))
fmt.Printf("private key => %v\n",priv)
公钥对应与曲线上点的Y坐标,因此从私钥可以推导出公钥,调用私钥的GetPubKey() 方法获得其对应的公钥实例:1
2pub := priv.GetPubKey()
fmt.Printf("public key => %v\n",pub)
tendermint的实现是返回压缩公钥,因此公钥长度是32+1=33字节 —— 额外的1个字节标识Y 在上部还是下部。 地址的计算方法和比特币一样,都是对公钥进行两重哈希运算(sha256->ripemd160), 最后得到20字节长的地址,但tendermint的地址没有像比特币那样添加网络前缀。
调用公钥实例的Address()方法获取公钥对应的地址,例如:1
2addr := pub.Address()
fmt.Printf("address => %v\n",addr)
尽管secp256k1在区块链领域已经非常流行,但是它也有一些问题。
首先是计算效率的问题。Montgomery ladder是一种可以快速简捷地进行椭圆曲线计算的 方法,但是secp256k1不支持,这使得它的计算效率无法通过采用该算法得到提升。
其次是secp256k1不具备数理完备性。在某些边界情况下,secp256k1有可能产生错误的结果, 因此这要求在算法实现时非常小心。
最后是secp256k1对扭曲攻击的抵抗力不够强。扭曲攻击是指攻击者从类似的曲线上抽点 来欺骗算法。secp256k1必须在签名和验证过程中检查攻击者提供的点是否真的在曲线上, 这使得安全性和效率打了折扣。
tendermint推荐的ed25519算法属于下一代的EdDSA签名算法,与secp256k1相比,ed25519 的计算能快30%,安全性更高,密钥和签名也更短一些。下表列出了两者的对比:
类型 Secp256k1 Ed25519
私钥长度 32 字节 64 字节
公钥长度 33 字节 32 字节
签名长度 ~71 字节 64 字节
安全目标 2128 2128
安全测试通过率 7/11 11/11
tendermint的ed25519实现包是crypto/ed25519,其中的PrivKeyEd25519和 PubKeyEd25519分别实现了私钥和公钥接口:
使用ed25519包的GenPrivKey()方法可以生成一个64字节长的随机私钥,例如:1
2priv := ed25519.GenPrivKey()
fmt.Printf("private key => %v\n",priv)
或者将一个特定的密文转换为私钥,例如:1
2priv := ed25519.GenPrivKeyFromSecret([]byte("your secret byte slice"))
fmt.Printf("private key => %v\n",priv)
调用私钥的GetPubKey()方法获得其对应的公钥实例:1
2pub := priv.GetPubKey()
fmt.Printf("public key => %v\n",pub)
地址的计算方法,就是对公钥进行sha256哈希计算,然后截取前20个字节。 调用公钥实例的Address()方法获取公钥对应的地址,例如:1
2addr := pub.Address()
fmt.Printf("address => %v\n",addr)
非对称密钥有一个很有用的特性,就是私钥签名,可以用公钥进行认证:
发送方首先使用私钥签名要发送的数据,例如msg的内容:1
2
3privTommy := secp256k1.GenPrivKey()
msg := []byte("some text to send")
sig := privTommy.Sign(msg)
由于secp256k1和ed25519都实现了PrivKey接口,因此你可以任选其一生成 你的私钥。
Sign()方法返回的是一个字节切片,通常和被签名的数据一起发送出去, 当然,接收方不一定持有发送方的公钥,因此通常也会把公钥一并发过去。
例如,我们可以使用如下的结构来声明要发送的签名数据:1
2
3
4
5type Letter struct {
Msg []byte
Signature []byte
PubKey secp256k1.PubKeySecp256k1
}
将签名数据序列化为字节码流,然后通过网络发送出去,或者拷贝给接收方:1
2
3
4letter := Letter{msg,sig,pubSender.(kf.PubKeySecp256k1)}
bz,err := json.Marshal(letter)
if err !=nil { panic(err) }
fmt.Printf("encoded letter => %x\n",bz)
在上面的代码中,我们使用了json编码器,当然你可以使用任何其他可用的编解码器, 例如gob。
接收方首先解码接收到的字节码流:1
2
3
4var received Letter
err = json.Unmarshal(bz,&received)
if err !=nil { panic( err)}
fmt.Printf("decoded letter => %+v\n",received)
然后就可以使用信件中发送方的公钥验证签名了:1
2valid := received.PubKey.VerifyBytes(received.Msg,received.Signature)
fmt.Printf("validated => %t\n",valid)
关注微信公众号区块链001
, 回复tendermint
获得
tendermint采用的分布式计算模型为状态机复制(State Machine Replication),其基本 思路就是通过在多个节点间通过同步输入序列来保证各节点状态机的同步。
状态机是一种在通信软件、游戏、工业控制等领域应用非常广泛的计算模型,用来抽象地表示一个 系统的演化过程。状态机由一组(有限或无限的)状态以及激发状态迁移的外部输入组成, 对于确定性状态机而言,在某一个时刻一定处于一个确定的状态,而在一个状态下 针对特定输入的状态迁移也是确定的。
现在让我们看一个计数器的状态以及其变化情况,在某一个特定时刻其状态为特定的数值:
counter state machine
显然,计数器有无限个状态(1,2,3…),但只有三个触发动作:
状态机复制是指在多个节点中的状态机保持一致,彼此互为副本,无论客户端访问哪一个 节点,都能得到同样的状态;无论客户端向哪一个节点提交交易,也都能保证各节点可以 最终过渡到一致的新状态。
尽管是显而易见的,但依然值得指出,只有确定性状态机才可以利用状态机 复制模型实现分布式共识。
显然,当任意一个节点收到交易请求时,首先需要与其他节点进行协调,确认达成一致意见后, 再分别于不同的节点执行同样的交易序列 —— 状态机复制就是通过在各节点之间保持交易序列 (状态机的外部输入)的一致次序来保证最终状态的一致性的,而节点间协调的过程,就是我们 所说的共识算法。
下图反映了tendermint作为共识引擎时,RPC客户端、tendermint程序和abci应用 三者之间(简化)的交互时序:
当RPC客户端提交一个新的交易后,该交易首先进入tendermint的交易池,然后tendermint将与 其他节点就要执行哪些交易的问题通过p2p协议进行协调,达成共识后,tendermint才会通知应用状态机 执行交易更新状态。
显然,在整个状态机复制模型中,作为状态机的ABCI应用是被动的,它只需要响应来自共识引擎 的ACBI消息,并执行相应的动作即可。
tendermint将ABCI协议交互过程进行了封装,开发者只需要实现Application接口,等待tendermint 在合适的时机调用就可以了:
上图列出了Application接口约定的方法,每个方法对应于一个特定的ABCI消息:
Info:当tendermint与ABCI应用建立初始连接时,将发送Info请求消息尝试获取应用状态对应的区块 高度、状态哈希等信息,以确定是否需要重放(replay)区块交易。
Query:当Rpc客户端发出abci_query调用时,tendermint将通过Query请求消息转发给ABCI应用,并 将响应结果转发回Rpc客户端。
CheckTx:当tendermint从Rpc接口或p2p端口收到新的交易时,首先会通过CheckTx请求消息提给ABCI应用 进行初步检查,确认该交易是否合规。只有ABCI应用确认有效的消息才会进入tendermint的交易 池等待下一步的共识确认。
InitChain:当创建创世块时,tendermint会发送InitChain请求消息给ABCI应用,可以在此刻进行 应用状态机的状态初始化。
BeginBlock/EndBlock:当tendermint就新区块交易达成共识后,将通过BeginBlock请求开始启动ABCI应用 的交易执行流程,并以EndBlock请求作为交易执行流程的结束。
DeliverTx:在BeginBlock和EndBlock请求之间,tendermint会为区块中的每一个交易向ABCI应用 发出一个DeliverTx请求消息,这是应用状态机更新的时机。
Commit:作为执行交易的最后一步,tendermint会发送Commit请求,并在获取响应后持久化区块状态。
为了减轻共识环节的工作负担,对于通过rpc接口提交的交易,tendermint引入了 交易检查环节,只有检查成功的交易才能够进入交易池等待确认,否则直接拒绝:
例如,下面的代码检查交易是否为指定的三种交易之一(0x01 - inc,0x02 - dec ,0x03 - reset), 否则拒绝:1
2
3
4
5
6func (app *EzApp) CheckTx(tx []byte) types.ResponseCheckTx{
if tx[0] < 0x04 {
return types.ResponseCheckTx{}
}
return types.ResponseCheckTx{Code:1,Log:"bad tx rejected"}
}
CheckTx()
方法返回的是一个ResponseCheckTx结构,其组成与ResponseDeliverTx 相同:
同样,结构中只有Code是必须的,为0值时表示交易执行成功,不同的非0值的失败含义由 应用自行定义。
现在,当我们对修改过的ABCI应用试图提交如下的交易时,将发生错误:
~$ curl http://localhost:26657/broadcast_tx_commit?tx=0x78
结果如下:
交易的唯一性要求
为了对抗重放攻击,以及避免节点间反弹的重复消息引起共识过程死循环,tendermint在 触发CheckTx()之前会使用一个缓存拒绝近期已经出现过的交易。因此当你试图重复提交 一个交易时,将提示该交易已经存在的错误:tx already exists in cache。
解决的办法就是为交易附加一个序列号,以保证交易的唯一性。例如,当我们使用0x01表示 inc交易时,可以如下的方式多次提交交易:1
2
3~$ curl http://localhost:26657/broadcast_tx_commit?tx=0x0101
~$ curl http://localhost:26657/broadcast_tx_commit?tx=0x0102
~$ curl http://localhost:26657/broadcast_tx_commit?tx=0x0103
交易序列号的设计完全取决于特定的应用,tendermint只是简单地拒绝已经在缓存中的交易。
计数器应用只有一个要维护的状态(计数值),因此第一版的实现很简单,我们 只需要在DeliverTx方法中检查交易类型,然后增减或复位成员变量Value即可:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15type CounterApp struct{
types.BaseApplication
Value int64
}
func (app *CounterApp) DeliverTx(tx []byte) types.ResponseDeliverTx{
switch tx[0] {
case 0x01: app.Value += 1
case 0x02: app.Value -= 1
case 0x03: app.Value = 0
default: return types.ResponseDeliverTx{Code:0,Log:"bad tx"}
}
info := fmt.Printf("value updated : %v",app.Value)
return types.ResponseDeliverTx{Info: info}
}
DeliverTx()
方法的返回结果是一个ResponseDeliverTx结构,其定义如下:
结构中只有Code是必须的,为0值时表示交易执行成功,不同的非0值的失败含义由 应用自行定义。
当tendermint准备构建创始区块时,将向abci应用发送InitChain消息,同时在该消息 请求中可以携带创世文件genesis.json中应用特定的初始化状态数据(app_byte), 因此是应用进行状态初始化的好时机:
例如,下面的代码将计数状态从100开始:1
2
3
4func (app *CounterApp) InitChain(req types.RequestInitChain) types.ResponseInitChain{
app.Value = 100
return types.ResponseInitChain{}
}
在上面的代码中我们直接在InitChain()实现中设定了技术初始值100,这当然是可以的,不过 更好的办法是借助于创世文件genesis.json,在该文件中声明应用状态的初始值。
例如,下面展示了genesis.json中的内容:1
2
3
4
5
6
7{
"genesis_time": "2018-10-30T00:42:47.699648591Z",
"chain_id": "test-chain-wx5RA8",
...
"app_hash": "",
"app_state": {"counter":100}
}
app_state字段的内容将以原始字节码的形式在InitChain请求的AppStateBytes字段传入, 因此,我们改为如下的实现:1
2
3
4
5
6func (app *CounterApp) InitChain(req types.RequestInitChain) types.ResponseInitChain{
var state map[string]int
json.Unmarshal(req.AppStateBytes,&state)
app.Value = state["counter"]
return types.ResponseInitChain{}
}
RPC客户端可以利用节点旳abci_query调用查询ABCI应用维护的状态,该调用允许在请求 中设定查询条件,以过滤潜在的查询结果:
对于我们的计数状态机而言,由于只有一个状态Value,因此直接返回它的当前值即可:1
2
3
4func (app *CounterApp) Query(req types.RequestQuery) types.ResponseQuery{
val := fmt.Sprintf("%d",app.Value)
return types.ResponseQuery{Value: []byte(val)}
}
Query()的参数是一个RequestQuery结构,用来指定查询条件,由于我们只有一个状态 值得返回,因此暂时先忽略它。
Query()的返回结果是一个ResponseQuery结构,其定义如下:
同样,结构中只有Code是必须的,为0值时表示交易执行成功,不同的非0值的失败含义由 应用自行定义即可。对于计数应用,我们使用Value字段来返回当前状态值。
现在可以使用abci_query来查询应用状态了:
~$ curl http://localhost:26657/abci_query
abci_query返回的value是base64编码的:
我们可以用base64解码value字段的值:
~$ echo MQ== | base64 -d
现在观察RequestQuery的结构,注意其Height字段:
对于状态机而言,当一个新的区块产生,其中的交易就会导致状态的迁移,因此状态是 与区块存在着对应关系 —— 在不同的区块高度,对应着状态机的不同状态:
因此对于ABCI应用而言,应当记录状态的历史变化,每一个区块高度,对应着不同版本的状态:1
2
3
4
5
6type CounterApp struct {
types.BaseApplication
Value int64
Version int64
History map[int64]int64
}
我们在Commit()方法中递增Version(以便和区块高度保持一致),并记录状态历史:1
2
3
4
5func (app *CounterApp) Commit() types.ResponseCommit{
app.Version += 1
app.History[app.Version] = app.Value
return types.ResponseCommit{}
}
现在,我们可以根据RequestQuery中的Height值来返回对应版本的状态了,高度0意味着要 返回最新区块的状态:1
2
3
4
5
6func (app *CounterApp) Query(req types.RequestQuery) types.ResponseQuery {
height := req.Height
if req.Height == 0 { height = app.Version }
val := fmt.Sprintf("%d",app.History[height])
return types.ResponseQuery{Value: []byte(val),Height: height}
}
我们现在可以查询历史状态了:
$ curl http://localhost:26657/abci_query?height=1
结果如下:
现在我们的计数应用已经在区块链中有了一些交易记录,也实现了基本的状态更新逻辑, 那么思考一个问题:如果重新启动节点(及ABCI应用),然后再次查询最后区块的状态值, 结果是8还是0?
这个问题是有意义的,毕竟我们只是在内存里记录了状态值以及其历史,重新启动后, 内存中的状态数据已经丢失了。
你可以自己尝试一下,不出意外的话,还是会和重新启动之前一样,你得到的状态值 依然是8。
这是因为当tendermint节点连接ABCI应用后,会有一个握手同步的处理,tendermint会 向ABCI应用发送Info消息获取应用状态的最后区块高度,如果ABCI应用返回的区块高度 小于tendermint节点旳最后区块高度,tendermint就会认为ABCI应用漏掉了这些区块并 进行重放:
显然,由于我们没有明确处理Info消息,因此握手时tendermint会认为我们的计数应用 的区块高度为0,所以它会重放所有的区块,这意味着当重放结束,我们的计数器值还是 会回到8。
容易理解,我们不期望每次重新启动节点(及ABCI应用)都重放所有区块,那会非常耗时 并且体验很差。因此我们可以在Info()方法中返回状态机处理过的最后区块高度,你知道 它对应于我们的Version成员:1
2
3func (app *CounterApp) Info(req types.RequestInfo) types.ResponseInfo{
return types.ResponseInfo{LastBlockHeight:app.Version}
}
Info()的返回值是一个ResponseInfo结构,其成员如下:
根据tendermint的约定,ABCI应用在Commit()调用时返回当前状态的哈希,会由tendermint 写入下一个区块头中的app_hash部分,作为区块链状态的一部分:
apphash用来表示某个区块时的应用状态特征,哈希函数自然是一个很好的选择,但tendermint 只要求这个值能够表达状态,因此我们理论上可以使用任何与状态相关的确定性的值。例如, 对于计数应用而言,我们可以直接使用计数器的值,因为这个状态本身很简单,不需要提取指纹了。1
2
3
4
5
6func (app *CounterApp) Commit() types.ResponseCommit{
app.Version += 1
app.Hash = []byte(fmt.Sprintf("%d",app.Value))
app.History[app.Version] = app.Value
return types.ResponseCommit{Data:app.Hash}
}
与此同时,在tendermint与abci应用握手时,如果涉及到区块重放,也会检查区块头记录的AppHash 与前一区块的Commit()返回的结果是否一致,因此,我们调整Info()来返回记录的当前状态的哈希:1
2
3func (app *CounterApp) Info(req types.RequestInfo) types.ResponseInfo{
return types.ResponseInfo{LastBlockHeight:app.Version,LastBlockAppHash:app.Hash }
}
到目前位置,我们的应用状态都是保存在内存里,每次重新启动都会从零开始。 现在考虑把状态持久化到硬盘上。这包括两部分工作:在Commit()中保存状态到硬盘、 在创建应用实例时载入硬盘保存的状态。
理论上你可以使用任何存储机制,例如SQL数据库、NoSQL数据库、文件系统等等。 不过为了便于观察,我们采用JSON格式的平文件记录计数值:1
2
3
4
5
6
7
8func (app *CounterApp) Commit() types.ResponseCommit{
app.Version += 1
app.History[app.Version] = app.Value
state,err := json.Marshal(app)
if err !=nil { panic(err) }
ioutil.WriteFile("./counter.state",state,0644)
return types.ResponseCommit{}
}
同时,我们在实例化计数应用时,载入之前的状态:1
2
3
4
5
6
7
8func NewCounterApp() *CounterApp {
app := CounterApp{ History:map[int64]int{} }
state, err := ioutil.ReadFile("./counter.state")
if err != nil { return &app }
err = json.Unmarshal(state,&app)
if err != nil { return &app }
return &app
}
现在我们的状态已经持久化,可以查看一下counter.state的内容:
关注微信公众号区块链001
, 回复tendermint
获得
tendermint虽然定位于引擎,但它其实是一个完整的区块链实现。在这一部分 的课程中,我们将使用一个最小化的ABCI应用,来熟悉tendermint的主要组成 部分,以及使用tendermint进行去中心化应用开发的主要流程和工具。
下图列出了tendermint应用的主要构成部分:
tendermint提供了一个预构建的同名可执行程序,我们将学习如何使用这个程序 来初始化节点配置文件并启动节点。这个程序是完整的节点实现,除了通过P2P协议 与其他节点交换共识,同时还提供了RPC接口供客户端提交交易或者查询应用状态。
我们将创建一个最小化的ABCI应用,tendermint可执行程序通过ABCI接口与 应用程序交互,例如要求应用执行交易、或者转发来自RPC接口的状态查询请求。
tendermint节点程序的行为非常依赖于配置文件,使用其init子命令 可以获得一组默认的初始化文件。 例如,在1#终端输入如下命令创建初始化文件:
~$ tendermint init
init子命令将在~/.tendermint目录下创建两个子目录data和config,分别用于 保存区块链数据和配置文件。
在data目录下将包含如下的数据文件,均为leveldb格式:
blockstore.db:区块链数据库
evidence.db:节点行为数据
state.db:区块链状态数据
tx_index.db:交易索引数据,
在config子目录下将包含如下的配置文件:
config.toml:节点软件配置文件
node_key.json:节点密钥文件,用于p2p通信加密
priv_validator.json:验证节点密钥文件,用于共识签名
genesis.json:创世文件
节点配置文件config.toml用来设置节点软件的运行参数,例如RPC监听端口等。 我们修改consensus.create_empty_blocks为false,即不出无交易的空块:
[consensus]create_empty_blocks = false
重新初始化
在我们开发ABCI应用的过程中,往往需要对应用中的状态结构等信息进行调整,再次重新启动 后就可能导致原有的链数据和新的状态结构不兼容,因此需要时不时地重新初始化区块链数据。
当然你可以完全删除~/.tendermint目录,然后重新执行tendermint init命令。不过官方 的建议是使用unsafe_reset_all子命令来做这个事情,这个命令可以保留现有的配置而仅删除 数据文件。例如:
~$ tendermint unsafe_reset_all
初始化之后,我们就可以启动节点了。在1#终端执行node子命令启动tendermint节点:
~$ tendermint node
可以看到tendermint在反复尝试abci应用的默认监听地址tcp://127.0.0.1:26658:
显然,tendermint要求一个配套的abci应用才能正常工作,我们将在下一节解决这个 问题。
在目前这种状态下,如果需要退出tendermint的执行,可以切换到2#终端,使用pkill 命令终止其运行:
~$ pkill -9 tendermint
tendermint开发包中已经包含了一个基本的ABCI应用实现类BaseApplication, 可以完成与tendermint节点旳基本交互:
tendermint节点程序可以通过socket通信访问ABCI应用,因此我们使用abci/server 包的NewServer()函数创建一个SocketServer实例来启动这个应用。
例如,下面的代码在tendermint尝试连接的默认端口26658启动abci应用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17package main
import (
"fmt"
"github.com/tendermint/tendermint/abci/types"
"github.com/tendermint/tendermint/abci/server"
)
func main(){
app := types.NewBaseApplication()
svr,err := server.NewServer(":26658","socket",app)
if err != nil { panic(err) }
svr.Start()
defer svr.Stop()
fmt.Println("abci server started")
select {}
}
将上述代码保存为~/repo/go/src/diy/c2/mini-app.go,然后在2#终端 进入c2目录并启动该应用:1
2~$ cd ~/repo/go/src/diy/c2
~/repo/go/src/diy/c2$ go run mini-app.go
现在回到1#终端重新启动tendermint节点:
~$ tendermint node
你可以看到这次tendermint节点启动成功了:
由于我们只有一个节点,因此tendermint会抱怨连接不到其他的节点,it‘s ok。
在一个典型的(非理想化的)去中心化应用的开发中,除了需要开发链上应用 (例如ABCI应用或者以太坊中的智能合约),往往还需要开发传统的网页应用 /桌面应用/手机应用,以方便那些不可能自己部署节点旳用户:
和以太坊一样,tendermint的节点也提供了RPC接口供这些传统应用代码访问节点功能, 例如提交交易或者查询节点状态,其默认的RPC监听端口是26657。
首先确保1#终端和2#终端分别运行着tendermint和abci应用,然后我们切换到3# 终端,输入如下命令提交交易0x68656c6c6f —— 对应于字符串hello的16进制表示:
~$ curl http://localhost:26657/broadcast_tx_commit?tx=0x68656c6c6f
响应结果类似于下图,其中check_tx和deliver_tx来自于abci应用,而交易哈希 和区块高度则由tendermint节点内部处理得出:
事实上,由于BaseApplication对于交易数据没有任何的限制,因此我们可以提交 任意有效的16进制表示,而这些交易都将成功地打包到区块里。
让我们看一下这个区块的内容,在3#终端输入如下命令:
~$ curl http://localhost:26657/block?height=2
注意结果中的Txs字段,它包含了该区块中所有交易的base64编码:
我们可以使用命令行工具base64简单地进行验证:
~$ echo aGVsbG8= | based64 -d
可以访问这里 查看tendermint区块结构的详细说明。
也可以通过哈希查看交易内容,在3#终端输入如下命令(注意,你的哈希可能与此不同):
~$ curl http://localhost:26657/tx?hash=0x2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C
得到如下的结果:
关注微信公众号区块链001
, 回复tendermint
获得
tendermint是一个开源的完整的区块链实现,可以用于公链或联盟链,其官方定位 是面向开发者的区块链共识引擎:
与其他区块链平台例如以太坊或者EOS相比,tendermint最大的特点是其差异化的定位: 尽管包含了区块链的完整实现,但它却是以SDK的形式将这些核心功能提供出来,供开发者 方便地定制自己的专有区块链:
tendermint的SDK中包含了构造一个区块链节点旳绝大部分组件,例如加密算法、共识算法、 区块链存储、RPC接口、P2P通信等等,开发人员只需要根据其应用开发接口 (Application Blockchain Communication Interface)的要求实现自己 的应用即可。
ABCI是开发语言无关的,开发人员可以使用自己喜欢的任何语言来开发基于tendermint的 专用区块链。不过由于tendermint本身是采用go语言开发的,因此用go开发ABCI应用的一个额外好处 就是,你可以把tendermint完整的嵌入自己的应用,干净利落地交付一个单一的可执行文件。
在技术方面,tendermint引以为傲的是其共识算法 —— 世界上第一个可以应用于公链的拜占庭 容错算法。tendermint曾于2016年国际区块链周获得最具创新奖,并在Hyperledger的雨燕(Burrow) 等诸多产品中被采纳为共识引擎。你可以点击 这里 查看其应用案例。
tendermint采用的共识机制属于一种权益证明( Proof Of Stake)算法,一组验证人 (Validator)代替了矿工(Miner)的角色,依据抵押的权益比例轮流出块:
由于避免了POW机制,tendermint可以实现很高的交易吞吐量。根据官方的说法,在 合理(理想)的应用数据结构支持下,可以达到42000交易/秒,引文参考 这里。 不过在现实环境中,部署在全球的100个节点进行共识沟通,实际可以达到1000交易/秒。
tendermint同时是拜占庭容错的(Byzantine Fault Tolerance),因此对于3f+1个 验证节点组成的区块链,即使有f个节点出现拜占庭错误,也可以保证全局正确共识的达成。同时 在极端环境下,tendermint在交易安全与停机风险之间选择了安全,因此当超过f个验证节点发生 故障时,系统将停止工作。
什么是拜占庭错误?简单的说就是任何错误:既包括节点宕机、也包括恶意节点的欺骗和攻击。
tendermint共识机制的另一个特点就是其共识的最终确定性:一旦共识达成就是真的达成, 而不是像比特币或以太坊的共识是一种概率性质的确定性,还有可能在将来某个时刻失效。 因此在tendermint中不会出现区块链分叉的情况。
tendermint的定位决定了在最终交付的节点软件分层中,应用程序占有相当部分的分量。 让我们通过与以太坊的对比来更好地理解这一点:
在上图中,tendermint结构中的abci应用和以太坊结构中的智能合约,都是由用户代码实现的。 显然,ABCI应用大致与EVM+合约的组合相匹配。
在以太坊中,节点是一个整体,开发者提供的智能合约则运行在受限的虚拟机环境中;而在 tendermint中,并不存在虚拟机这一层,应用程序是一个标准的操作系统进程,不受任何 的限制与约束 —— 听起来这很危险,但当你考虑下使用tendermint的目的是构建专有的区块链 时,这种灵活性反而更有优势了。
事实上,tendermint留下的应用层空间如此之大,以至于你完全可以在ABCI应用中实现一个 EVM,然后提供solidity合约开发能力,这就是超级账本的 Burrow 做的事情。
关注微信公众号区块链001
, 回复tendermint
获得
MongoDB是一个介于关系数据库和非关系数据库之间的产品,是非关系数据库当中功能最丰富,最像关系数据库的。他支持的数据结构非常松散,是类似json的bson格式,因此可以存储比较复杂的数据类型。
CAP理论,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
用OSX 的 brew 来安装 mongodbbrew install mongodb
或者 sudo brew install mongodb
如果要安装支持 TLS/SSL 命令如下:brew install mongodb --with-openssl
查看是否安装成功mongod -version
如果显示版本信息,说明已经安装成功
mongo默认会在根目录 /data/db 下启动服务,所以需要先创建此路径。sudo mkdir -p /data/db
然后开启服务mongod
或者开启指定路径的服务mongod --dbpath=xxx
新开一个终端,输入命令mongo
进入mongo系统
再次输入命令测试show dbs
显示如下
1 | > show dbs |
即已成功运行
显示数据库show dbs
显示数据表show collections
选择或者创建 mydb数据表use mydb
如果没有 user表 会自动创建db.user.save({"name":"zhangsan","age":2})
删除数据表db.user.drop()
删除数据库use mydb
db.dropDatasase()
删除记录db.user.remove({"name":"zhangsan"})
查所有db.user.find()
查 age=2db.user.find({"age":2})
只改动某一项值
1 | db.user.update({"_id" : ObjectId("5b41c89323c223baaa7d4ef1")},{$set:{"name":"wangwu"}}) |
如果没有set,相当于覆盖这条记录
1 | package main |
1 | $ mkdir -p $GOPATH/src/github.com/coreos |
每个节点都要执行以下配置,HOST_1、HOST_2、HOST_3 分别设置为多台服务器的IP
进入到bin目录下$ cd $GOPATH/src/github.com/coreos/etcd/bin
3个ip分别换成3太服务器真实的ip
1 | TOKEN=token-03 |
machine 1 执行如下命令
1 | $ cd $GOPATH/src/github.com/coreos/etcd/bin |
machine 2 执行如下命令
1 | $ cd $GOPATH/src/github.com/coreos/etcd/bin |
machine 3 执行如下命令
1 | $ cd $GOPATH/src/github.com/coreos/etcd/bin |
检测服务器运行是否正常
3个ip分别换成3太服务器真实的ip
1 | $ cd $GOPATH/src/github.com/coreos/etcd/bin |
查看进入集群的服务器列表./etcdctl --endpoints=$ENDPOINTS member list
1 | ./etcdctl --endpoints=$ENDPOINTS put foo "Hello World!" |
1 | ./etcdctl --endpoints=$ENDPOINTS put web1 value1 |
1 | ./etcdctl --endpoints=$ENDPOINTS put key myvalue |
1 | $ ./etcdctl --endpoints=$ENDPOINTS put user1 bad |
1 | // 当 stock1 的数值改变( put 方法)的时候,watch 会收到通知 |
ETCD是用于共享配置和服务发现的分布式,一致性的KV存储系统
etcd作为一个受到ZooKeeper与doozer启发而催生的项目,除了拥有与之类似的功能外,更专注于以下四点。
1. 简单:基于HTTP+JSON的API让你用curl就可以轻松使用。
2. 安全:可选SSL客户认证机制。
3. 快速:每个实例每秒支持一千次写操作。
4. 可信:使用Raft算法充分实现了分布式。
!<–more–>
在如下路径创建文件夹$ mkdir -p $GOPATH/src/github.com/coreos
下载etcd包$ git clone https://github.com/coreos/etcd.git
下载完后,然后依次执行下面命令1
2
3$ cd etcd
$ ./build
$ ./bin/etcd
1 | package main |
一个 Raft 集群包含若干个服务器节点;通常是 5 个,这允许整个系统容忍 2 个节点的失效,每个节点处于以下三种状态之一:
follower(跟随者)
:所有结点都以 follower
的状态开始。如果没收到 leader
消息则会变成 candidate
状态。candidate(候选人)
:会向其他结点“拉选票”,如果得到大部分的票则成为leader
。这个过程就叫做Leader选举(Leader Election)。leader(领导者)
:所有对系统的修改都会先经过leader
。Raft通过选出一个leader来简化日志副本的管理,例如,日志项(log entry)只允许从leader流向follower。
基于leader的方法,Raft算法可以分解成三个子问题:
Leader election
(领导选举):原来的leader挂掉后,必须选出一个新的leader
Log replication
(日志复制):leader从客户端接收日志,并复制到整个集群中
Safety
(安全性):如果有任意的server将日志项回放到状态机中了,那么其他的server只会回放相同的日志项
Raft 使用一种心跳机制来触发领导人选举。当服务器程序启动时,他们都是 follower
(跟随者) 身份。如果一个跟随者在一段时间里没有接收到任何消息,也就是选举超时,然后他就会认为系统中没有可用的领导者然后开始进行选举以选出新的领导者。要开始一次选举过程,follower
会给当前term加1并且转换成candidate
状态。
然后他会并行的向集群中的其他服务器节点发送请求投票的 RPCs 来给自己投票。候选人的状态维持直到发生以下任何一个条件发生的时候,
他自己赢得了这次的选举
其他的服务器成为领导者
如果在等待选举期间,candidate接收到其他server要成为leader的RPC,分两种情况处理:
candidate
会转成follower
状态leader
,并继续保持candidate
状态一段时间之后没有任何一个获胜的人
有可能,很多follower同时变成candidate,导致没有candidate能获得大多数的选举,从而导致无法选出主。当这个情况发生时,每个candidate会超时,然后重新发增加term,发起新一轮选举RPC。需要注意的是,如果没有特别处理,可能出导致无限地重复选主的情况。
Raft采用随机定时器的方法来避免上述情况,每个candidate选择一个时间间隔内的随机值,例如150-300ms,采用这种机制,一般只有一个server会进入candidate状态,然后获得大多数server的选举,最后成为主。每个candidate在收到leader的心跳信息后会重启定时器,从而避免在leader正常工作时,会发生选举的情况。
当选出 leader
后,它会开始接受客户端请求,每个请求会带有一个指令,可以被回放到状态机中。leader
把指令追加成一个log entry
,然后通过AppendEntries RPC并行的发送给其他的server,当改entry被多数派server复制后,leader
会把该entry回放到状态机中,然后把结果返回给客户端。
当 follower
宕机或者运行较慢时,leader
会无限地重发AppendEntries给这些follower,直到所有的follower都复制了该log entry。
raft的log replication保证以下性质(Log Matching Property):
如果两个log entry有相同的index和term,那么它们存储相同的指令
如果两个log entry在两份不同的日志中,并且有相同的index和term,那么它们之前的log entry是完全相同的
其中特性一通过以下保证:
特性二通过以下保证:
如果follower没有发现与它一样的log entry,那么它会拒绝接受新的log entry 这样就能保证特性二得以满足。
在一些一致性算法中,即使一台server没有包含所有之前已提交的log entry,也能被选为主,这些算法需要把leader上缺失的日志从其他的server拷贝到leader上,这种方法会导致额外的复杂度。相对而言,raft使用一种更简单的方法,即它保证所有已提交的log entry都会在当前选举的leader上,因此,在raft算法中,日志只会从leader流向follower。
为了实现上述目标,raft在选举中会保证,一个candidate只有得到大多数的server的选票之后,才能被选为主。得到大多数的选票表明,选举它的server中至少有一个server是拥有所有已经提交的log entry的,而leader的日志至少和follower的一样新,这样就保证了leader肯定有所有已提交的log entry。
领导人知道一条当前任期内的日志记录是可以被提交的,只要它被存储到了大多数的服务器上。如果一个领导人在提交日志条目之前崩溃了,未来后续的领导人会继续尝试复制这条日志记录。然而,一个领导人不能断定一个之前任期里的日志条目被保存到大多数服务器上的时候就一定已经提交了。下图展示了一种情况,一条已经被存储到大多数节点上的老日志条目,也依然有可能会被未来的领导人覆盖掉。
如上图的例子,图(c)就发生了一个log entry虽然已经复制到大多数的服务器,但是仍然有可能被覆盖掉的可能,如图(d),整个发生的时序如下:
图a中,S1被选为主,然后复制到log index为2的log entry到S2上
图b中,S1挂掉,然后S5获得了S3,S4和自身的选举,成为leader,然后,其从客户端收到了一个新的log entry(3)
图c中,S5挂掉,S1重新正常工作,又被选为主,继续复制log entry(2),在log entry(2)被提交前,S1又挂掉
图d中,S5又重新被选为领导者,然后,会把term 3的log entry覆盖到其他log index为2的log entry
为了上图描述的情况,Raft 永远不会通过计算副本数目的方式去提交一个之前任期内的日志条目。只有领导人当前任期里的日志条目通过计算副本数目可以被提交;一旦当前任期的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。例如,图e中,如果S1在挂掉前把log entry(4)复制到了大多数的server后,就能保证之前的log entry(2)被提交了,之后S5也就不可能被选为领导者了。
以反证法来证明,假设任期 T 的领导人(领导人 T)在任期内提交了一条日志条目,但是这条日志条目没有被存储到未来某个任期的领导人的日志中。设大于 T 的最小任期 U 的领导人 U 没有这条日志条目。
如果 S1 (任期 T 的领导者)提交了一条新的日志在它的任期里,然后 S5 在之后的任期 U 里被选举为领导人,然后至少会有一个机器,如 S3,既拥有来自 S1 的日志,也给 S5 投票了。
在领导人 U 选举的时候一定没有那条被提交的日志条目(领导人从不会删除或者覆盖任何条目)。
领导人 T 复制这条日志条目给集群中的大多数节点,同时,领导人U 从集群中的大多数节点赢得了选票。因此,至少有一个节点(投票者、选民)同时接受了来自领导人T 的日志条目,并且给领导人U 投票了,这个投票者是产生这个矛盾的关键。
这个投票者必须在给领导人 U 投票之前先接受了从领导人 T 发来的已经被提交的日志条目;否则他就会拒绝来自领导人 T 的附加日志请求(因为此时他的任期号会比 T 大)。
投票者在给领导人 U 投票时依然保有这条日志条目,因为任何中间的领导人都包含该日志条目(根据上述的假设),领导人从不会删除条目,并且跟随者只有和领导人冲突的时候才会删除条目。
投票者把自己选票投给领导人 U 时,领导人 U 的日志必须和投票者自己一样新。这就导致了两者矛盾之一。
首先,如果投票者和领导人 U 的最后一条日志的任期号相同,那么领导人 U 的日志至少和投票者一样长,所以领导人 U 的日志一定包含所有投票者的日志。这是另一处矛盾,因为投票者包含了那条已经被提交的日志条目,但是在上述的假设里,领导人 U 是不包含的。
除此之外,领导人 U 的最后一条日志的任期号就必须比投票人大了。此外,他也比 T 大,因为投票人的最后一条日志的任期号至少和 T 一样大(他包含了来自任期 T 的已提交的日志)。创建了领导人 U 最后一条日志的之前领导人一定已经包含了那条被提交的日志(根据上述假设,领导人 U 是第一个不包含该日志条目的领导人)。所以,根据日志匹配特性,领导人 U 一定也包含那条被提交当然日志,这里产生矛盾。
因此,假设不成立,所有比 T 大的领导人一定包含了所有来自 T 的已经被提交的日志。日志匹配原则保证了未来的领导人也同时会包含被间接提交的条目
跟随者或者候选人崩溃,会按如下处理:
领导人选举是 Raft 中对时间要求最为关键的方面。Raft 可以选举并维持一个稳定的领导人,只要系统满足下面的时间要求:
1 | 广播时间(broadcastTime) << 选举超时时间(electionTimeout) << 平均故障间隔时间(MTBF) |
广播时间指的是从一个服务器并行的发送 RPCs 给集群中的其他服务器并接收响应的平均时间;
选举超时时间就是选举的超时时间限制
平均故障间隔时间就是对于一台服务器而言,两次故障之间的平均时间。
选举超时时间要大于广播时间的原因是,防止跟随者因为还没收到领导者的心跳,而重新选主。
选举超时时间要小于MTBF的原因是,防止选举时,能正常工作的server没有达到大多数。
对于广播时间,一般在[0.5ms,20ms]之间,而平均故障间隔时间一般非常大,至少是按照月为单位。因此,一般选举超时时间一般选择范围为[10ms,500ms]。因此,当领导者挂掉后,能在较短时间内重新选主。
http://thesecretlivesofdata.com/raft/
实现效果:
4个子节点投票,选出leader,投票停止,leader状态变为leader,子节点状态重置。leader向子节点发送心跳数据,表名自己活着。leader使用浏览器自定义给子节点发送数据。
1 | package main |
运行:
开启4个终端 分别执行
如果5001成为了leader,在浏览器输入http://127.0.0.1:5011/req?data=XXX
xxx就是leader向子节点发送的数据,浏览器的端口是终端的端口加上10,如果5000是leader,浏览器就是5010.
基于拜占庭将军问题,一致性的确保主要分为这三个阶段:预准备(pre-prepare)、准备(prepare)和确认(commit)。流程如下图所示:
其中C为发送请求端,0123为服务端,3为宕机的服务端,具体步骤如下:
1 | package main |
如何运行:开启4个终端,eg:go run main.go Apple …
然后在浏览器输入:http://localhost:1112/req?warTime=1234
DPOS:Delegated Proof of Stake,委任权益证明
它的原理是让每一个持有币的人进行投票,由此产生n位代表 , 我们可以将其理解为n个超级节点或者矿池,而这n个超级节点彼此的权利是完全相等的。从某种角度来看,DPOS有点像是议会制度或人民代表大会制度。如果代表不能履行他们的职责(当轮到他们时,没能生成区块),他们会被除名,网络会选出新的超级节点来取代他们。EOS就是采用DPOS共识算法。
假设n为21,竞选的节点有几百个,持币人对这些节点进行投票,选出票数最多的21位,由这21位轮流来出块。
1 | package main |
输出1
2
3
4
5竞选的节点 [{第 1 个节点 0} {第 2 个节点 0} {第 3 个节点 0} {第 4 个节点 0} {第 5 个节点 0} {第 6 个节点 0} {第 7 个节点 0} {第 8 个节点 0} {第 9 个节点 0} {第 10 个节点 0}]
选出的节点 [{第 10 个节点 8} {第 4 个节点 7} {第 3 个节点 6}]
第 10 个节点 出块 {1 2018-06-29 19:28:28.41834078 +0800 CST m=+0.000940357 0c142d83bf773e248c3438dd99423f6b289d171696b5e24573e06e2c4c445161 [110 101 119 32 98 108 111 99 107 32 48] 0xc420090000}
第 4 个节点 出块 {2 2018-06-29 19:28:28.418365811 +0800 CST m=+0.000965387 0c142d83bf773e248c3438dd99423f6b289d171696b5e24573e06e2c4c445161 439cbbac2d6a8b476b7dbffd788c0f8019b55d65c6c075eb32ce4060e3a2cd63 [110 101 119 32 98 108 111 99 107 32 49] 0xc420090018}
第 3 个节点 出块 {3 2018-06-29 19:28:28.418395274 +0800 CST m=+0.000994849 439cbbac2d6a8b476b7dbffd788c0f8019b55d65c6c075eb32ce4060e3a2cd63 86d8790c046523635a02f1316a4b85c27c7df3a762b8d7bc550bf5317adf8455 [110 101 119 32 98 108 111 99 107 32 50] 0xc420090030}
POS:Proof of Stake,股权证明
类似于财产储存在银行,这种模式会根据你持有数字货币的量和时间,分配给你相应的利息。
简单来说,就是一个根据你持有货币的量和时间,给你发利息的一个制度,在股权证明POS模式下,有一个名词叫币龄,每个币每天产生1币龄,比如你持有100个币,总共持有了30天,那么,此时你的币龄就为3000,这个时候,如果你发现了一个POS区块,你的币龄就会被清空为0。你每被清空365币龄,你将会从区块中获得0.05个币的利息(假定利息可理解为年利率5%),那么在这个案例中,利息 = 3000 * 5% / 365 = 0.41个币,这下就很有意思了,持币有利息。以太坊就是采用POS共识算法。
每个旷工都有出块(即挖矿)的权力,只要出块成功,就有系统给出的奖励,这里不需要通过复杂的计算来挖矿,问题只在于谁来出块,股权越大,出块的概率就越大,反之,则相反。POS有很多变种,股权可以是持有币的数量,或者支付的数量等等。
1 | package main |
输出1
2
3{1 new block 72e8838ad3bb761c7d3ba42c4e6bad86409dd3f4ce958c890409c4b9ddf44171 e4f9575cfb14ee146810862c9e5cc78ebff185f5888f428dbb945bd9060b31f7 2018-06-29 19:29:04.827332898 +0800 CST m=+0.000837770 0xc42007e0a0}
0x12341
2
Proof of Work,工作证明。
POW共识算法主要是通过计算难度值来决定谁来出块。POW的工作量是指方程式求解,谁先解出来,谁就有权利出块。方程式是通过前一个区块的哈希值和随机值nonce来计算下一个区块的哈希值,谁先找到nonce,谁就能最先计算出下一个区块的哈希值,这种方式之所以被称为计算难度值是因为方程式没有固定解法,只能不断的尝试,这种解方程式的方式称为哈希碰撞,是概率事件,碰撞的次数越多,方程式求解的难度就会越大。比特币就是采用POW共识算法
这里涉及到两个重要的概念,一个是难度系数,一个是nonce,nonce可以理解为一个随机数,就是挖矿中要找到一个符合条件的nonce值。
这里假设难度系数是4(比特币初始难度系数就是4),将一个区块中的数据加上nonce值打包,nonce值从0开始一直递增,将这打包的数据计算hash值,hash满足最前面有4个0,就是挖矿成功。难度系数为多少,hash最前面就需要满足多少个0。
1 | package main |
输出
1 | 挖矿中 ac6665903c0cd2f000e17483fbcf6e3e8fa365de2b55663e7c94167f816d1489 |
DSA 是专业用于数字签名和验签,并且只有这个作用, 不能用于加密和解密。
在安全性上,DSA和RSA差不多,但是速度比RSA快很多。
1 | func main() { |
椭圆曲线加密算法,即:Elliptic Curve Cryptography,简称ECC,是基于椭圆曲线数学理论实现的一种非对称加密算法。相比RSA,ECC优势是可以使用更短的密钥,来实现与RSA相当或更高的安全。
椭圆曲线在密码学中的使用,是1985年由Neal Koblitz和Victor Miller分别独立提出的。
比特币就是用ECC来做签名和验签。
一般情况下,椭圆曲线可用下列方程式来表示,其中a,b,c,d为系数。
E:y2=ax3+ bx2+cx+d
例如,当a=1,b=0,c=-2,d=4时,所得到的椭圆曲线为:
E:y2=x3-2x+4
该椭圆曲线E的图像如图X-1所示,可以看出根本就不是椭圆形。
1 | func main() { |
数字签名就是只有信息的发送者才能产生的别人无法伪造的一段数字串,这段数字串同时也是对信息的发送者发送信息真实性的一个有效证明。
生成消息签名这一行为是由消息的发送者来完成的,也称为“对消息签名”。生成签名就是根据消息内容计算数字签名的值,这个行为意味着“我认可该消息的内容”。
验证数字签名这一行为一般是由消息的接收者来完成的,但也可以由需要验证消息的第三方来完成,这里的第三方在本书中被命名为验证者。验证签名就是检查该消息的签名是否真的属于发送者,验证的结果可以是成功或者失败,成功就意味着这个签名是属于发送者的,失败则意味着这个签名不是属于发送者的。
在数字签名中,生成签名和验证签名这两个行为需要使用各自专用的密钥来完成。发送者使用“签名密钥”来生成消息的签名,而验证者则使用“验证密钥”来验证消息的签名。数字签名对签名密钥和验证密钥进行了区分,使用验证密钥是无法生成签名的。此外,签名密钥只能由签名的人持有,而验证密钥则是任何需要验证签名的人都可以持有。
公钥密码包括一个由公钥和私钥组成的密钥对,其中公钥用于加密,私钥用于解密。用公钥加密所得到的密文只有
用与之对应的私钥才能正确解密。
数字签名中也同样会使用公钥和私钥组成的密钥对,不过这两个密钥的用法和公钥密码是相反的,即用 私钥加密 相当于 生成签名,而用 公钥解密 则相当于验证签名。
RSA是提出这个算法的三人姓氏开头字母组成,可用于加密,也可以用于数字签名。
DSA ( Digital Signature Algorithm)是一种数字签名算法,是由NIST ( National Institute of Standards and Technology,美国国家标准技术研究所)于1991年制定的数字签名规范( DSS)。DSA是Schnorr算法与ElGammal方式的变体,只能被用于数字签名。
ECDSA ( Elliptic Curve Digital Signature Algorithm)是一~种利用椭圆曲线密码来实现的数字
签名算法( NIST FIPS 186-3 )。
1 | func main() { |
RSA是一种公钥密码算法,它的名字是由它的三位开发者,即Ron Rivest、Adi Shamir 和 Leonard Adleman的姓氏的首字母组成的( Rivest-Shamir-Adleman )。
RSA可以被用于公钥密码和数字签名。
在RSA中,明文、密钥和密文都是数字。RSA的加密过程可以用下列公式来表达:
密文=明文E mod N (RSA加密)
RSA的密文是对代表明文的数字的E次方求mod N的结果。换句话说,就是将
明文和自己做E次乘法,然后将其结果除以N求余数,这个余数就是密文。
加密公式中出现的两个数E
和 N
,到底都是什么数呢? RSA的加密是求明文的E
次方mod N
,因此只要知道E和N这两个数,任何人都可以完成加密的运算。所以说,E
和 N
是RSA加密的密钥,也就是说,E
和 N
的组合就是公钥。
RSA的解密和加密一样简单,可以用下面的公式来表达:
明文=密文 D mod N ( RSA解密)
表示密文的数字的D次方求 mod N就可以得到明文。
这里所使用的数字N和加密时使用的数字N是相同的。数 D
和数 N
组合起来就是RSA的解密密钥,因此D和N的组合就是私钥。
在RSA中,加密和解密的形式是相同的。加密是求“明文的E次方的 mod
N”,而解密则是求“密文的D次方的 mod N”。
在RSA中,加密是求“明文的E次方的 mod
N”,而解密则是求“密文的D次方的 mod N”。
由于E和N是公钥,D和N是私钥,因此求E、D和N这三个数就是生成密钥对。
用 Linux或者mac 上自带,命令如下,完成制作非对称加密的秘钥对(公钥和私钥)
1.生成 RSA 私钥(传统格式的)
openssl genrsa -out rsa_private_key.pem 1024
2.将传统格式的私钥转换成 PKCS#8 格式的(JAVA需要使用的私钥需要经过PKCS#8编码,PHP程序不需要,可以直接略过),这个过程需要输入两遍密码,是针对私钥的密码,不要密码直接按回车
openssl pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM
3.生成 RSA 公钥
openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem
此时在系统根目录已经生成了两个文件。可以直接使用cat
命令查看文件中的内容
1 |
|
输出1
24bf96c2a3d83a71745ebc15441046f2ca0bc2cffab73a0a452bcb5c3b58ee717585e779df5046a31f71a6abf650e097f86e4ee95cb14ddd121794763a556987a965be2ec9eb47ff1cf17adcc166bc349a2471f24b924d50927b41bb61d4c0f357949a05e1f00db870582e00e7234e35923e6f1eae3021892590458af3b790f8a
hello world
1 | func main() { |
AES中没有使用Feistel网络,其结构称为SPN结构。
和DES相同,AES也由多个轮组成,其中每个轮分为SubBytes、ShiftRows、MixColumns、AddRoundKey 4个步骤,即:字节代替、行移位、列混淆和轮密钥加。根据密钥长度不同,所需轮数也不同,128位、192位、256位密钥,分别需要10轮、12轮和14轮。第1轮之前有一次AddRoundKey,即轮密钥加,可以视为第0轮;之后1至N-1轮,执行SubBytes、ShiftRows、MixColumns、AddRoundKey;最后一轮仅包括:SubBytes、MixColumns、AddRoundKey。
AES总体结构示意图:
密码算法可以分为分组密码和流密码两种
分组密码(block cipher)是每次只能处理特定长度的一块数据的一类密码算法,这里的“一块”就称为分组(block)。一个分组的比特数就称为分组长度(block lenght)。
例如 DES和3DES的分组长度都是64比特。AES的分组长度为128比特。
流密码(stream cipher)是对数据流进行连续处理的一类密码算法。流密码中一般以1比特、8比特、或32比特等为单位进行加密和解密。
分组密码处理完一个分组就结束了,因此不需要通过内部状态来记录加密的进度;相对地,流密码是对一串数据进行连续处理,因此需要保持内部状态。
分组密码算法只能加密固定长度的分组,但是我们需要加密的明文长度可能会超过分组密码的分组长度,这时就需要对分组密码算法进行迭代,以便将一段很长的明文全部加密。而迭代的方法就称为分组密码的模式(mode)。
ECB模式存在很高的风险,下面举例后面4中模式的使用.
加密的过程中使用了随机流,所以每次加密的密文都不一样
1 | func main() { |
输出1
2ffa22c136fd3e944255d43e255c98ecc
hollo, world!
1 | func main() { |
输出1
292e5c5d7bc54b337a7edbb548ee1a62c8c3c079b71f465a3f0566c0d74b8d513
abc hello world!
1 | func main() { |
输出1
29ee409f8513e3fcba2f1ba726da0b2a5d80251efa073544220b44c8e8fee18fce4
abcd hello world!
1 | func main() { |
输出1
2c645da2d14896d2b75d41a538a5a3efe3c7721f51f2eb2e92b0c5b8ba141caf534
abcd hello world!