Daoleno

Cosmos-SDK 应用程序剖析

August 12, 2020 • ☕️☕️☕️☕️ 18 min read

Cosmos-SDK 中文文档参见 Cosmos-SDK CN Doc

SDK 应用程序剖析

Node Client

全节点的核心进程是基于 SDK 包的。网络中的参与者运行此过程以初始化其状态机,与其他全节点连接并在新块进入时更新其状态机。

                ^  +-------------------------------+  ^
                |  |                               |  |
                |  |  State-machine = Application  |  |
                |  |                               |  |   Built with Cosmos SDK
                |  |            ^      +           |  |
                |  +----------- | ABCI | ----------+  v
                |  |            +      v           |  ^
                |  |                               |  |
Blockchain Node |  |           Consensus           |  |
                |  |                               |  |
                |  +-------------------------------+  |   Tendermint Core
                |  |                               |  |
                |  |           Networking          |  |
                |  |                               |  |
                v  +-------------------------------+  v

区块链全节点以二进制形式表示,通常以 -d 后缀表示守护程序(例如,appd 表示 appgaiad 表示 gaia)。这个二进制文件是通过编译一个简单的代码文件 main.go 构建的,main.go 通常位于./cmd/appd/中。 此操作通常通过用 Makefile 编译。

编译了二进制文件,就可以通过运行 start 命令 来启动节点。 此命令功能主要执行三件事:

  1. [app.go] 创建了一个状态机实例。
  2. 用最新的已知状态初始化状态机,该状态机是从存储在 ~/.appd/data 文件夹中的 db 中提取的。 此时,状态机的高度为:appBlockHeight
  3. 创建并启动一个新的 Tendermint 实例。 该节点将与对等节点进行连接交换信息。 它将从他们那里获取最新的 blockHeight,如果它大于本地的 appBlockHeight,则重播块以同步到该高度。 如果 appBlockHeight0,则该节点从创世开始,并且 Tendermint 通过 ABCI 接口向 app 发送 InitChain 初始化链命令,从而触发 InitChainer

Core Application File

通常,状态机的核心是在名为 app.go 的文件中定义的。 它主要包含“应用程序的类型定义”和“创建和初始化它”的功能。

Type Definition of the Application

app.go 中的一个重要部分是应用程序的结构。 它通常由以下部分组成:

  • app.go 中定义的基于 baseapp 扩展的应用程序。 当交易由 Tendermint 发送到应用程序时,app 使用 baseapp 的方法将它们转送到对应的模块。 baseapp 为应用程序实现了大多数核心逻辑,包括所有的 ABCI 方法和转送消息逻辑。
  • 包含整个状态的 keys,他是基于 Cosmos SDK 的 multistore 实现的。 每个模块使用 multistore 的一个或多个存储后端来存储其状态。可以使用在 app 类型中声明的特定键来访问这些存储。这些密钥以及 keepers 是 Cosmos SDK 的对象功能模型的核心。
  • 模块 keeper 的列表。 每个模块都会抽象定义一个 keeper,该 keeper 实现模块存储的读写。 一个模块的 keeper 方法可以从其他模块(如果已授权)中调用,这就是为什么它们在应用程序的类型中声明并作为接口导出到其他模块的原因,以便后者只能访问授权的功能。
  • 应用程序的 codec 用于序列化和反序列化数据结构以便存储它们,因为存储只能持久化 []bytes编解码器必须是确定性的。 默认编解码器为 amino
  • 模块管理器是一个包含应用程序模块列表的对象。 它简化了与这些模块相关的操作,例如 register routes ,query route 或设置各种功能的模块之间顺序执行情况,例如 InitChainer,BeginBlocke 和 EndBlocker
  • 请参阅 gaia 中的应用程序定义示例

Constructor Function

此函数构造了上面定义的应用程序。在应用的 start 命令中使用,它必须具有 AppCreator 签名。

以下是此功能执行的主要操作:

  • 创建初始化一个新的 codec 实例,并使用基础模块管理器初始化每个应用程序模块的 codec
  • 使用 baseapp 实例,编解码器和所有适当的存储键的引用实例化一个新应用程序。
  • 使用每个应用程序模块的 NewKeeper 功能实例化在应用程序的类型中定义的所有 keeper。 注意,所有 keeper 必须以正确的顺序实例化,因为一个模块的 NewKeeper 可能需要引用另一个模块的 keeper
  • 使用每个应用模块的 AppModule 来实例化应用程序的模块管理器
  • 使用模块管理器,初始化应用程序的 routes 和 query route。 当交易由 Tendermint 通过 ABCI 中继到应用程序时,它使用此处定义的路由被路由到相应模块的回调 handler。 同样,当应用程序收到查询时,使用此处定义的查询路由将其路由到适当的模块的 querier。
  • 使用模块管理器,注册应用程序的模块的 invariants。 invariants 是在每个块末尾评估的变量(例如 token 的总供应量)。 检查不变式的过程是通过 InvariantsRegistry 的特殊模块完成的。 invariants 应等于模块中定义的预测值。 如果该值与预测的值不同,则将触发不变注册表中定义的特殊逻辑(通常会中断链)。这对于确保不会发现任何严重错误并产生难以修复的长期影响非常有用。
  • 使用模块管理器,在每个应用程序的模块 的 InitGenesis,BegingBlocker 和 EndBlocker 函数之间设置执行顺序。 请注意,并非所有模块都实现这些功能。
  • 模块实现这些功能。
  • 设置其余的应用程序参数:

    • InitChainer :在应用程序首次启动时对其进行初始化。
    • BeginBlockerEndBlocker:在每个块的开始和结尾处调用。
    • anteHandler:用于处理费用和签名验证。
  • 挂载存储.
  • 返回应用实例.

请注意,此函数仅创建该应用的一个实例,如果重新启动节点,状态将从 〜/.appd/data 文件夹中保留下来的状态中加载,如果节点是第一次启动,则从创世文件生成。查看程序构造的过程示例

InitChainer

InitChainer 用于根据创世文件初始化应用程序的状态。 当应用程序从 Tendermint 引擎收到InitChain消息时调用该方法,该方法会在节点的appBlockHeight == 0(即创世)时运行。 应用程序必须通过 SetInitChainer 方法设置其 constructor 中的Initchainer

通常,InitChainer主要由每个应用程序模块的 InitGenesis 函数组成。 这是通过调用模块管理器的 InitGenesis 函数来完成的,而模块管理器的 InitGenesis 函数将依次调用其包含的每个模块的 InitGenesis 函数。 请注意,必须使用模块管理器的 SetOrderInitGenesis 方法设置模块的 InitGenesis 函数的顺序。 这是在应用程序的构造函数 application-constructor 中完成的,必须在 SetInitChainer 之前调用 SetOrderInitGenesis。

查看来自 gaiaInitChainer示例

BeginBlocker and EndBlocker

该 SDK 为开发人员提供了在其应用程序中实现自定义代码可能性。 这是通过两个名为 BeginBlockerEndBlocker 的函数实现的。当应用程序分别从 Tendermint 引擎接收到 BeginBlockEndBlock 消息时,将调用它们,它们分别在每个块的开始和结尾处发生。应用程序必须通过 SetBeginBlockerSetEndBlocker 方法在其 constructor 中设置 BeginBlockerEndBlocker

通常,BeginBlockerEndBlocker 函数主要由每个应用程序模块的 BeginBlockEndBlock 函数组成。 该行为通过调用模块管理器的 BeginBlock 和 EndBlock 函数来完成,而后者又会调用其包含的每个模块的 BeginBlock 和 EndBlock 函数。 请注意,必须分别在模块管理器中使用 SetOrderBeginBlock 和 SetOrderEndBlock 方法来设置模块的 BegingBlock 和 EndBlock 函数必须调用的顺序,并且调用需要在 SetBeginBlocker 和 SetEndBlocker 函数之前。

附带说明,请记住特定于应用程序的区块链是确定性的,这一点很重要。开发人员必须注意不要在 BeginBlocker 或 EndBlocker 中引入不确定性,还必须注意不要使它们在计算上过于昂贵,因为当 执行 BeginBlocker 和 EndBlocker 时 gas 不会限制计算代价。

请参阅 gaia 中的 BeginBlockerEndBlocker 函数的示例

Register Codec

MakeCodec 函数是 app.go 文件的最后一个重要功能。 此函数的目的是使用 RegisterCodec 函数实例化 codeccdc,例如 amino 初始化 SDK 的编解码器以及每个应用程序的模块。

为了注册应用程序的模块,MakeCodec 函数在 ModuleBasics 上调用 RegisterCodecModuleBasics 是一个基本管理器,其中列出了应用程序的所有模块。 它在init()函数中得到实例化,仅用于注册应用程序模块的非依赖元素(例如编解码器)。 要了解有关基本模块管理器的更多信息,请点击这里

请参阅 gaia 中的 MakeCodec 示例

Modules

Modules 是 SDK 应用程序的灵魂。它们可以被视为状态机中的状态机。当交易通过 ABCI 从底层的 Tendermint 引擎中继到应用程序时,会由 baseapp 分发到对应模块来处理。这种范例使开发人员可以轻松构建复杂的状态机,因为他们所需的大多数模块通常已经存在。对于开发人员而言,构建 SDK 应用程序所涉及的大部分工作都围绕构建其应用程序尚不存在的自定义模块,并将它们与已经存在的模块集成到一个统一的应用程序中。在应用程序目录中,标准做法是将模块存储在 x/ 文件夹中(不要与 SDK 的x/文件夹混淆,该文件夹包含已构建的模块)。

Application Module Interface

模块必须实现 Cosmos SDK AppModuleBasic 中的 interfaces 和 AppModule。 前者实现了模块的基本非依赖性元素,例如编解码器,而后者则处理了大部分模块方法(包括需要引用其他模块的keeper的方法)。AppModuleAppModuleBasic 类型都在名为 module.go 的文件中定义。

AppModule 在模块上公开了一组有用的方法,这些方法有助于将模块组合成一个一致的应用程序。 这些方法是从模块管理器中调用的,该模块管理应用程序的模块集合。

Message Types

每个 module 定义 messages 接口。 每个 transaction 包含一个或多个 messages

当全节点接收到有效的交易块时,Tendermint 通过 DeliverTx 将每个交易发到应用程序。然后,应用程序处理交易:

  • 收到交易后,应用程序首先从 []bytes 反序列化得到。
  • 然后,在提取交易中包含的消息之前,它会验证有关交易的一些信息,例如费用支付和签名。
  • 使用 message 的 Type()方法,baseapp 可以将其发到对应模块的回调 handler 以便对其进行处理。
  • 如果消息已成功处理,则状态将更新。

模块开发人员在构建自己的模块时会创建自定义消息类型。 通常的做法是在消息的类型声明前加上 Msg。 例如,消息类型 MsgSend 允许用户传输 tokens

它由 bank 模块的回调 handler 处理,最终会调用 auth 模块来写 keeper 以更新状态。

Handler

回调 handler 是负责处理 baseapp 传递的 message 的模块。 仅当通过 ABCI 接口的 DeliverTx 从 Tendermint 收到交易时,才执行模块的处理程序功能。 如果通过 CheckTx,仅执行无状态检查和与费用相关的有状态检查。为了更好地理解 DeliverTxCheckTx 之间的区别以及有状态和无状态检查之间的区别,请看这里

模块的处理程序通常在名为 handler.go 的文件中定义,并包括:

  • NewHandler 将消息发到对应的回调 handler。 该函数返回一个 handler 函数,这个函数之前在 AppModule 中注册,以在应用程序的模块管理器中初始化应用程序的路由。
  • 模块定义的每种消息类型的处理函数。开发人员在这些函数中编写消息处理逻辑。这通常包括进行状态检查以确保消息有效,并调用 [keeper](https://docs.cosmos.network/master/basics/app-anatomy.html#keeper) 的方法来更新状态。

处理程序函数返回结果类型为 sdk.Result,该结果通知应用程序消息是否已成功处理:

Querier

Queriershandlers 非常相似,只不过它们是用来查询状态而非处理交易的。 用户从 interface 发起 query,并提供 queryRoute 和一些 data。 然后使用 queryRoute 通过 baseapphandleQueryCustom 方法查询到正确的应用程序的 querier 函数

模块的 Querier 是在名为 querier.go 的文件中定义的,包括:

  • NewQuerier 查找对应 query 函数。 此函数返回一个 querier 函数,此前它在 AppModule 中注册,以在应用程序的模块管理器中用于初始化应用程序的查询路由。参阅 nameservice
  • 对于模块定义的每种需要查询的数据类型,都具有一个查询器功能。开发人员在这些函数中编写查询处理逻辑。这通常涉及调用 keeper 的方法来查询状态并将其序列化为 JSON。

Keeper

Keepers 是模块存储部件。要在模块的存储区中进行读取或写入,必须使用其 keeper 方法。这是由 Cosmos SDK 的 object-capabilities 模型确保的。 只有持有存储密钥的对象才能访问它,只有模块的 keeper 才应持有该模块存储的密钥。

Keepers 通常在名为 keeper.go 的文件中定义。 它包含 keeper 的类型定义和方法。

keeper 类型定义通常包括:

  • 多重存储中模块存储的密钥

    • 参考其他模块keepers。 仅当 keeper 需要访问其他模块的存储(从它们读取或写入)时才需要。
  • 对应用程序的编解码器的引用。 keeper 需要它在存储结构之前序列化处理,或在检索它们时将反序列化处理,因为存储仅接受 []bytes 作为值。

与类型定义一起,keeper.go 文件的一个重要组成部分是 Keeper 的构造函数 NewKeeper。 该函数实例化上面定义的类型的新 keeper,并带有 codec,存储 keys 以及可能引用其他模块的 keeper 作为参数。从应用程序的构造函数中调用 NewKeeper 函数。文件的其余部分定义了 keeper 的方法,主要是 getter 和 setter。

Command-Line and REST Interfaces

每个模块都定义了 application-interfaces 向用户公开的命令行命令和 REST routes。 用户可以创建模块中定义的类型的消息,或查询模块管理的状态的子集。

CLI

通常,与模块有关的命令在模块文件夹中名为 client/cli 的文件夹中定义。CLI 将命令分为交易和查询两类,分别在 client/cli/tx.goclient/cli/query.go 中定义。这两个命令基于 Cobra Library之上:

  • Transactions 命令使用户可以生成新的交易,以便可以将它们包含在块中并更新状态。应该为模块中定义的每个消息类型 message-types 创建一个命令。该命令使用户提供的参数调用消息的构造函数,并将其包装到交易中。SDK 处理签名和其他交易元数据的添加。
  • 用户可以查询模块定义的状态子集。查询命令将查询转发到应用程序的查询路由,然后将查询请求路由到提供queryRoute参数的相应 querier。

REST

模块的 REST 接口允许用户生成交易并通过对应用程序的 light client daemon(LCD) 查询状态。 REST 路由在 client/rest/rest.go 文件中定义,该文件包括:

  • RegisterRoutes 函数,用于注册路由。从主应用程序的接口 application-interfaces 中为应用程序内使用的每个模块调用此函数。SDK 中使用的路由器是 Gorilla’s mux
  • 需要公开的每个查询或交易创建功能的自定义请求类型定义。这些自定义请求类型基于 Cosmos SDK 的基本请求类型构建:
  • 每个请求的一个处理函数可以找到给定的模块。 这些功能实现了服务请求所需的核心逻辑。

Application Interface

Interfaces 允许用户与全节点客户端进行交互。 这意味着从全节点查询数据,或者接受全节点中包含在块中的新交易。

通过汇总在应用程序使用的每个模块中定义的 CLI 命令构建 SDK 应用程序的 CLI。 应用程序的 CLI 通常具有后缀-cli(例如 appcli),并在名为cmd/appcli/main.go的文件中定义。 该文件包含:

  • main()函数,用于构建 appcli 接口客户端。这个函数准备每个命令,并在构建它们之前将它们添加到rootCmd中。在 appCli 的根部,该函数添加了通用命令,例如 status,keys 和 config,查询命令,tx 命令和 rest-server。
  • 查询命令是通过调用queryCmd函数添加的,该函数也在 appcli/main.go 中定义。此函数返回一个 Cobra 命令,其中包含在每个应用程序模块中定义的查询命令(在main函数中作为sdk.ModuleClients数组传递),以及一些其他较低级别的查询命令,例如阻止或验证器查询。查询命令通过使用 CLI 的命令 appcli query [query]来调用。
  • 通过调用txCmd函数来添加交易命令。与queryCmd类似,该函数返回一个 Cobra 命令,其中包含在每个应用程序模块中定义的 tx 命令,以及较低级别的 tx 命令,例如交易签名或广播。使用 CLI 的命令appcli tx [tx]调用 Tx 命令。
  • registerRoutes 函数,在初始化轻客户端(LCD)时从 main 函数调用。 registerRoutes调用应用程序每个模块的 RegisterRoutes 功能,从而注册该模块 routes 到 LCD 的查询路由。可以通过运行以下命令 appcli rest-server来启动 LCD。

nameservice demo 中查看应用程序的主要命令行示例

Dependencies and Makefile

因为开发人员可以自由选择其依赖项管理器和项目构建方法。 也就是说,当前最常用的版本控制框架是 go.mod。 它确保在整个应用程序中使用的每个库都以正确的版本导入。 请参阅 demo 中的示例

通常使用 Makefile 来构建应用程。 Makefile 主要确保在构建应用程序的两个入口点 appdappcli 之前运行 go.mod。 请参阅 nameservice demo 中的 Makefile 示例