# Tendermint ABCI 接口介绍

概述

Tendermint项目通过分层的理念将区块链应用构建划分成3层: P2P网络层, 共识层以及应用层, 项目本身提供了P2P网络层以及Tendermint共识协议层的实现, 并且定义了通用的ABCI接口来支持与上层应用的交互. ABCI接口的定义支持应用层的深度定制, Tendermint项目本身只负责P2P网络通信以及共识过程, 而交易检查和执行, 存储状态更新, PoS中的奖惩与验证者集合更新以及链上治理等都可以由应用层根据需要进行定制

通过ABCI接口进行交互时, 根据传统的C/S模型划分, 则Tendermint本身是客户端, 而上层应用App则是服务器, 客户端发起ABCI请求, 而服务器端则根据请求作出做出响应. 在允许应用层逻辑的深度定制之外, Tendermint也支持上层应用的多种实现方式, 只要遵循了ABCI接口的规范, Tendermint和应用层之间可以通过以下三种方式进行交互:

  • 进程内交互: 用Golang开发的App与Tendermint一起编译生成单独的二进制文件
  • GRPC交互: 用任意语言开发的App均可以与Tendermint通过GPRC进行交互
  • TSP交互: 用任意语言开发的App均可以与Tendermint通过Tendermint Socket Protocol进行交互

值得指出的是, 当通过GRPC或者TSP交互时, 上层App可以用任意编程语言实现, 达到了与Tendermint项目解耦的效果.

在介绍Tendermint的架构设计时, 有讨论到mempool.Reactor收到一笔交易时, 需要检查交易是否合法, 而这需要上层应用的协助, 而执行区块时也需要上层应用的协助, 另外Tendermint本身也需要知道上层应用的一些信息, 由此Tendermint的ABCI接口按照功能划分为以下三大类:

  1. Mempool connection: CheckTx
  2. Consensus connection: InitChain, BeginBlock, DeliverTx, EndBlock,Commit
  3. Info connection: Info, SetOption,Query

第一类方法与Mempool相关。Tendermint项目中维护了一个Mempool,用来存放接收到的有效交易。在网络接收到交易之后,需要通过CheckTx方法将交易发送给上层App进行基本的有效性验证,验证通过之后才会将交易加入到Mempool中。

第二类方法与区块执行相关, 在接收到一个合法区块之后,需要将区块提交给App来执行。为了最大限度的支持上层应用的可定制化,Tendermint将一个区块的执行拆分成 BeginBlock,DeliverTx,EndBlock, Commit这四步来完成。InitChain方法用来初始化链状的,只在链刚刚启动的时候被调用。

+----------+      +---------+   ...   +---------+      +--------+      +------+
|BeginBlock|----> |DeliverTx|  -----> |DeliverTx|----->|EndBlock|----->|Commit|
+----------+      +---------+         +---------+      +--------+      +------+
     ^                                                                     |
     |                                                                     |
     +---------------------------------------------------------------------+

第三类方法主要用来查询上层App的信息,如Info方法会返回App的版本号、上个区块高度和上个区块的ApphashQuery可以用来查询某个存储状态。SetOption用来配置一些与共识无关的选项,如设置节点所能接受的最小gasPrice等。

这三类方法在与上层App交互的时候,均会涉及到App存储状态的读写。这三类方法逻辑相互独立,也因此可以并发执行. App方面需要为每一类方法分别维护状态信息,而同一类方法则共用同一个状态信息。下图中展示了通过ABCI接口进行交易检查, 区块构建以及区块执行的基本逻辑.

  • Mempool通过CheckTx让上层App检查交易有效性, 有效性检查结果在TxResult中返回, Mempool将有效的交易保存在交易池中.
  • 当一个节点被选中作为新区块的提议者时, 从Mempool中抓取交易构建区块(Proposal Txs), 并通过共识协议在全网达成共识. 当全网就新的区块达成共识后, 网络中的节点都会更新自己Mempool中的交易, 移除因为新区块执行而不再有效的交易(Reap)
  • 通过共识协议确定的新的区块通过ABCI接口提交给App执行, 交易执行结果通过TxResult返回给共识引擎, 共识引擎基于这些结果更新本地状态并开始下一个区块的构建.

Node结构体中的proxyApp proxy.AppConns用来完成ABCI交互, 接口proxy.AppConns中封装了Mempool, Consensus以及Query三类分别处理三类不同的ABCI接口, 其类型分别为接口AppConnMempool, 接口AppConnConsensus以及接口AppConnQuery.接下来介绍这三类接口中定义的方法与涉及的数据结构.

// tendermint/proxy/multi_app_conn.go  11-18
// Tendermint's interface to the application consists of multiple connections
type AppConns interface {
    service.Service

    Mempool() AppConnMempool
    Consensus() AppConnConsensus
    Query() AppConnQuery
}

AppConnMempool

Tendermint底层维护了一个Mempool,用来存储接收到的有效交易,以便在节点被选为区块提案者时从中获取交易并构建区块。Tendermint自身没有办法检查交易的有效性,只能借助上层App完成对交易的有效性检查。需要指出的是,判定是否允许交易进入Mempool的检查通常只会执行比较基本的检查, 也因此可能会交易进行Mempool并最终被打包进区块, 但是最终在链上执行时失败的情况. 这是因为不同交易之间的执行可能会相互影响, 也因此一笔交易最终是否能够执行成功取决于执行交易时刻的链上状态, 而这些状态在判定是否允许交易进入Mempool时无法确定. 也即Tendermint打包的区块中可能包含最终会执行失败的交易, 这并不影响区块的合法性.

为了保证三类ABCI接口可以并行, 上层App需要为Mempool类的ABCI单独方法维护一个状态。对应每类ABCI接口的状态在一个新区块被提交之后都会更新各自的状态当前状态。由于前述的先执行的交易可能会影响后续的交易的执行结果, 也因此ABCI接口的设计也需要保证, 在每一类方法中, 一个方法的前一次调用能够在状态上影响后一次调用。例如,对Mempool连接来说,CheckTx检查的一笔交易可能会引起某种状态变更,后面的交易检查所基于的状态是此次变更之后的新状态,以此类推。

CheckTx

Tendermint需要向App发送的CheckTx请求定义如下:

type RequestCheckTx struct {
    Tx                   []byte      
    Type                 CheckTxType 
    XXX_NoUnkeyedLiteral struct{}    
    XXX_unrecognized     []byte      
    XXX_sizecache        int32       
}

Tendermint与App之间所有的请求和回应数据格式都通过一个Protobuf文件定义,数据格式编码方式的统一可以为上层App的开发提供更多选择以及更多灵活性.RequestCheckTx结构体中以XXX_开头的字段是Protobuf库用来存储未知域的数据,本章节不不做介绍,后续会自动将这些字段省略。在RequestCheckTx结构中,Tx的类型为字节数组, 也即Tendermint层面不关心交易的具体格式. 上层App则可以根据业务逻辑尝试将字节数组解析为一笔标准交易. RequestCheckTx结构中另一个字段为CheckTxType类型的Type的字段,这个字段可以有两种取值.

type CheckTxType int32

const (
    CheckTxType_New     CheckTxType = 0
    CheckTxType_Recheck CheckTxType = 1
)

值为CheckTxType_New时,表示这是从网络中收到的全新交易,在将这笔交易放入Mempool之前需要对它进行一次完整的合法性检查。CheckTxType_Recheck则与新区块执行导致的Mempool的更新相关. 在一个区块被提交之后,Tendermint需要重新遍历自己Mempool中的交易,以删除那些因为区块执行而变得不合法的交易。当App收到Type=CheckTxType_Recheck的请求时,就知道这笔交易之前已经检查过一次,就可以跳过那些不受区块执行状态更新影响的有效性检查步骤, 从而提高执行效率。

App处理完收到CheckTx的请求后, 会返回ResponseCheckTx结构体类型的结果, 其中的Code字段表示这笔交易是否通过了检查, Code字段为0时, 表示交易有效. 区块链世界中, 为了防止链上资源的滥用, 链上的任何资源消耗都需要消耗一定的Gas. 交易本身可以指定自身提供的最大的Gas值(也即为了执行这笔交易愿意付出的最大成本), 而在真正处理这笔交易时可能并不需要交易指定的最大的gas值, ResponseCheckTx结构体的GasWanted字段表示这笔交易中附带的最大Gas, 而GasUsed字段表示本次交易验证过程中消耗的Gas.

之后我们会看到,有许多ABCI方法的返回结果Response*中都包含了Events字段,每一个Event都包含了Type表示事件类型,以及一个Attributes类型用来存储一些键值对,来标识在方法执行过程中发生了什么特定行为。根据上层App返回的Events,Tendermint可以提供交易、区块等的索引服务。同时,外部服务商如钱包、区块浏览器等也可以向Tendermint订阅其感兴趣的事件,以便在这些事件发生时向外推送。

ResponseCheckTxResponseDeliverTx结构基本一致,其他字段留作以后解释。

// tendermint/abci/types/types.pb.go 1596-1608
type ResponseCheckTx struct {
    Code                 uint32   
    Data                 []byte   
    Log                  string   
    Info                 string   
    GasWanted            int64    
    GasUsed              int64    
    Events               []Event  
    Codespace            string   
  // 省略 XXX_ 开头的字段
}

AppConnConsensus

InitChain

InitChain方法用来初始化链的状态,只在链刚刚启动的时候被调用一次,链的起始状态被写在一个genesis.json文件中,Tendermint解析这个文件的字段,将相应信息发给App进行初始化。RequestInitChain定义如下,该结构体是InitChain方法的主要参数:

// cosmos-sdk/abci/types/types.pb.go 476-485
type RequestInitChain struct {
    Time                 time.Time         
    ChainId              string            
    ConsensusParams      *ConsensusParams  
    Validators           []ValidatorUpdate 
    AppStateBytes        []byte            
}

Tendermint从genesis.json文件中解析链的初始时间Time、链标识ChainId、共识参数ConsensusParams、验证者集合等参数Validators。由于Tendermint不关心App状态相关的数据,所以AppStateBytes类型为字节数组, 上层传给App收到RequestInitChain之后负责对AppStateBytes进行解析并设置应用初始状态.

// cosmos-sdk/abci/types/types.pb.go 1915-1922
// ConsensusParams contains all consensus-relevant parameters
// that can be adjusted by the abci app
type ConsensusParams struct {
   Block                *BlockParams     
   Evidence             *EvidenceParams  
   Validator            *ValidatorParams 
   // 省略 XXX_ 开头的字段
}

ConsensusParams中包含了会影响到共识执行的各类参数, 其中BlockParams指定了允许的区块最大字节数、最大Gas数(当设置为-1时,表示没有限制),EvidenceParams指定了与双签证据有效期相关的举证参数而ValidatorParams则指定了与验证者公钥相关的参数.

由于Tendermint层和上层App需要共享共识参数和验证者集合,而App链上治理的相关业务逻辑有可能会导致这共识参数和验证者集合的变化。因此在ResponseInitChain和随后会讲到的ResponseEndBlock中都这两个字段,用来通知Tendermint相关参数的更新,保持App和Tendermint的一致性。

// cosmos-sdk/abci/types/types.pb.go 1382-1388
type ResponseInitChain struct {
    ConsensusParams      *ConsensusParams  
    Validators           []ValidatorUpdate 
  // 省略 XXX_ 开头的字段
}

BeginBlock

当根据Tendermint共识协议就新区块的内容达成共识之后, 就需要将该区块提交给App执行. 整个执行过程分为四步. 第一步是BeginBlock,该方法用来通知App一个新高度上区块的到来,App接收到相应请求后,可以在交易执行前进行预处理, 例如Cosmos Hub中为了激励共识过程参与者而设计的通过通胀进行铸币的过程就发生在这一步。RequestBeginBlock中包含的字段包括:

  • Hash: 新区块的区块头的哈希值
  • Header: 新区块的区块头, 包含区块高度, 时间, 上一区块的标识, 本区块提议者等信息
  • LastCommitInfo: 验证者对新区块的投票信息
  • ByzantineValidators: 新区块中包含的验证者的作恶信息, 目前仅实现了双签作恶
// cosmos-sdk/abci/types/types.pb.go 626-634
type RequestBeginBlock struct {
    Hash                 []byte         
    Header               Header         
    LastCommitInfo       LastCommitInfo 
    ByzantineValidators  []Evidence     
    // 省略 XXX_ 开头的字段
}

在收到RequestBeginBlock之后,App需要为新区块的执行做一些准备:设置好区块高度、执行上下文等。另外,App还需根据CommitInfoByzantineValidators来执行链上惩罚措施。Cosmos-SDK的slashing模块根据CommitInfo推断验证者的稳定性并对稳定性很差的验证者进行惩罚, 而evidence模块则根据ByzantineValidators的信息对在共识中作恶的验证者进行惩罚.

上层App处理完RequestBeginBlock请求之后, 返回给Tendermint的ResponseBeginBlock中包含了在处理过程中发生的事件集合Events(比如对哪个验证者执行了链上惩罚等事件). Tendermint层会存储这些事件信息, 供对链上事件感兴趣的区块链浏览器, 钱包等服务商等查询.

// cosmos-sdk/abci/types/types.pb.go 1549-1554
type ResponseBeginBlock struct {
   Events               []Event  
   // 省略 XXX_ 开头的字段
}

DeliverTx

在完成BeginBlock的调用之后,Tendermint层需要按序将区块中的交易传给App进行处理。由于交易在Tendermint看来只是一个字节序列,因此RequestDeliverTx也只包含了这一个字段:

// cosmos-sdk/abci/types/types.pb.go 752-757
type RequestDeliverTx struct {
    Tx                   []byte
  // 省略 XXX_ 开头的字段
}

虽然App的业务逻辑千差万别, 但是不论业务逻辑如何变化, 每一笔交易的执行都可以分为以下三步:

  1. 尝试将交易解码为App定义的标准交易。
  2. 对交易执行预检查,即交易的合法性检查。
  3. 根据App的实现,执行解码后的交易。

值得指出的是,DeliverTxCheckTx的处理逻辑类似,两者都需要进行第一步的预检查。但CheckTx通常并不会真正执行交易,这取决于上层App的具体实现方式. 也因此, 被打包进区块的交易有可能在真正执行的时候失败,但这并不影响交易所在区块的合法性。这种方式所带来的一个好处是,能够降低CheckTx对资源的占用,提高链的交易处理能力。

App每处理完一个交易的执行,就会向Tendermint返回处理结果,ResponseDeliverTx中包含的字段与ResponseCheckTx类似:

  • Code字段标志着交易执行是否成功,下一区块的LastResultsHash计算涉及到该字段
  • CodeSpace字段指定了Code的命名空间
  • GasWanted字段记录了本交易所提供的gas总量
  • GasUsed指在实际执行过程中真实消耗的gas总量. 如果GasUsed>GasWanted,就表明交易因为gas不够而执行失败。
  • Events包含了本次交易执行过程中所发生的事件集合,Tendermint存储后供外部订阅者查询。
  • Data字段可以存储从App返回的任何确定性的数据,该字段的值对全网来说必须是一致的, 下一区块的LastResultsHash计算涉及到该字段
  • Log字段可以包含任意的日志信息, 该字段是共识无关的
  • Info字段则可以包含除日志之外的其他任意信息

Tendermint在执行一个区块时, 会根据接收到的所有的ResponseDeliverTx中的Code字段来统计区块中执行成功的交易数量。

// cosmos-sdk/abci/types/types.pb.go 1699-1711
type ResponseDeliverTx struct {
   Code                 uint32   
   Data                 []byte   
   Log                  string   
   Info                 string   
   GasWanted            int64    
   GasUsed              int64    
   Events               []Event  
   Codespace            string
   // 省略 XXX_ 开头的字段
}

EndBlock

待执行区块中所有的交易均通过DeliverTx执行完毕之后, Tendermint需要发送RequestEndBlock类型的请求通知App当前区块的交易已经发送完毕,该请求里只包含了当前的区块高度Height. 在收到该请求后,App可以根据区块中交易的执行结果做进一步的处理.

// cosmos-sdk/abci/types/types.pb.go 799-804
type RequestEndBlock struct {
   Height               int64
   // 省略 XXX_ 开头的字段
}

App返回给Tendermint的ResponseEndBlock里包含本次区块执行后活跃验证者集合的更新ValidatorUpdates,共识参数的更新ConsensusParamUpdates和发生的事件Events。在BeginBlock方法中,我们提到了App可能需要对恶意验证者进行惩罚。此外,在交易执行过程中各个验证者的投票权重也会因为相关交易的执行而发生变化,因此EndBlock方法的一个重要用途就是App重新计算验证者投票权重的变化(Cosmos-SDK的staking模块实现了这一操作),将这个集合返回给Tendermint, Tendermint根据这一信息更新活跃验证者集合并使用带权重的轮换算法选择下一个区块的区块提案者. 在当前的实现中, 交易执行引发的验证者集合的更新反映到底层Tendermint会有一个区块的延迟(参见Cosmos-SDK的PoS实现中参数ValidatorUpdateDelay的介绍). 也即在区块高度HEndBlock中产生的新的验证者集合首次参与共识投票是针对区块高度为H+2的区块, 并且其投票信息包含在区块高度为H+3的区块中. 然而共识参数的更新会直接影响下一个区块。需要说明的是,如果App返回的共识参数不为空,Tendermint会完全使用该参数来替换原来的参数,因此App返回的参数更新中,同一类参数(如BlockParams类型的参数)必须全部设置,即使是那些未更新的。

// cosmos-sdk/abci/types/types.pb.go 1802-1809
type ResponseEndBlock struct {
    ValidatorUpdates      []ValidatorUpdate 
    ConsensusParamUpdates *ConsensusParams  
    Events                []Event
  // 省略 XXX_ 开头的字段
}

ValidatorUpdate结构体中的字段PubKey代表验证者的共识公钥, Power字段则表示更新后的验证者的投票权重,该值必须是是一个非负类型的值,并且不能超过MaxTotalVotingPower的上限. 在接收到App返回的结果后,Tendermint会据此更新自己的验证者集合信息和共识参数信息,并将Events存储后供外部订阅者查询。

// cosmos-sdk/abci/types/types.pb.go 2629-2635
type ValidatorUpdate struct {
    PubKey               PubKey   
    Power                int64
  // 省略 XXX_ 开头的字段
}

Commit

在执行DeliverTx的时候,App并没有将交易造成的状态更新持久化到数据库,Tendermint需要主动发起一个Commit调用来通知App将本次区块的状态更新持久化存储下来。在这一过程中,Tendermint无需向App传送任何字段,因此RequestCommit被定义为一个空的结构体。

Tendermint的当前区块中需要包含上一个区块的AppHash,因此App在持久化存储状态之后,还需计算一个Apphash返回给Tendermint。ResponseCommit结构体中包含了一个字节数组DataApphash就存储在这里,但理论上App可以在此处返回任何确定性的值给Tendermint。

// cosmos-sdk/abci/types/types.pb.go 1865-1871
type ResponseCommit struct {
   Data                 []byte
   // 省略 XXX_ 开头的字段
}

AppConnQuery

Info

Info接口主要用来维护在Tendermint与App之间的一致性。在正常情况下,App与Tendermint之间可以按照上面所述的流程来完成新区块的执行. 但在实际执行时,App和Tendermint有可能在中间的任意一步崩溃,从而导致两者的状态不一致。因此,在重新启动Tendermint时,需要通过发送RequestInfo来向上层App请求其最新状态,验证两者是否同步。在不一致的时候,Tendermint需要根据App落后的区块数来重放这些区块。

// cosmos-sdk/abci/types/types.pb.go 357-364
type RequestInfo struct {
    Version              string   
    BlockVersion         uint64   
    P2PVersion           uint64
  // 省略 XXX_ 开头的字段
}

RequestInfo结构体里包含了Tendermint当前的版本号Version、区块版本号BlockVersion和P2P协议版本号。App返回给Tendermint的ResponseInfo结构体定义如下, Data可以包含任意的信息,Version代表App的语义版本号,AppVersion表示App的协议版本号,但这几个字段都不是必须的。 LastBlockHeightLastBlockAppHash字段是App必须返回的,其用途就是同步Tendermint与App的状态。

// cosmos-sdk/abci/types/types.pb.go 1238-1247
type ResponseInfo struct {
    Data                 string   
    Version              string   
    AppVersion           uint64   
    LastBlockHeight      int64    
    LastBlockAppHash     []byte
  // 省略 XXX_ 开头的字段
}

Query

由于Tendermint完全不掌握上层App的业务逻辑和数据存储,但是外部应用只能通过Tendermint来与上层App进行交互。因此,在外部查询者需要查询一些上层状态时,需要Tendermint将查询转发给App,这就是Query方法的用途。

// cosmos-sdk/abci/types/types.pb.go 555-563
type RequestQuery struct {
    Data                 []byte   
    Path                 string   
    Height               int64    
    Prove                bool     
  // 省略 XXX_ 开头的字段
}

RequestQuery结构体中包含了需要查询的路径Path、参数Data、查询的状态高度Height和是否需要App对查询的结果进行证明Prove。App一方面需要定义可供查询的数据和参数格式,另一方面需要引入可认证数据结构进行键值对存储,以支持查询的证明。该证明对于实现高效的轻客户端来说很重要,它可以允许客户端在无需跟踪所有链上数据的情况下验证返回的结果的真实性。

// cosmos-sdk/abci/types/types.pb.go 1437-1451
type ResponseQuery struct {
    Code uint32 
    // bytes data = 2; // use "value" instead.
    Log                  string        
    Info                 string        
    Index                int64         
    Key                  []byte        
    Value                []byte        
    Proof                *merkle.Proof 
    Height               int64         
    Codespace            string    
  // 省略 XXX_ 开头的字段
}

App返回的结果类型为ResponseQuery结构体, 其中CodeCodeSpace用来标识查询是否成功。如果成功的话,KeyValue字段存储了查询的结果,Proof是App返回的证明,Index代表了查询的Key在树中的索引值。 Tendermint在拿到查询结果后直接将其返回给外部查询者.

Proof是指Merkle树的存在性或者不存在性证明, 而为了支持证明, 就需要在上层App的存储结构中使用可认证的数据结构, 如以太坊的Merkle Patricia Trie或者Tendermint项目开发的IAVL+树. Cosmos-SDK采用模块化设计的理念, 每个应用的模块都利用IAVL+树维护本模块相关的状态, 而不同模块的IAVL+树的树根又参照RFC6962中的Merkle树规范, 共同组成了一棵简单Merkle树, 其树根就是AppHash. 证明某个模块中的某个值确实存在时, 首先需要提供从AppHash到某个模块的IAVL+树根的证明, 然后提供从该IAVL+树根到具体值的证明. 由此也解释了为何Proof结构体中需要包含一组ProofOp.每个ProofOp结构结构包含一个Merkle树证明. 其中的Type字段表示Merkle树的类型, Key字段根据Type的不同有不同的含义,对于IAVL+树类型的证明,该值表示查询的key,对于Merkle树类型的证明,该值表示状态查询的模块名称。 Data字段则包含了实际的证明.

// cosmos-sdk/abci/types/types.pb.go 93-99
type Proof struct {
    Ops                  []ProofOp 
  // 省略 XXX_ 开头的字段
}

// cosmos-sdk/abci/types/types.pb.go 30-37
type ProofOp struct {
    Type                 string   
    Key                  []byte   
    Data                 []byte
  // 省略 XXX_ 开头的字段
}

SetOption

// cosmos-sdk/abci/types/types.pb.go 421-427
type RequestSetOption struct {
    Key                  string   
    Value                string   
}

SetOption用来从Tendermint向App发起与共识无关的键值对设置。比如说,Key="min-fee"value="100utom",可以设置上层App在CheckTx时用到的最小gasPrice

ClientApplication

接下来介绍Tendermint为了支持ABCI接口而采用的整体设计, 其中包含两个最主要的主体结构:ABCI客户端Client和 ABCI服务器Application . 在ABCI的交互中,Tendermint作为ABCI客户端向作为服务器的App发起请求,App处理请求并及时响应。为了能够支持App的多种实现方式,Tendermint中的ABCI客户端Client被定义为Go语言中的接口类型, Tendermint自身提供了三种ABCI Client的实现:localClient, grpcClient, socketClient. 与此相对应,App可以根据自己的实现来创建出任意一种 ABCI Application: localserver,grpcserver,socketserver.

三类ABCI方法Mempool,Consensus,Query从功能上来讲是相互独立的,为了能够使Tendermint底层在使用这些功能时逻辑更加清晰,Tendermint将ABCI中划分为三类接口类型AppConnMempool,AppConnConsensus,AppConnQuery 来供底层不同模块调用. 其中每一类接口方法都是ABCI接口方法的一个子集。因此,实现了ABCIClient接口也就自然满足了这三类接口的要求。

接下来介绍ABCI Client接口的定义以及三类连接接口AppConnMempool,AppConnConsensus,AppConnQuery,并以localClientlocalserver 为例介绍以进程内交互方式实现的ABCI客户端与服务器, 以socketClientsocketserver为例介绍基于TSP实现的ABCI客户端与服务器.

ABCI Client接口

ABCI Client接口定义如下,可以看到,Client集合了之前介绍的三类ABCI方法,并且针对每一类方法都分别有一个同步版本和异步版本。同步版的方法是Tendermint打包好请求发送给App处理,并等待App返回的处理结果。异步版的方法则可以在发送完请求后去做别的事情,等收到响应之后再回来处理。因此,异步版的方法返回的结果是一个指向ReqRes结构体的指针, 里面包含了发送的请求和收到的响应, 以及对类请求/响应的回调函数, 稍后再具体介绍。此外,Client中还内嵌了一个Service的接口类型,该类型作为一个基本的服务类型接口多次出现在Tendermint的底层实现中,它是一个包含start()stop()reset()等方法的接口, 在Tendermint的整体架构一章已经有详细介绍,在此不再赘述。

// tendermint/abci/client/client.go 21-50
type Client interface {
    service.Service

    SetResponseCallback(Callback)
    Error() error

    FlushAsync() *ReqRes
    EchoAsync(msg string) *ReqRes
    InfoAsync(types.RequestInfo) *ReqRes
    SetOptionAsync(types.RequestSetOption) *ReqRes
    DeliverTxAsync(types.RequestDeliverTx) *ReqRes
    CheckTxAsync(types.RequestCheckTx) *ReqRes
    QueryAsync(types.RequestQuery) *ReqRes
    CommitAsync() *ReqRes
    InitChainAsync(types.RequestInitChain) *ReqRes
    BeginBlockAsync(types.RequestBeginBlock) *ReqRes
    EndBlockAsync(types.RequestEndBlock) *ReqRes

    FlushSync() error
    EchoSync(msg string) (*types.ResponseEcho, error)
    InfoSync(types.RequestInfo) (*types.ResponseInfo, error)
    SetOptionSync(types.RequestSetOption) (*types.ResponseSetOption, error)
    DeliverTxSync(types.RequestDeliverTx) (*types.ResponseDeliverTx, error)
    CheckTxSync(types.RequestCheckTx) (*types.ResponseCheckTx, error)
    QuerySync(types.RequestQuery) (*types.ResponseQuery, error)
    CommitSync() (*types.ResponseCommit, error)
    InitChainSync(types.RequestInitChain) (*types.ResponseInitChain, error)
    BeginBlockSync(types.RequestBeginBlock) (*types.ResponseBeginBlock, error)
    EndBlockSync(types.RequestEndBlock) (*types.ResponseEndBlock, error)
}

作为ABCI接口的一个子集,AppConnMempool接口定义了mempool相关的方法:

// tendermint/proxy/app_conn.go 23-31
type AppConnMempool interface {
    SetResponseCallback(abcicli.Callback)
    Error() error

    CheckTxAsync(types.RequestCheckTx) *abcicli.ReqRes

    FlushAsync() *abcicli.ReqRes
    FlushSync() error
}

CheckTxAsync实现了异步的CheckTx传输,ABCI服务器要提供有序的异步消息传输机制,来允许Tendermint在App处理完上一个CheckTx请求之前继续向App发送下一个请求。由于处理是异步的,CheckTxAsync方法返回的结果中既包含了响应结果,也包含了请求信息,以及本请求是否已经处理完毕的标记等。Tendermint可以在创建ReqRes结构体类型的变量时通过SetResponseCallback方法来设置响应的回调函数cb,以便在App返回响应之后根据结果来做进一步处理。针对CheckTx而言,这个回调函数即是通过判断交易是否通过了App的有效性检查,并将交易加入mempool中,同时通知其他模块mempool中已经有新交易等待打包。

//tendermint/abci/client/client.go 74-82
type ReqRes struct {
   *types.Request
   *sync.WaitGroup
   *types.Response // Not set atomically, so be sure to use WaitGroup.

   mtx  sync.Mutex
   done bool                  // Gets set to true once *after* WaitGroup.Done().
   cb   func(*types.Response) // A single callback that may be set.
}

具体实现ABCI 客户端时,在调用一些异步的ABCI方法时可能只是将这些请求保存到一个待发送的队列里面,并未真正发送出去。而Flush()方法会定时自动触发,用来将尚未发送给服务器的请求发送出去,以确保异步请求真正发送至ABCI服务器。

作为ABCI接口的一个子集,AppConnConsensus接口定义了区块执行相关的方法:

// tendermint/proxy/app_conn.go 11-21
type AppConnConsensus interface {
    SetResponseCallback(abcicli.Callback)
    Error() error

    InitChainSync(types.RequestInitChain) (*types.ResponseInitChain, error)

    BeginBlockSync(types.RequestBeginBlock) (*types.ResponseBeginBlock, error)
    DeliverTxAsync(types.RequestDeliverTx) *abcicli.ReqRes
    EndBlockSync(types.RequestEndBlock) (*types.ResponseEndBlock, error)
    CommitSync() (*types.ResponseCommit, error)
}

AppConnMempoolCheckTx相类似,DeliverTx的实现也是异步的。除此之外,其他方法都只需要同步的实现。异步方法的返回值都是*abcicli.ReqRes类型的,ABCI客户端需要为已经发送的请求保存相应的ReqRes,以便在收到服务器的响应时根据请求结果执行相应的回调函数。同步方法的返回结果则直接对应了本类型的Response,如:ResponseInitChain, ResponseBeginBlock等.

作为ABCI功能的一个子集,AppConnQuery接口定义了与Query相关的方法集合,这些接口方法的主要功能在前面小节中已经介绍过, 此处不再重复.

// tendermint/proxy/app_conn.go 33-41
type AppConnQuery interface {
    Error() error

    EchoSync(string) (*types.ResponseEcho, error)
    InfoSync(types.RequestInfo) (*types.ResponseInfo, error)
    QuerySync(types.RequestQuery) (*types.ResponseQuery, error)
}

尽管在实现时,这三类连接的实现都包含了同一个ABCIClient接口类型的变量,将ABCI Client功能拆分成这三类子接口能够使得Tendermint底层各个模块的功能划分看起来更加清晰:Mempool模块只需要依赖AppConnMempool的接口类型,而无需依赖共识相关的ABCI方法.

进程内交互

在App采用Go语言开发并且和Tendermint编译成一个二进制文件时,Tendermint和App采用的是进程内的通信方式,两者分别对应localClient & localserverlocalClient的实现方式比较简单,在初始化的时候,需要将App作为Application类型的变量传入:

// tendermint/abci/types/local_client.go 16-21
type localClient struct {
    service.BaseService

    mtx *sync.Mutex
    types.Application
    Callback
}

由于Tendermint对ABCI方法的调用是并发进行的,但App的实现并不要求是并发安全的。因此,需要在Client中加一把锁来控制与App的交互。Application同样被定义为一个接口类型,其中包含了Tendermint所期望上层App提供的ABCI方法,以供ABCI 客户端调用,从而驱动App的状态更新。

由于Tendermint和App被编译在一个二进制文件里,localClient可以直接调用Application暴露的接口,也不需要再额外定义Server类型。由于两者采用进程内通信方式进行交互,localClient所有的异步方法内部实际上都是按照同步的方式来进行的,只是在处理响应结果时,异步方法相比同步方法多了一个回调函数的执行,可以由Tendermint底层各组件选择使用同步的或异步的方法。模块在使用时可以选择异步的方法调用,并且为同类请求设置一个回调函数:如Mempool模块为AppConnMempool连接类型设置的globalCb函数,该函数作为一个全局的回调函数,处理对于任何AppConnMempool类的响应时都会被调用。

// tendermint/abci/types/application.go 11-26
type Application interface {
    // Info/Query Connection
    Info(RequestInfo) ResponseInfo                // Return application info
    SetOption(RequestSetOption) ResponseSetOption // Set application option
    Query(RequestQuery) ResponseQuery             // Query for state

    // Mempool Connection
    CheckTx(RequestCheckTx) ResponseCheckTx // Validate a tx for the mempool

    // Consensus Connection
    InitChain(RequestInitChain) ResponseInitChain    // Initialize blockchain w validators/other info from TendermintCore
    BeginBlock(RequestBeginBlock) ResponseBeginBlock // Signals the beginning of a block
    DeliverTx(RequestDeliverTx) ResponseDeliverTx    // Deliver a tx for full processing
    EndBlock(RequestEndBlock) ResponseEndBlock       // Signals the end of a block, returns changes to the validator set
    Commit() ResponseCommit                          // Commit the state and return the application Merkle root hash
}

TSP交互

当采用Java, C++, Rust等语言开发上层App时, 无法与Go语言实现的Tendermint项目编译成一个二进制文件时,可以选择使用Tendermint Socket Protocol (TSP)来进行交互通信. TSP默认是建立在TCP连接之上的,因此继承了TCP连接的可靠性和和稳定性。SocketClient的实现原理如下:在socketClient被启动时,会同时开启两个go-routine:用来发送请求的sendRoutine和 用来接收响应的recvRoutine。与localClient的实现一个很大的不同在于,socketClient真正实现了异步方法的“异步”逻辑,即每次调用一个异步方法CheckTxAsync时,socketClient并不会把该请求立即发送出去,而是存储在自己的一个待发送队列里,同时设置一个定时器,在定时器被自动触发时,才会真正把待发送队列里的所有请求发送出去。

// tendermint v0.33.3 abci/client/socket_client.go:29-44
type socketClient struct {
    service.BaseService

    addr        string
    mustConnect bool
    conn        net.Conn

    reqQueue   chan *ReqRes
    flushTimer *timer.ThrottleTimer

    mtx     sync.Mutex
    err     error
    reqSent *list.List                            // list of requests sent, waiting for response
    resCb   func(*types.Request, *types.Response) // called on all requests, if set.

}

socketClient结构体的定义如上, 其中各个字段的含义解释如下:

  • BaseService封装了一个通用的服务类型,包含了服务的名字、是否已经启动/停止、日志器以及具体的实现
  • addr,mustConnect,conn字段记录了网络连接相关的信息
  • flushTimer字段是关于前面提到的定时器, 以便定期将待发送的请求发送给服务器, 并同时给服务器发送Flush请求让服务器将已经处理完毕的请求结果按序返回
  • reqQueue字段表示待发送队列,在每个异步方法被调用时,会将相应请求放入该队列
  • reqSent字段存储了待发送的请求的ReqRes变量,用来在收到请求时对响应结果进行处理
  • resCb字段与localClient中的类似,可以对同一类连接请求设置一个共同的回调函数

socketClient通过socket连接接收到App的响应时,会从reqSent中按序取出相应的ReqRes,将其请求状态标记为已完成,并从reqSent中移除,然后调用为socketClient设置的全局的回调函数,如果该ReqRes变量也设置了回调函数的话,还会再调用为每一个ReqRes设置的回调函数,至此完成请求的发送和响应的处理。

对比socketClient的功能,可以想到socketserver需要实现的逻辑就是接收并处理请求,以及发送对请求的响应。socketserver的结构体定义如下:

//tendermint v0.33.3 abci/client/socket_server.go:17-30
type SocketServer struct {
    service.BaseService

    proto    string
    addr     string
    listener net.Listener

    connsMtx   sync.Mutex
    conns      map[int]net.Conn
    nextConnID int

    appMtx sync.Mutex
    app    types.Application
}

socketClient不同的是,Tendermint底层不同组件(如共识组件、mempool组件等)可能都与App有一个ABCI连接,因此socketserver端需要维护一个connsmap,以便管理所有连接。在启动socketServer时,会开启一个acceptConnectionsRoutine的goroutine来接收来自各个socketClient的连接请求,对每一个接收到的连接,都会再启动两个goroutine:用来处理请求的handleRequestsRoutine和用来返回结果的handleResponsesRoutine。在socketserver接收到Flush类型的请求时,会将已经处理好的响应结果按序返回给socketClient,至此完成请求的处理和响应的返回.

总结

Tendermint与App之间通过ABCI接口进行交互,从功能上ABCI连接可以分为三类:AppConnMempool相关的主要用来做交易的有效性检查。AppConnConsensus允许Tendermint将新生成的区块分阶段提交给App执行。AppConnQuery连接用来同步Tendermint与App的状态同步,并处理状态查询。ABCI接口使得App的业务逻辑可以基于Tendermint之上独立开发,负责独立功能的模块逻辑更加清晰。

**本文由CoinEx Chain开发团队成员Jia、longcpp撰写。CoinEx Chain是全球首条基于Tendermint共识协议和Cosmos SDK开发的DEX专用公链,借助IBC来实现DEX公链、智能合约链、隐私链三条链合一的方式去解决可扩展性(Scalability)、去中心化(Decentralization)、安全性(security)区块链不可能三角的问题,能够高性能的支持数字资产的交易以及基于智能合约的Defi应用。