构建与编码

本节中的主题提供了对用于构造运行时逻辑的编写代码的更详细探索,包括可用于构建和与节点交互的库和工具,以及如何编译逻辑以构建 Substrate 节点的更近一步的观察。

代码库的介绍

在使用 node template 时,你不需要知道任何有关正在使用的底层架构或库的信息,因为基本组件已经组装好并可以直接使用。

然而,如果你想要设计和构建自定义区块链,你可能需要熟悉可用的库,并了解这些不同库的功能。

substrate 架构设计中,你了解了 Substrate 节点的核心组件以及节点的不同部分如何承担不同的责任。在技术层面上,节点不同层之间的职责分离反映在用于构建基于 Substrate 的区块链的核心库中。下图说明了库如何映射外节点和运行时职责,以及基本库如何提供两者之间的通信层。

核心节点库

使 Substrate 节点能够处理其网络职责的库,包括共识算法和块执行在内的是以使用 sc_ 为前缀名的 Rust crates。例如,sc_service库负责为 Substrate 区块链构建网络层,管理网络参与者和交易池之间的通信。

在外部节点和运行时之间提供通信层的库是以使用 sp_ 为前缀名的 Rust crates。这些库编排了需要外部节点和运行时之间交互活动。例如,sp_std 库从Rust 标准库中获取有用的基本逻辑,并使其可用于依赖于运行时的任何代码。

使你能够构建运行时逻辑并对传入和传出运行时的信息进行编码和解码的库是 Rust crates,在 crate 名称中使用 frame_ 前缀。frame_* 库为运行时提供了基础代码结构。例如,frame_system 库提供了一系列与其他 Substrate 组件交互的基本功能,以及 frame_support 允许你声明运行时存储项、错误和事件。

除了 frame_* 库提供的基础代码结构外,运行时还可以包括一个或多个 pallet_* 库。每个使用 pallet_ 为前缀的 Rust crate 都代表一个 FRAME 模块。在大多数情况下,你使用 pallet_* 库来组装你想要加入区块链的功能,以满足你的项目。

你可以使用 sp_* 核心库公开的基础代码构建 Substrate 运行时,而无需使用 frame_*pallet_* 库。然而,frame_*pallet_* 库为构建 Substrate 运行时提供了最有效方法。

模块化架构

核心库的分离为编写区块链逻辑提供了灵活的模块化架构。基础代码库提供了一个外部节点和运行时都可以在此之上构建的方式,而无需彼此直接通信。基础类型和 traits 在它们各自独立的 crates 中公开,因此它们可用于外部节点和运行时组件,而不会引入循环依赖性问题。

前端代码库

除了使你能够基于 Substrate 区块链的核心库构建区块链外,你还可以使用客户端库与 Substrate 节点进行交互。你可以使用客户端库来构建特定应用程序的前端。通常,客户端库公开的功能是在 Substrate 远程过程调用(RPC)APIs 的顶部实现的。有关使用元数据和前端库来构建应用程序的更多信息,可参见应用程序开发

构建过程

在架构设计中,你了解到 Substrate 节点由外部节点主机和运行时执行环境组成。这些节点组件通过调用运行时 API 和调用主机函数相互通信。在本节中,你将进一步了解如何将 Substrate 运行时编译为平台本机可执行文件和存储在区块链上的 WebAssembly(Wasm)二进制文件。在你了解了二进制文件如何编译的内部工作原理之后,你将进一步了解为什么有两个二进制文件,何时使用,以及如果需要,如何更改执行策略。

编译一个优化的组件

你可能已经知道,可以通过在 Substrate node 项目的根目录中运行 cargo build --release 命令来编译 Substrate 节点。此命令为项目构建平台特定的可执行文件和 WebAssembly 二进制文件,并生成优化后的可执行文件。生成优化的可执行文件包括一些编译后处理。

作为优化过程的一部分,WebAssembly 运行时二进制会通过一系列内部步骤进行编译和压缩,然后再将其包含在链中的 genesis 状态中。为了让你更好地理解该过程,下图总结了这些步骤。

以下各小节将更详细地描述构建过程。

构建 WebAssembly 二进制文件

WebAssembly中包含的功能
用于自定义构建过程的环境变量
用于自定义构建过程的环境变量
压缩 WebAssembly 二进制文件

执行策略

构建没有 native runtime 的 WebAssembly

不使用 WebAssembly 编译 Rust

运行时存储

存储项目

声明存储项目

访问存储项目

哈希算法

初始化配置

最佳实践

交易、权重和费用

如何计算费用

使用 transaction payment pallet

有特殊要求的交易

默认权重注释

提交派发权重校准

自定义费用

自定义 pallets

Pallet 宏和属性

有用的 FRAME traits

运行时实现

Pallet 耦合

紧密的耦合 Pallet

松散的耦合 Pallet

选择 Pallet 耦合策略

事件和错误

声明一个事件

向运行时公开事件

存下一个事件

支持的类型

监听一个事件

错误

Randomness

确定随机性

Substrate's randomness trait

安全属性

Chain specification

自定义外部节点配置

自定义初始配置

存储 chain specification 信息

通过一个 chain specification 启动节点

为运行时声明存储项

Raw chain specifications

Privileged calls and origins

Raw origins

Origin call

Custom origins

Next steps

远程过程调用

远程过程调用,或叫RPCs,是外部编程的一种方式,例如,一个浏览器或前端应用程序,与一个 Substrate 节点通信。他们通常被用作检查存储值、提交交易、并查询当前共识授权。Substrate 附带了几个默认的RPCs。在许多案例中,添加自定义 RPCs 到你的节点中是很有用的。

RPC扩展构建器

要将自定义 RPC 客户端连接到 Substrate 节点时,你必须提供一个被称为 RPC 扩展构建器的函数。此函数会采用一个参数,为节点应该接受还是拒绝不安全的 RPC 调用,并返回一个节点需要创建 JSON RPC 的 IoHandler。关于更多上下文的内容,可通过查看 RpcExtensionBuilder trait API 文档。

RPC类型

RPCs 可以是节点共识机制的接口,也可以是任何外部用户向区块链提交交易的接口。在所有情况下,重要的是要考虑 RPCs 公开哪些 endpoints。启动一个节点,并运行此命令以查看节点的 RPC API 的全部列表:

curl -H "Content-Type: application/json" -d '{"id":1, "jsonrpc":"2.0", "method": "rpc_methods"}' http://localhost:9933/

公开的RPCs

Substrate 节点提供以下命令行选项,允许你公开 RPC 接口:

--ws-external
--rpc-external
--unsafe-ws-external
--unsafe-rpc-external

默认情况下,如果你尝试暴露 RPC 接口并同时运行验证者节点,则该节点会拒绝启动;--unsafe-* 标签允许你取消此安全限制。暴露 RPC 接口会对外暴露一个巨大的攻击可能性,必须要仔细的审查。

有很多 RPC 方法可以用来控制节点的行为,但是你应该避免暴露它。例如,你不应该暴露下面的 RPC 方法:

  • author_submitExtrinsic — 允许向本地交易池提交交易。
  • author_insertKey — 允许向本地密钥存储库插入私钥。
  • author_rotateKeys — session 密钥轮换。
  • author_removeExtrinsic — 从池中移除并禁用 extrinsic。
  • system_addReservedPeer — 添加保留节点。
  • system_removeReservedPeer — 移除保留节点。

你还应该避免暴露可能需要很长时间执行的 RPC 方法,这可能会组织客户端的状态同步。例如,你应该避免使用下面的 RPC 方法:

  • storage_keys_paged — 获取状态中具有指定前缀和分页支持的所有 key。
  • state_getPairs — 获取状态中具有指定前缀的所有 key 及其值。

这些 RPCs 是使用 #[rpc(name = "rpc_method")] 宏声明的,其中 rpc_method 是函数的名称, 例如,author_submitExtrinsic 对应于 submit_extrinsic

如果请求来自不受信任的用户,过滤掉此类调用则至关重要。实现这一点的方法是通过 JSON-RPC 代理,该代理能够过滤检查调用并仅传递允许的 APIs。

RPCs for remote_externalities

remote_externalities 上下文中存在一种特殊类型的 RPCs 使用方法。rpc_api 允许你对 Substrate 节点进行一次性的调用,例如,对于使用 try_runtime 等工具进行测试非常有用。

Endpoints

当启用任何 Substrate 节点时,你可以使用下面两个 endpoints:

  • HTTP endpoint:http://localhost:9933
  • WebSocket endpoint:ws://localhost:9944

大多数 Substrate 前端库和工具都使用更强大的 Websocket endpoint 与区块链进行交互。通过 WebSockets,你可以订阅链的状态,诸如事件,以及你的区块链发生在无论任何条件下的所有改变,都会接收推送通知。

要调用 Metadata 端点,需要与运行节点一起运行下面这行命令:

curl -H "Content-Type: application/json" -d '{"id":1, "jsonrpc":"2.0", "method": "state_getMetadata"}' http://localhost:9933/

该命令的返回值不是 human-readable 的格式,基于此,它需要使用类型编码(SCALE)

每个存储项都有一个相关联的存储 key,用于查询存储,这就是 RPC endpoints 知道怎么去查看。

示例

state_getMetadata RPC 请求:

function get_metadata_request(endpoint) {
  let request = new Request(endpoint, {
    method: "POST",
    body: JSON.stringify({
      id: 1,
      jsonrpc: "2.0",
      method: "state_getMetadata",
    }),
    headers: { "Content-Type": "application/json" },
  });
  return request;
}

原文本解码:

function decode_metadata(metadata) {
  return new TextDecoder().decode(util.hexToU8a(metadata));
}

state_getStorage RPC request:

Request:   {"id":1,"jsonrpc":"2.0","method":"state_getStorage",["{storage_key}"]}

其中 storage_key 是由对应名称的pallet,函数和 key(可选的)生成的参数。

function get_runtime_storage_parameter_with_key(module_name, function_name, key) {
  // We use xxhash 128 for strings the runtime developer can control
  let module_hash = util_crypto.xxhashAsU8a(module_name, 128);
  let function_hash = util_crypto.xxhashAsU8a(function_name, 128);

  // We use blake2 256 for strings the end user can control
  let key_hash = util_crypto.blake2AsU8a(keyToBytes(key));

  // Special syntax to concatenate Uint8Array
  let final_key = new Uint8Array([...module_hash, ...function_hash, ...key_hash]);

  // Return a hex string
  return util.u8aToHex(final_key);
}

应用程序开发

元数据系统

元数据格式

RPC APIs

连接到一个节点

开始构建

前端用例

升级运行时

无分叉运行时升级是用于 Substrate 框架区块链开发的的一个定义特性。无需分叉代码库就可以更新运行时逻辑,这使你的区块链能够随着时间的推移而发展和改进。通过包含为区块链定义运行时执行环境(运行时 WebAssembly blob)状态中的一个元素,可以实现此功能。

因为运行时是区块链状态的一部分,所以网络维护人员可以利用区块链的无信任、去中心化共识功能来安全地增强运行时。

在用于运行时开发的 FRAME 系统中,系统库定义了用于更新运行时定义的 the set_code call升级一个运行中的网络教程演示了在不关闭节点或中断操作的情况下升级运行时的两种方法。但是,本节教程中的两个升级都演示了向运行时添加功能,而不是更新现有的运行时状态。如果运行时升级需要更改现有状态,则可能需要进行存储迁移。

运行时版本控制

构建过程中,你了解了编译节点会同时生成平台的原生二进制文件和 WebAssembly 二进制文件,并且可以通过执行策略命令行选项来控制在区块生产过程的不同地方选择使用哪一个二进制文件。选择要与之通信的运行时执行环境的组件称为执行程序。尽管你可以覆盖自定义场景的默认执行策略,但在大多数情况下,或者执行程序通过评估原生二进制和 WebAssembly 二进制的以下信息选择适当的二进制文件来使用:

  • spec_name
  • spec_version
  • authoring_version

为了向执行程序进程提供此信息,运行时包含类似于以下的运行时版本结构

pub const VERSION: RuntimeVersion = RuntimeVersion {
  spec_name: create_runtime_str!("node-template"),
  impl_name: create_runtime_str!("node-template"),
  authoring_version: 1,
  spec_version: 1,
  impl_version: 1,
  apis: RUNTIME_API_VERSIONS,
  transaction_version: 1,
};

结构中的参数提供了以下信息: |This parameter|Provides this| |:-----|:-----| |spec_name|不同 Substrate 运行时的标识符。| |impl_name|spec 实现的名称。这对节点影响不大,只用于区分不同团队实现的代码。| |authoring_version|authorship 接口的版本。除非是它的原生运行时,否则生产节点不会尝试生成块。| |spec_version|运行时 specification 的版本。完整节点不会尝试使用它的原生运行时来替代链上的 Wasm 运行时,除非 Wasm 和原生二进制文件之间的所有 spec_namespec_versionauthoring_version 都是相同的。spec_version 的更新可以作为一个 CI 过程自动化,就像 Polkadot 网络一样。当 transaction_version 有更新时,通常会递增此参数。| |impl_version|specification 的实现版本,节点可以忽略这一点,它只用于表明代码是不同的。只要 authoring_versionspec_version 是相同的,代码本身就可能会发生变化,但是原生二进制文件和 Wasm 二进制文件做的是相同的事情。通常只有逻辑之外的破坏性优化才会导致 impl_version 的更改。| |transaction_version|处理交易的接口版本。此参数可以用于同步硬件钱包或其他签名设备的固件更新,以验证运行时交易是否有效。该参数允许硬件钱包知道哪些交易可以安全签名。如果在 construct_runtime! 宏中的 pallet 索引发生变化,或者如果对可调用的函数有任何更改,这个数字就会碰撞,比如参数的数量或参数类型。如果更新了这个编号,那么 spec_version 也必须更新。| |apis|支持的运行时 APIs 及其版本列表。|

编排引擎,有时被称为执行程序,验证原生运行时是否具有相同的一致性,在 WebAssembly 选择执行之前,将驱动逻辑作为 WebAssembry。但是由于运行时版本是手动设置的,因此如果运行时版本被错误表示,编排引擎仍然可能做出不适当的决策。

访问运行时版本

FRAME 系统通过 state.getRuntimeVersion RPC endpoint 公开运行时版本信息。endpoint 接受一个可选的块标识符。然而,在大多数情况下,你使用运行时元数据来理解运行时公开的 APIs 以及如何与这些 APIs 交互。只有当链的运行时 spec_version 更改时,运行时元数据才应该更改。

无分叉的运行时升级

传统区块链在升级其链的状态转换功能时,需要进行硬分叉。硬分叉要求所有节点操作员停止其节点并手动升级到最新的可执行文件。对于分布式生产网络,协调硬分叉升级可能是一个复杂的过程。

运行时版本属性使基于 Substrate 的区块链能够实时升级运行时逻辑,而不会造成网络分叉。

要执行无分叉运行时升级,Substrate 使用现有运行时逻辑将存储在区块链上的 Wasm 运行时更新为具有新逻辑的新共识迭代版本。作为共识过程的一部分,这种升级被推送到网络上的所有全节点。在 Wasm 运行时升级之后,编排引擎会发现本机运行时 spec_namespec_versionauthoring_version 不再与新的 Wasm 运行时匹配。因此,编配引擎会执行规范的 Wasm 运行时,而不是在任何执行流程中使用原生本地运行时。

存储迁移

存储迁移是自定义的一次性函数,允许你更新存储以适应运行时中的更改。例如,如果运行时升级将用于表示用户余额的数据类型从无符号整数更改为有符号整数,存储迁移将读取现有值为无符号整数,并回写已转换为有符号整数的更新值。如果你没有在需要时对数据的存储方式进行此类更改,运行时就无法正确地解释存储值以包含在运行时状态中,并可能导致没有定义的行为。

使用FRAME进行存储迁移

使用 OnRuntimeUpgrade trait 实现 FRAME 存储迁移。OnRuntimeUpgrade trait 指定一个单独的函数,on_runtime_upgrade,它允许你指定在运行时升级之后立即运行的逻辑,但要在任何 on_initialize 函数或交易被执行之前。

准备存储迁移

准备存储迁移意味着理解运行时升级所定义的更改。Substrate 存储库使用 E1-runtimemigration 标签来指定此类更改。

写一个迁移

每次存储迁移都是不同的,具有不同的需求和不同的复杂性级别。但是当你需要进行存储迁移时,可以参考以下推荐实践进行操作:

  • 将迁移提取到可复用的函数中,并为它们编写测试。
  • 包括在迁移中的登录以协助调试。
  • 请记住迁移是在升级的运行时上下文中执行的。迁移代码可能需要包含已弃用的类型,如本例所示。
  • 使用存储版本使迁移更具有声明性,从而使迁移更安全,如本例所示。

迁移的排序

默认情况下,FRAME 命令执行 on_runtime_upgrade 函数,根据 pallet 在 construct_runtime! 宏中出现的顺序。对于升级,函数以相反的顺序运行,从最先执行的最后一个 pallet 开始。如果需要你也可以强制执行自定义顺序(请参阅此处的示例)。 FRAME 存储迁移按照这个顺序运行:

  1. 如果使用自定义顺序,则需要自定义 on_runtime_upgrade
  2. 系统 frame_system::on_runtime_upgrade 函数。
  3. 所有的 on_runtime_upgrade 函数都是在运行时中定义的,从 construct_runtime! 宏中的最后一个 pallet 开始。

测试迁移

测试存储迁移非常重要,以下是一些可用来测试存储迁移的工具:

  • Substrate debug kit 包括一个 remote externalities 工具,该工具允许对实时的链数据安全地执行存储迁移单元测试。
  • fork-off-substrate 脚本可以很容易地创建一个 chain specification,引导一个本地测试链来测试运行时升级和存储迁移。