☰
从零跑通Substrate:手写首个Pallet的区块链应用链开发实战
2026/9/28 17:31:10 网站建设 项目流程

如果你正准备做一条自己的链,最磨人的往往不是业务代码怎么写,而是底层那套基础设施——共识、网络层、账本、状态存储,哪一个单独拎出来都是一座山。我在调研了几套方案之后,最终把重心放在了Substrate上。这个由Parity团队维护的区块链开发框架,把链开发的绝大多数底层工作都打包成了现成模块,我只需要专注写自己的业务逻辑。这篇文章就是我最近一段时间从零跑通Substrate、手写第一个自定义pallet、再到踩坑修复的完整记录,适合想用Substrate做应用链或联盟链、但对框架内部还不太熟悉的开发者参考。

1. 我最初对Substrate的误解:它不是一个区块链,而是一个"造链机"

很多人刚接触Substrate时会把它理解成一个现成的区块链项目,跑起来就能用。我第一次看文档时也有这种错觉,因为Substrate确实自带一个可运行的节点模板,装好环境、编译完,一条带余额转账功能的链就起来了。但如果你真正开始改代码,就会发现Substrate的定位完全不是"一条链",而是一套"链的脚手架"。

打个不算太准确的比方:如果你要从零做一辆车,普通方式是买零件、焊接、装配、调校,全部手工来。而Substrate相当于给了你一台3D打印机,外加一堆设计好的标准图纸(也就是模块化的pallet)。你不需要自己去冶炼钢铁、造发动机,只需要告诉它"我要一辆能拉货、带空调、烧柴油的车",然后按它的规则组装和定制就行。

这套设计的核心是三个层次:

  • 节点客户端(Client):负责网络通信、同步区块、运行共识引擎等调度工作,严格来说这部分你很少需要动它。
  • Runtime(运行时):链上状态转换的核心,所有的业务逻辑、存储结构、手续费规则都定义在这里,最终会被编译成WebAssembly(wasm)放在链上。
  • FRAME:Parity为编写Runtime提供的一套模块化框架,pallet就是FRAME里的基本单元,每个pallet对应一个业务领域(账户、余额、治理、存证……)。

这条链路下来,我最大的感受是:Substrate逼着你把"链的逻辑"和"节点的逻辑"分开思考。过去很多人写链,业务规则和节点代码混在一起,想升级一个业务字段都要硬分叉。Substrate从架构层面就把这个问题用wasm化解了,这也是我后来坚定选它的主要原因。

2. 核心架构拆解:Runtime、Client与FRAME到底怎么配合

2.1 Runtime与Client的边界

如果只看目录结构,Substrate项目被分成node和runtime两大部分,这个边界很容易被忽略,但它恰恰是整个框架的灵魂。

Runtime是链的状态转换逻辑,也就是区块执行时真正跑的那段代码。Substrate会把Runtime编译成两种形式:本机代码(为了方便调试和性能测试)和wasm字节码(真正随区块存储在链上)。节点之间的对账,对齐的是这条wasm的逻辑,而不是本机逻辑。这一点很反直觉,很多新手上来直接在runtime里加了个println!想调试,发现日志根本不输出——因为链上执行的是wasm版本,本机Runtime只作用于本地验证。

2.2 FRAME pallet机制

FRAME把Runtime拆成一个一个的pallet,每个pallet互相独立,通过construct_runtime!宏组装进Runtime。这其实就是一种依赖注入的思路:Runtime只是注册表,真正执行逻辑的是各个pallet。

construct_runtime!宏里的顺序看上去只是列表顺序,实际上决定了pallet在Runtime中注册的索引,会影响事件和错误在前端polkadot-js里的解码方式。手动调整过pallet顺序之后,如果忘记同步改前端索引,链上可能一切正常,但事件解码会错位。这个细节初期很坑。

2.3 为什么说"无分叉升级"是杀手锏

传统区块链要升级逻辑,最麻烦的就是分叉协调——要么硬分叉让全节点换软件,要么就忍着不改。Substrate的思路是:链本身存着最新的runtime wasm,管理员(一般是拥有Sudo权限的账户)发起一次set_code调用,把新编译好的runtime wasm作为参数上传替换,节点自动加载新逻辑。这整个过程不需要节点停机,也不需要全网协调软件版本。

但无分叉升级不等于随意升级,存储结构如果变了,需要写迁移代码在on_runtime_upgrade钩子里处理老数据,否则轻则数据读不到,重则链直接起不来。这条后面我会专门讲。

3. 环境准备与第一个节点:从装依赖到跑通本地测试链

3.1 环境依赖:不走一遍不知道的坑

Substrate是Rust项目,环境准备比大多数框架繁琐一点。我按顺序在Ubuntu 22.04上装过一遍,以下是有效的依赖步骤:

# 基础编译工具 sudo apt update sudo apt install -y git curl make clang pkg-config libssl-dev build-essential # protobuf编译器,缺少的话wasm构建会报错 sudo apt install -y protobuf-compiler # Rust工具链 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

装完Rust后,记得把工具链切换到nightly并添加wasm目标,Substrate构建runtime时依赖nightly的特性:

rustup default nightly rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly

3.2 初始化项目模板

用官方模板最省事,打开终端执行:

git clone https://github.com/substrate-developer-hub/substrate-node-template.git cd substrate-node-template cargo build --release

这一步第一次编译的时间会非常久,几十分钟到一两个小时都是正常现象,因为要把全部依赖都编译一遍。如果编译完看到target/release/node-template这个可执行文件,就说明环境完全通了。这一步我建议直接配好Rust的增量编译和本地缓存,不然每次清缓存重编都会让人怀疑人生。

还有一个容易忽略的点:整个模板是一个Cargo工作空间,runtime/Cargo.toml里的substrate-wasm-builder会在编译时调用protoc和wasm工具链。如果你装protoc的顺序晚于工具链,或者环境变量没生效,编译过程中会冒出一堆关于missing protoc的报错,重开一个终端或source ~/.cargo/env通常能解决。

3.3 启动一条开发链

编译成功后,直接用开发者模式跑:

./target/release/node-template --dev

--dev模式会使用默认的开发链配置,内置预置账户,而且每次重启会重置链的状态,非常适合本地开发调试。启动后控制台会打印出正在出块的日志,看到类似"Producing block"的字样就说明链在正常出块。

前端调试我直接用了polkadot-js Apps的公共界面,在设置里把endpoint切换到本地ws://127.0.0.1:9944,就能看到链上状态、事件和账户余额。这个组合对开发期来说完全够用。

4. 手写第一个Pallet:一个存证模块的完整落地

4.1 Pallet基本结构

模板自带的pallets/template里面有一个空的pallet框架。我当时想做的业务是"哈希存证"——用户提交一个内容哈希上链,之后任何人都能在链上验证某个哈希是不是被存过。这个业务用Substrate来做非常顺,因为存储、事件、错误处理都是现成的。

先看一下pallet代码的组织方式。Substrate 4.0之后的写法全部基于属性宏(attribute macro),核心组成如下:

#![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::config] pub trait Config: frame_system::Config { type RuntimeEvent: IsType<<Self as frame_system::Config>::RuntimeEvent>; } #[pallet::pallet] #[derive(frame_support::PartialEqNoBound)] pub struct Pallet<T>(_); // 存储、事件、错误、调用函数都写在这里 }

#[pallet::config]定义的Configtrait是pallet与外界的接口,比如你想让存证模块支持自定义手续费Token,就可以在这里加一个关联类型约束。新手阶段不建议在Config里加太多自定义类型,先用标准的RuntimeEvent就够了。

4.2 设计存储与事件

存证业务需要记录两样东西:存证的主体(谁存证的)和哈希内容本身。我用了两个存储项,一个是StorageMap,存哈希到存证人的映射;另一个用StorageDoubleMap,存"存证人+哈希"的关联,方便后续做"我的存证列表"查询:

#[pallet::storage] #[pallet::getter(fn hash_owner)] pub type Hashes<T: Config> = StorageMap< _, Blake2_128Concat, T::Hash, (T::AccountId, BlockNumberFor<T>), >; #[pallet::storage] pub type OwnerHashes<T: Config> = StorageDoubleMap< _, Blake2_128Concat, T::AccountId, Blake2_128Concat, T::Hash, (), >;

这里有个选型的细节:StorageMap的key我用了Blake2_128Concat这个hash算法,它是Substrate的推荐选择,既能防key碰撞,又保留了key的原始值可以在链下恢复,便于前端直接做查询过滤。如果你不需要遍历存储、只关心精确查询,也可以用Identity,更快但有key泄漏风险。对存证场景来说,Blake2_128Concat是最稳的选择。

事件我定义成"存证成功",方便链下监听:

#[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { HashStored { hash: T::Hash, account: T::AccountId, block: BlockNumberFor<T>, }, }

4.3 可调用函数的实现

核心函数就是store_hash,逻辑非常简单:检查哈希是否已经被存过,如果没有就写入存储并触发事件;如果已存在,就直接返回错误AlreadyExists。这里必须用ensure!宏做前置条件判断,这是Substrate的惯用法:

#[pallet::call] impl<T: Config> Pallet<T> { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn store_hash( origin: OriginFor<T>, hash: T::Hash, ) -> DispatchResult { let account = ensure_signed(origin)?; ensure!(!Hashes::<T>::contains_key(&hash), Error::<T>::AlreadyExists); let block = frame_system::Pallet::<T>::block_number(); Hashes::<T>::insert(&hash, (&account, block)); OwnerHashes::<T>::insert(&account, &hash, ()); Self::deposit_event(Event::HashStored { hash, account, block }); Ok(()) } }

ensure_signed(origin)?会验证调用者身份,提取出账户地址;错误类型需要在#[pallet::error]里提前声明:

#[pallet::error] pub enum Error<T> { AlreadyExists, }

写完这些,还有一个很关键但容易漏的步骤:把pallet注册到runtime里。这要在runtime/src/lib.rs中做三件事——在construct_runtime!中加入EvidencePallet,在impl evidence_pallet::Config for Runtime里指定RuntimeEvent,最后在types(或Runtime的impl中)加上对应的type EvidencePallet(实际上注册在construct_runtime!里即可,不用额外定义类型)。这里面的细节是:如果你在Configtrait里加了新的关联类型而没有在runtime里补上对应的实现,编译会直接报trait not satisfied,这在初期是最常见的编译错误之一。

4.4 编译验证

改完代码后执行:

cargo build --release

如果只想验证runtime部分编译是否通过,可以用cargo check -p node-template-runtime --release,速度会快很多。这里我踩过一个大坑:第一次改完pallet后直接cargo build --release,结果卡在wasm构建上接近二十分钟,最后还因为机器内存不足挂了。后来才学会先check,再开RUST_LOG=runtime::evidences=debug之类带日志的debug模式反复调,最后才走完整release构建。

5. 跑起来之后踩过的坑:从编译崩溃到运行期诡异现象

5.1 wasm相关编译问题:protoc与rust-src

如果你的环境和我一样是全新机器,最容易在首次构建runtime时碰到两类问题:一是protoc没装,报错信息是failed to execute protoc,这是substrate-wasm-builder在生成wasm绑定代码时需要protoc,装好后重开终端即可;二是Rust源码组件缺失,Substrate的某些宏需要抓取标准库源码,报错通常是关于rust-src或rustc-dev的,需要执行:

rustup component add rust-src --toolchain nightly

这两类错误都很容易被搜到但对应不上自己的报错,因为它们往往以编译中间警告的形式出现,真正的致命错误藏在几百行日志的最底部。我的习惯是编译失败后先搜"error:"关键字,而不是看整个输出。

5.2 运行期的"逻辑没生效"问题:缓存与本地Runtime

我遇到过一种很诡异的现场:改了pallet逻辑,重新编译运行,但链上行为还是老样子。起初以为是没改对,后来发现是保留了大量旧区块数据导致的。开发模式下你可以在启动时加--tmp参数,让节点每次用临时目录运行,自动隔绝旧状态。如果已经用固定目录跑过且有旧区块,直接删掉/tmp下对应的chain数据目录,再重新启动就行。

另一个类似的坑是浏览器前端缓存。polkadot-js的Apps会缓存metadata,如果你升级了runtime但没手动刷新metadata,前端显示的依然可能是旧接口。遇到"方法签名对不上"的情况,先试试清缓存和刷新metadata,不要急着怀疑链上逻辑。

5.3 升级Runtime时的存储迁移

我前面提到过,无分叉升级不是换张皮就完事。如果新版pallet改了存储结构(比如Hashes从StorageMap变成了StorageDoubleMap),旧数据不会自动跟着变。你需要在新runtime里写一段迁移逻辑,放在#[pallet::hooks]中的on_runtime_upgrade里,逐个读取旧存储并写入新结构。这段迁移代码还要求写得非常小心,一旦执行到一半panic,升级会回滚,而且很多情况下回滚后还会留下部分副作用,排查起来特别痛苦。

给一个保守建议:早期开发阶段,与其写复杂迁移,不如用--tmp模式配合重置链数据。等逻辑稳定后,再认真设计迁移。链上数据结构一旦上线,改动成本就完全不一样了。

5.4 调试技巧

Substrate提供的try-runtime工具是后期调试升级迁移的神器,它能用快照环境预演runtime升级,提前发现迁移代码和数据不兼容的问题。命令行大致是:

cargo build --release --features try-runtime ./target/release/node-template try-runtime --chain dev on-runtime-upgrade live

我在自己项目里用它验证过两次迁移,都因为预演发现字段对齐问题而避免了上链事故。这工具初期可能用不上,但只要你打算做正式部署,请务必学会。

6. 什么场景适合Substrate,以及我的学习路线建议

6.1 适合与不适合的判断

做了这段时间之后,我对"要不要选Substrate"有了比较清醒的判断。如果你的需求是:

  • 想要一条有自定义业务逻辑的应用链,且希望保留未来升级空间;
  • 团队已经有Rust基础,或者愿意投入时间补Rust;
  • 业务涉及复杂状态处理,比如存证、供应链追踪、积分体系等需要链上存储的场景;

那么Substrate是非常顺手的工具。反过来,如果你只是想快速搭一条支持Token转账的测试链,或者团队完全没有Rust经验,那Substrate的入门成本确实不低,也许先用现成的链模板甚至直接用现有公链的链上合约功能更实际。Substrate能省掉的是底层基础设施的重复造轮子,但它不会替你做业务设计和产品规划——pallet还是要自己写的,Rust还是绕不开的。

6.2 学习路线的核心顺序

以我个人的经验,学习路径大致是:环境搭建 -> 跑通node-template -> 先读runtime/src/lib.rs了解pallet如何组装 -> 照着模板写一个最简单的pallet -> 用polkadot-js观察事件和存储变化 -> 研究常用pallet(Balances、System)的源码 -> 最后再碰存储迁移、共识选型和跨链设计这些进阶内容。

其中最重要的是第二步到第四步的循环:改代码、编译、跑链、看事件。Substrate的抽象层次多,光看文档容易晕,只有亲手把第一个pallet部署上链并看到自己的事件被前端捕捉到,才会突然对"runtime是链上逻辑"这句话有真实的体感。

在我写完这个存证pallet并成功跑通的那天,我重新审视了Substrate这套设计的价值。它真正解决的不是"帮你写链",而是"让链的逻辑可以被当作普通业务代码来写、来测试、来升级"——这个思维转变,比记住任何具体API都重要。后来我在团队内部做分享时,也一直在强调这个视角:别把Substrate当成黑盒节点,把它当成一组约定俗成的"链上应用开发规范",你会少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询