ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Substrate区块链开发框架入门:从Runtime、FRAME到自定义pallet实践

Substrate区块链开发框架入门:从Runtime、FRAME到自定义pallet实践 1. 先搞清楚技术圈天天说的Substrate到底是干什么的1.1 一个词的两副面孔你在搜索引擎里输入substrate大概率会看到两类完全不同的结果一类来自生物化学领域说的是酶反应中的底物另一类来自区块链开发圈指的是一套用于构建自定义区块链的开发框架。今天我们聊的是后者。Substrate 是由 Parity Technologies 开源的区块链构建框架Polkadot 的底层实现就是基于这套框架完成的。但要注意一个关键认知Polkadot 只是用 Substrate 搭出来的一个具体项目不代表 Substrate 只能用来做平行链。它更像是一套区块链乐高积木你可以用它从零拼出一条具有独立共识、独立治理、独立业务的链也可以拼出一条接入跨链生态的平行链。这篇文章适合谁适合那种已经了解区块链基本概念区块、交易、节点但还没上手写过链上逻辑的开发者。你不需要精通 Rust但最好有一点基础如果完全没有 Rust 经验读起来会稍微吃力我建议先花一周过一遍 Rust 语法再说。1.2 没有Substrate的时候我们是怎么建链的在 Substrate 出现之前想拥有一条自己的链基本只有两条路。第一条路从零开始。听起来很酷但代价极其惨重。你要自己实现 P2P 网络层、交易池、共识算法、状态存储、RPC 接口、账户模型、签名验证……这些每一个都是独立的研究方向。我见过一个团队花了八个月最后只跑通了一个可以转账的网络还动不动就出块中断。对于绝大多数项目而言从零造链不是工程问题是战略问题。第二条路fork 现成的链。最典型的就是 fork Bitcoin 或 Ethereum 改参数。这条路入门快但越走越窄。你继承的不只是代码还有历史包袱旧的账户模型、固定的虚拟机指令集、难以模块化的代码结构。每次想改核心逻辑都要在别人的架构里打补丁越补越乱。很多人说 fork 是站在巨人肩膀上实际上更像是住在别人家客厅里装修。Substrate 的思路是完全不同的第三路。它把区块链里那些所有链都需要的组件全部预置好比如网络层libp2p、数据库层RocksDB / ParityDB、共识引擎BABE、Aura、Grandpa、交易池、JSON-RPC。你要做的是把精力集中在链的业务逻辑上——也就是决定一条链的状态如何变化的那些规则。换句话说Substrate 把区块链本身做成了一个框架开发者只需要往里填业务。这个差异可以用一个表格直观感受一下维度从零开发Fork 现成链使用 Substrate网络与共识全部手写继承但不灵活预置且可替换业务逻辑自由但成本高受限于原有架构通过 pallet 模块自由组合升级方式硬分叉或停机硬分叉无分叉升级runtime 可热替换团队门槛极高中等中等偏高但边界清晰社区资源少多但分散有官方文档和大量参考链我当时第一次跑通 Substrate 链时最直观的感受是它把建链这件事从研究院课题变成了工程实践。你不用再纠结区块怎么同步、共识怎么协调这些底层能力像自来水管一样拧开就有你可以直接去关心自家链上的业务逻辑。2. 拆骨架Runtime、FRAME和pallet各自承担什么角色2.1 Runtime是链的灵魂外面的网络层都是皮肤很多人第一次看 Substrate 源码会蒙因为它分成好多个 crate不知道重点在哪。我的建议是先抓住一条主线链 外层运行时节点 内层 Runtime。外层节点负责的是物理世界的事情和其他节点建立连接、接收交易、广播区块、存储状态、提供 RPC 接口。这一部分用 Rust 写成编译出来就是一个可执行文件跑起来就是节点。内层 Runtime 是链的逻辑核心它定义了状态转换函数STF, State Transition Function——给定一个初始状态进来一笔交易状态应该怎么变。这个 Runtime 在执行时会编译成原生代码同时还会编译成一个 WASM 文件。这个 WASM 版本极其重要它是链上存储的逻辑真相。当网络升级时节点通过替换这个 WASM 文件就能实现规则更新不需要停链也不需要所有节点非同步硬分叉。这就是 Substrate 引以为傲的无分叉升级的基础。理解到这一层你就知道为什么在 Substrate 里Runtime 就是一切这句话不是在夸大。网络层、共识层写得再稳如果 Runtime 里某个 pallet 出问题链照样会按错误的规则去处理交易。反过来只要 Runtime 的 WASM 在链上存得够好节点代码怎么重构链的逻辑都不会漂移。2.2 FRAME一条链的业务积木盒Runtime 不是让你把所有逻辑堆在一个大文件里。Substrate 提供了一套叫 FRAMEFramework for Runtime Aggregation of Modular Entities的模块化体系它是一组工具和规范让你把业务拆成一堆名为 pallet 的模块再像搭积木一样组合到 Runtime 里。每个 pallet 是一个独立的 Rust crate里面包含了一组相关的链上逻辑。比如 Balances pallet 管转账System pallet 管账户与交易核验Timestamp pallet 管时间戳。你可以写自己的 pallet也可以从社区拿来现成的。一条链的 Runtime 本质上是一堆 pallet 的注册表每个 pallet 各自维护自己的存储项、可调用函数、事件和错误码。FRAME 的价值不只是模块化写代码这么简单它强制你思考一条链上各组件之间的边界。比如你的业务 pallet 需要知道用户余额够不够你不能直接去操作余额存储而是通过调用 Balances pallet 提供的能力比如reserve或transfer来实现。这种接口化的设计避免了多条逻辑互相踩脚也让审计和测试变得可行——每个 pallet 都能单独写测试模拟各种边界条件。2.3 pallet 内部长什么样一个标准 pallet 的骨架包括几个固定部分新手把它们理顺了后面写代码就不慌。第一部分是Configtrait。它定义了这个 pallet 对外部的依赖比如RuntimeEvent、RuntimeCall以及其他 pallet 的接口。这是 pallet 的解耦关键——无论这个 pallet 被装进哪条链只要那条链能满足这些 trait 约束就能工作。第二部分是Pallet结构体。它标记了模块的标记类型FRAME 靠它在 Runtime 里找到这个 pallet 的存储和函数。第三部分是存储项storage。你用#[pallet::storage]宏定义一个变量这个变量会自动映射到链上的状态数据库。比如定义一个StorageMap来保存用户地址 - 捐赠金额的映射那每次读取和写入都会经过 Substrate 的存储层自动计算存储键自动同步到节点数据库。第四部分是调用函数extrinsics。外部提交的交易最终会落到这里执行。每个调用函数都要标注#[pallet::call_index]还要指定weight计算资源消耗估算值。第五部分是事件Event 和RuntimeEvent和错误Error。事件是链上动作的日志方便外部索引和客户端监听错误是调用失败时返回给用户的具体原因而不是笼统一句失败。第六部分是#[pallet::hooks]标注的钩子函数比如on_initialize和on_finalize。它们在每个区块开始和结束时会自动被调用适合做奖励结算、清账这类周期性任务。我看到不少新手第一次打开 pallet 源码时会被一大片宏标签吓到其实没关系你只需要记住宏是帮你省了样板代码不是魔法。每个#[pallet::xxx]都对应 FRAME 在背后帮你生成的一部分实现你只要按照规范写运行时就能正确运作。3. 跑通第一条链从模板到本地网络的完整上手路径3.1 环境准备Rust工具链其实是最容易卡住的一环如果你已经看完理论准备动手我建议直接用官方提供的substrate-node-template模板。它是一条精简但五脏俱全的链包含了 System、Balances、Timestamp 几个基础 pallet 和一套简单的区块逻辑。你不需要从空目录手写 Cargo.toml这能帮你跳过大量初期配置。但在编译之前有个地方是新手踩坑重灾区——Rust 工具链的版本。Substrate 一般使用 Rust 的nightly版本而不是 stable。原因很简单Substrate 依赖了一些只有 nightly 才开启的实验性编译特性比如wasm目标下的某些代码生成选项。你在.cargo/config.toml里通常会看到类似配置rustup toolchain install nightly-2024-01-01 rustup target add wasm32-unknown-unknown --toolchain nightly-2024-01-01 rustup component add rust-src --toolchain nightly-2024-01-01注意这里的日期不是随手写的。Substrate 的版本通常绑定某一个具体的 nightly 日期如果随意用最新的 nightly有可能会遇到某些 crate 的编译错误。最稳妥的做法是看一眼模板仓库里的rust-toolchain.toml里面会明确指定该用什么版本直接rustup toolchain install那个版本就行。还有一个小建议编译前先确认你的机器内存足够。Substrate 这类大型 Rust 项目的编译非常吃内存我见过 8GB 内存的机器在编译substrate-node-template时直接 OOM内存耗尽。如果条件有限可以先把链接器换成lld能明显降低内存占用编译时间也会快一些。不改也跑得起只是时间更长。3.2 编译并启动开发链环境准备好之后编译其实是一个命令的事cargo build --release但这一步可能会消耗很长的时间。我第一次编译这个模板时在普通电脑上花了大约 20 分钟到半小时而且之后每次修改 Runtime 代码后重新编译通常也需要几分钟。这不是 Substrate 特有的毛病大型 Rust 项目都这样习惯了就好。为了减少频繁全量编译带来的时间损耗Substrate 有一个sccache缓存方案可以把依赖的编译结果缓存下来换分支、改配置时能快不少。属于我强烈推荐的进阶工具。编译完成之后目录下会多出一个target/release/node-template可执行文件。启动开发链最简单的方式是./target/release/node-template --dev--dev参数会启动一个单节点开发模式它不需要你配置 validator 集合每个区块产生时也不需要等外部节点确认适合本地调试业务逻辑。想重置链上数据时直接删掉--tmp目录或使用purge-chain --dev命令这个动作很常用尤其是你改了存储结构后旧数据和新逻辑不匹配时必须清一次链。启动之后的日志会滚动显示每个区块的生产情况类似于2024-06-01 10:00:00 Idle (0 peers), best: #42 (0x12ab...), finalized #40看到这种日志说明你的链已经成功出块了。3.3 用Polkadot-JS Apps查看链的运作本地链跑起来后怎么直观看到它上面的账户和区块最常用的工具是 Polkadot-JS Apps一个基于浏览器的前端控制台。在浏览器里打开它的站点点击左上角的切换网络按钮把端点设置为你本地默认的ws://127.0.0.1:9944Substrate 默认的 WebSocket RPC 端口就能连接上你的开发链。你可以在Accounts页面看到开发模式预置的一组账户这些账户带有测试余额方便你练习转账操作。也可以切到Explorer页面观察每个区块里包含的交易还能在Chain State页面直接读取任意 pallet 的存储项。这一步的重点不是让你学前端操作而是帮你建立链上发生了什么的直觉。我个人的经验是每写一个 pallet都要在 Polkadot-JS 里查一遍对应存储项的状态确认读写结果符合预期。如果状态不对再回到代码调试比盲改代码高效得多。4. 让链有自己的业务写第一个自定义pallet的全过程4.1 需求先行我们要做一个什么样的pallet光跑通模板没有意义Substrate 的价值在于让你写自己的链上逻辑。这里我用一个尽量贴近真实业务的例子来演示做一个项目捐赠登记pallet支持三个动作发起捐赠、查询某地址累计捐赠额度、查询所有捐赠记录的总数。为什么选捐赠登记因为它逻辑足够简单但又覆盖了 pallet 开发的核心要素存储地址到金额的映射、状态变更写存储、事件输出捐赠成功、错误处理金额必须大于 0。你把这个例子跑通后面再写更复杂的业务只是往这个骨架里添肉的问题。假设我们的 pallet 名称为donations放在pallets/donations/src/lib.rs。下面我按实际写代码的顺序给你拆解。4.2 从零写pallet的骨架代码第一步声明依赖并在Cargo.toml里给 pallet 起名字。模板的pallets/donations/Cargo.toml中关键配置长这样[package] name pallet-donations version 0.1.0 edition 2021 [dependencies] frame-support { git https://github.com/paritytech/substrate, branch polkadot-v1.0.0, default-features false } frame-system { git https://github.com/paritytech/substrate, branch polkadot-v1.0.0, default-features false } sp-runtime { git https://github.com/paritytech/substrate, branch polkadot-v1.0.0, default-features false } sp-std { git https://github.com/paritytech/substrate, branch polkadot-v1.0.0, default-features false } [features] default [std] std [ frame-support/std, frame-system/std, sp-runtime/std, sp-std/std, ]这个配置看起来冗长但逻辑并不复杂你写的 pallet 需要在链上 WASM 环境no_std和本地测试环境std下都能工作所以每个依赖都要声明default-features false然后在stdfeature 里显式开启。凡是报找不到stdfeature这种错十有八九是这里漏了。第二步写lib.rs的骨架。核心结构如下#![cfg_attr(not(feature std), no_std)] pub use pallet::*; #[frame_support::pallet] pub mod pallet { use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; #[pallet::pallet] pub struct PalletT(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventT IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::storage] #[pallet::getter(fn donations_of)] pub type DonationsT: Config StorageMap _, Blake2_128Concat, T::AccountId, u128, ValueQuery, ; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { Donated { who: T::AccountId, amount: u128 }, } #[pallet::error] pub enum ErrorT { AmountZero, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn donate( origin: OriginForT, amount: u128, ) - DispatchResult { let who ensure_signed(origin)?; ensure!(amount 0, Error::T::AmountZero); let current Donations::T::get(who); Donations::T::insert(who, current.saturating_add(amount)); Self::deposit_event(Event::Donated { who, amount }); Ok(()) } } }逐个解释一下关键宏的作用。#[pallet::storage]宏把Donations声明成一个StorageMap键是T::AccountId值是u128插值编码使用Blake2_128Concat——这是 Substrate 里最常用的 storage 哈希方案它把键哈希后作为存储键的一部分同时保留了键的原始值方便按前缀迭代。ValueQuery表示查不到键时返回默认值u128默认 0省去Option的 unwrap 操作。ensure_signed(origin)是系统库提供的安全检查它确保只有真实用户签名才能调用这个函数否则返回错误。这个函数是所有对外业务入口的第一步几乎动弹不得。donate函数里的saturating_add也很关键它会在溢出时钉在最大值而不是 panic。链上代码不允许随意 panic一旦崩溃可能导致整个区块回滚甚至影响共识安全。所以处理加法、乘法时优先用saturating_*系列方法这是 Substrate 开发者的基本习惯。如果调用失败代码会进入ensure!分支返回Error::T::AmountZero这一点非常实用用户从钱包看到的不是笼统的交易失败而是具体的金额不能为 0。4.3 单元测试与模拟运行环境pallet 写完后你当然可以直接装进链里跑但在那之前强烈建议先写单元测试。没有测试的链上逻辑上线后出了 bug排查成本会高到让你怀疑人生。Substrate 的 pallet 测试通常依赖sp_io和frame_support::test_util提供的一套模拟外部环境。核心思路是用TestExternalities模拟链上状态数据库然后直接调用 pallet 的函数最后检查存储是否被正确更新。一个最基础的测试骨架如下#[cfg(test)] mod tests { use super::*; use frame_support::{assert_ok, assert_noop, traits::OnFinalize}; use sp_runtime::BuildStorage; #[test] fn donate_should_work() { new_test_ext().execute_with(|| { let alice 1u64; assert_ok!(Donations::donate(RuntimeOrigin::signed(alice), 100)); assert_eq!(Donations::donations_of(alice), 100); }); } }这里的new_test_ext()通常定义在mock.rs里它构造了一个最小化的 Runtime 测试环境。注意账户用了u64就是为了测试方便生产环境里通常换成更安全的AccountId32。assert_ok!和assert_noop!这两个宏是测试的好帮手。前者断言函数返回 Ok后者断言返回指定的错误。我写过不少 pallet只要把每个函数的分支都写上 assert后面升级重构时心里就有底了。4.4 把pallet装进runtime并编译自己的 pallet 写好后要和 Runtime 进行装配。打开runtime/src/lib.rs做三步操作。第一步在construct_runtime!宏里注册 palletconstruct_runtime!( pub enum Runtime { System: frame_system, Balances: pallet_balances, Donations: pallet_donations, // ... 其他 pallet } );第二步在impl pallet_donations::Config for Runtime里配置RuntimeEventimpl pallet_donations::Config for Runtime { type RuntimeEvent RuntimeEvent; }第三步把 pallet 所需的依赖 crate 加到 runtime 的Cargo.toml里。这里要注意 runtime 的 Cargo.toml 必须做两处声明一处在[dependencies]另一处在[features]的std列表里漏了后者的stdfeature 会导致编译时找不着标准库实现。装配完成后重新cargo build --release。编译过了启动--dev节点去 Polkadot-JS Apps 的 Extrinsics 页面选择donations.donate填入金额提交一笔交易再回 Chain State 查询donationsOf你会发现存储已经更新事件也出现在区块详情里。到这一步一条带自定义业务的链就跑通了。5. 真实项目里绕不开的三个坑存储、weight与升级5.1 链上存储怎么设计才不后悔我见过很多新手 pallet功能是对的但存储设计一塌糊涂。最典型的问题是把大量数据塞进一个StorageMap的值里越存越大最后每个区块的读写都变慢区块验证超时链开始拥堵。链上存储不是数据库每字节状态都要在所有节点之间复制、同步、验证成本远比普通服务器上的 MySQL 高。设计存储时遵循几条原则能避免大部分麻烦能算出来的数据就不要存。比如总捐赠次数可以在事件日志里统计不必额外存一个计数除非你每秒都要读。使用StorageMap时注意前缀。如果需要按前缀遍历用Blake2_128Concat如果只是直接按键读取用Identity哈希可以省一点 CPU但键长度要短且不能泄露明文信息。对大列表做分页或索引不要把无限增长的Vec塞进一个存储项里。链上永远不要做取出全部数据再排序这种事复杂度不可控。我在捐赠例子里只存了一个地址到金额的映射这是合理的起点。如果业务要求展示最近 100 条捐赠记录就得另建索引 pallet 或使用外部索引服务。5.2 失控的weight不是写个数字就算完Substrate 里每个 extrinsic 都要求标注weight它告诉系统这个调用大概消耗多少计算资源。系统拿这个值做两件事一是限制每个区块的总执行量防止一条链被超高复杂度的交易堵死二是作为交易费计算的基准。新手最容易犯的错误是随便写一个常数比如我上面的#[pallet::weight(10_000)]。如果这个函数只是简单插入一条存储那是没问题的。但如果函数里有循环、有复杂计算这种固定 weight 就可能严重偏离实际。更麻烦的是如果 weight 标得太低会让一个区块塞入大量本该拒绝的重交易最终拖慢全链。Substrate 提供了一套 benchmark 机制来估算 weight原理是在不同输入规模下运行函数测量实际耗时生成一个随输入大小变化的 weight 函数。这在生产链上基本是必做的。我的建议是早期开发阶段可以用固定常量替换掉但凡是准备部署到公开网络就一定要跑一遍frame-benchmarking的模板把真实 weight 算出来。5.3 无分叉升级听起来很美但要用好也不简单Substrate 支持无分叉升级这确实是个巨大的工程红利你可以通过提交一个包含新 WASM Runtime 的特殊交易让整条链在不停机的条件下切换业务规则。但这不是改个数字到处完事。升级的坑集中在数据迁移上。如果你的新 Runtime 里某个存储项的结构变了比如u128改成了u64 u64旧数据和新逻辑就完全对不上。Substrate 提供了一套迁移机制在 pallet 里写on_runtime_upgrade钩子函数在升级执行时遍历旧存储并将数据转换成新格式。这个钩子必须极为谨慎因为一旦执行失败整条链可能陷入不可用状态。我在实际项目里处理数据迁移的方法是先在本地和测试网上完整跑一遍迁移用try-runtime工具在升级交易执行前模拟整个迁移过程确认没有错误再上主网。try-runtime是 Parity 官方提供的链上升级验证工具它可以读取线上状态并用新 Runtime 预演属于升级前必须跑的最后一道卡口。除此之外升级交易本身也值得花心思它是作为一条系统级交易来执行的并不会和普通用户交易抢区块空间。但在升级完成之前链上的旧逻辑仍然在接受交易两者之间可能产生一瞬的不一致。所以正经项目都会设计一个升级窗口提前公告尽量减少损失。6. 用Substrate这一年我的真实感受和给新手的工具箱6.1 三条最重要的认知转变如果你看完前面几节已经开始动手写了那剩下的就是心态和经验层面的东西。我用自己的实际经历给你几条忠告。第一别试图一开始就理解所有底层机制。Substrate 的抽象层次非常多从最底层的sp_*系列到最上层的 FRAME每一项单独拎出来都能写一本书。刚开始只要会用模板、会写 pallet、会跑节点就够了。等你对业务逻辑足够熟了再逐步往下钻理解共识怎么运作、交易池怎么排序你会发现曾经玄乎的概念都变得具体。第二Rust 的错误信息一开始会很硬核但别怂。Substrate 项目里的编译错误往往是最上面一个笼统的 trait 约束报错真实原因藏在十几层之下。我的习惯是看到错误后先找note部分然后搜索错误信息里出现的类型大多数情况下是某个 truck 没实现或某个 feature 没开启。这个排错能力随着写得多会越来越强属于必备技能。第三参考官方文档和源码是最好的老师。Substrate 官方文档写的很系统但有些细节更新不及时看源码反而更准确。遇到不理解的宏或函数直接跳到对应的frame-support源码里搜往往能看到最权威的注释和实现。这不是笨办法而是社区里多数有经验的开发者都在用的办法。6.2 我常用的一些资源如果你准备认真在这条路上走下去以下工具和资料我认为值得放进书签substrate-node-template最干净的起点模板适合所有新手。substrate-front-end-template一个简单的 React 前端帮助你快速把链上和浏览器接通。Polkadot-JS Apps虽然不是官方管控台但它是查看链上状态和提交 extrinsic 最通用的工具。frame-benchmarking-cli生成 weight 的官方工具发布公开网络前必备。try-runtime升级前验证的唯一可靠手段。Rust 官方 cookbook、Rust Book补语言短板的基本资料。6.3 一个小技巧调试期多用assert和panic信息最后分享一个我自己的调试习惯。Substrate 的日志系统很灵活但如果你在 pallet 里手动写println!默认看不到输出——不是它不执行而是被日志级别过滤了。想快速打印调试信息有两个办法一是在 pallet 的 Cargo.toml 里加一句[features] default [std] std [frame-support/std]然后在代码里使用log::info!再在运行节点时加参数-lpallet_donationsdebug指定模块日志级别。这能让你看到 pallet 内部打出的日志而不会淹没在节点噪音里。二是写测试时直接用println!因为在测试环境里输出是直接打到终端上的。我的习惯是复杂逻辑先在测试里把每个中间变量println!出来确认计算过程和预期一致再移除这些输出。这种方式比盯着区块浏览器查状态要快得多因为你能看到代码执行的完整轨迹。这一年用下来的最大体会是Substrate 是一套学习曲线陡峭但上限很高的框架。它的核心概念并不神秘凡是理解了 Runtime、FRAME、pallet 这三层结构的人都能在很短的时间内搭出自己的链。真正的难度在于细节Rust 的编译器约束、存储设计的前瞻性、升级迁移的严谨性。这些坑没有捷径只能一个个踩过去。希望这篇文章能让你少走几步弯路把省下来的时间花在真正有价值的业务逻辑上。
返回列表