如果你第一次在网上搜 "substrate" 这个词,大概率不是被它"中文译名叫基底"搞糊涂,就是被一堆看起来互相矛盾的介绍弄懵。我当初从"想发一条自己的链"这个想法出发,翻了不少资料才搞清楚:Substrate 是一整套用 Rust 写成的区块链开发框架,Polkadot 上的中继链、平行链,以及大量独立网络,底层跑的都是这套框架。这篇文章会先把 Substrate 最容易被误解的架构分层讲清楚,再带你从零跑通一个自定义模块,最后分享几个我在 pallet 开发和测试过程中真实踩过的坑。适合那种"已经能跑通模板,但还想真正理解并动手改链"的开发者。
1. substrate 到底是"一个东西"还是"一堆东西"
很多人刚接触 Substrate 时最大的困惑是:它到底是一个软件、一个 SDK,还是一套协议?我的理解其实很简单,它是一个"把区块链拆成标准零件,然后允许你自由组装"的框架。这也是它和以太坊这类"整链交付"方案最大的区别。
要理解 Substrate,先把它拆成三个不同层次的组件:外部节点、运行时、以及 FRAME 这套 pallet 开发库。三者各自解决不同的问题,但最终组合成一条完整的链。
1.1 客户端:你平时"跑节点"跑的是这一层
客户端是编译后直接跑在你机器上的二进制程序,也就是平时敲./target/release/node-template --dev启动的那个东西。它负责所有"链底层"但不涉及业务逻辑的工作,包括:
- 网络层:基于 libp2p 实现节点发现、区块同步、交易广播;
- 共识层:比如生产区块用的 BABE 或 Aura,最终性用的 GRANDPA;
- 存储层:区块、状态、索引都落在 RocksDB 或 ParityDB 里;
- RPC 层:对外提供 JSON-RPC 接口,前端应用通过它读写链。
这部分代码对于绝大多数业务开发者来说是"黑盒"。你不必搞懂 GRANDPA 的投票协议到底怎么在节点间协调,因为框架已经把它封装成稳定的共识引擎。这就像你开车不需要重新发明变速箱,踩油门就能走。
1.2 运行时:真正被存到链上的业务逻辑
真正有意思的是运行时。区块链的业务逻辑——比如余额怎么转、治理投票怎么算、自定义的业务模块怎么运行——全部在运行时里定义。Substrate 的做法很特别:运行时不是直接编译成机器码,而是编译成 Wasm,然后以"链上状态"的形式存在链上。
这带来的直接后果是:链上的业务逻辑可以随区块一起被网络共识验证,而且可以通过一次特殊的交易替换掉旧的 Wasm。这就是常说的"无分叉升级",后面我会专门讲。
理解这个分层之后,再去读 Substrate 文档,你会有一种豁然开朗的感觉:原来"链上代码"和"链上数据"是两个可以独立更新的东西。数据的存储结构在链上,代码以 Wasm 形式也在链上,两者绑定在一起,但又各自独立演进。
1.3 FRAME:被反复复用的 pallet 工具箱
第三层是 FRAME,它是 Substrate 官方提供的一套 runtime 开发库。FRAME 的核心单元叫 pallet,你可以把它理解成"链上的一个功能模块"。账户余额是一个 pallet,交易费用是一个 pallet,链上治理是一组 pallet,你完全可以在上面添加自己业务逻辑的 pallet。
| 层次 | 主要职责 | 你平时需要关注的程度 |
|---|---|---|
| 外部节点客户端 | 网络、共识、存储、RPC | 基本不用改 |
| Runtime | 业务逻辑的 Wasm 代码 | 核心开发区域 |
| FRAME / pallet | 可复用的模块库 | 复用 + 自定义 |
我早期犯的一个错误,是试图"读懂每一行代码再动手"。实际完全没必要。Substrate 的工程哲学就是"分层屏蔽复杂度":你在 pallet 里写业务逻辑时,不需要关心区块是怎么广播出去的;反过来,你调整共识参数时,也尽量不要碰业务代码。先接受这种分层,再逐步深入,比一开始死抠底层高效得多。
2. 坦白说,我也纠结过要不要自己从零搓一条链
在做技术选型时,我有个很轴的念头:既然框架是别人的,会不会有黑盒问题?万一自定义需求很怪,框架反而束缚手脚怎么办?于是那段时间我真去调研了"从零写一条链"需要做的事,结果很快就清醒了。
2.1 自己写链要面对的问题清单
如果完全从零开始,你至少得解决以下问题:
- P2P 网络:节点之间怎么发现彼此、怎么同步区块、怎么广播交易;
- 状态存储:账户余额、业务数据怎么持久化,怎么保证可验证、可回溯;
- 共识算法:区块由谁生产、怎么确认、怎么防止分叉和双花;
- 交易执行环境:怎么把一段交易代码在节点里安全执行,并且验证执行结果一致;
- RPC 接口:前端钱包、浏览器通过什么方式查询链上数据;
- 运行时升级:业务规则变了之后,现有的链怎么升级,节点是否要硬分叉。
这不是一年半载能做完的工程量。就算不考虑工程质量,光是把这些模块正确组合并保证安全,对专业团队都是很大的挑战。我意识到:如果你最关心的是业务本身,而不是重复发明区块链底层技术,那么站在框架肩膀上走,几乎是唯一务实的选择。
| 开发方式 | 需要自己写的部分 | 工作量和风险 |
|---|---|---|
| 从零写链 | P2P、共识、存储、RPC、业务逻辑、升级机制 | 极高,周期以年计 |
| Substrate + FRAME | 只用 pallet 组合 + 自定义业务逻辑 | 集中在 runtime 层,可控 |
2.2 为什么不直接拿智能合约框架做
也有人说:那我直接用智能合约平台不就行了?确实,如果你要做的逻辑复杂度不高,合约是更快的路径。但 Substrate 和智能合约最大的区别在于:合约是被链上固定规则包裹的"沙盒程序",而 Substrate 的 runtime 可以修改链本身的规则。
举个例子。在智能合约里,转账手续费的结构、共识参数、治理规则等等,都在链层被固定,合约不能越界修改。而在 Substrate 里,你可以写一个 pallet 直接改掉交易费用的计算方式,或者设计一套新的区块生产者激励机制。这种"协议层可编程"能力,是普通合约平台给不了的。
所以我的判断标准很简单:如果你要做的是一条应用链,或者你希望业务规则能深度定制到链层面,选 Substrate;如果业务逻辑可以完全落在现有合约平台的能力范围内,那没有必要自建一条链的成本。
2.3 我的选择:什么项目适合 substrate
具体来说,我在下面几种场景里会优先考虑 Substrate:
- 对交易费用、出块节奏、共识参数有特殊要求;
- 业务数据模型很复杂,合约存储难以优雅表达;
- 希望链上规则可以平滑升级,不想硬分叉;
- 未来要接入跨链生态,比如作为平行链接入中继链。
反过来,如果只是做一个标准的代币、一个 NFT 市场,用成熟的合约平台可能更快。技术选型最忌"为了框架而框架",把需求摆出来,答案往往很明显。
3. 第一次跑通 node-template:环境、编译,以及别在错误版本上浪费时间
Substrate 开发的第一步通常是拉一个 node-template 项目下来。这个模板已经配置好了一条链最基础的零件,包括账户系统、余额模块、sudo 模块和一个示例 pallet。你的工作是在这个骨架上做加法,而不是从中抽出部件。
3.1 环境准备与模板下载
Substrate 目前依赖 Rust 的 nightly 工具链,并需要 Wasm 编译目标。如果你机器上没装过 Rust,先去 rustup 官网装,然后执行:
rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly然后拉模板:
git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template这一步之后千万别急着删库里的旧分支。Substrate 的版本迭代非常快,模板的当前版本如果和网上老教程对不上,你会在编译阶段被一堆莫名其妙的宏错误轰炸。建议把模板 repository 一直保留在本地,作为版本对照的参照物。
3.2 编译和启动
进入项目后,直接编译 release 版本:
cargo build --release第一次编译需要下载并编译几百个依赖 crate,耗时因机器而异,二十分钟到一个小时都正常。这时候别盯着终端干着急,可以去读读 runtime 目录下的代码结构,或者提前把 polkadot-js/apps 前端工具准备好。
编译完成后启动开发链:
./target/release/node-template --dev--dev参数会让节点以单人开发者模式启动,自动清空之前的链上状态,每次启动都是一条新链。如果你加了--tmp,临时数据用完后自动删,调试阶段我强烈建议两者连用:./target/release/node-template --dev --tmp。
3.3 连接前端
节点默认监听ws://127.0.0.1:9944。把 polkadot-js/apps 打开,切换网络到 Custom,填上本地 WebSocket 地址,就能看到你这条链的区块在滚动。前端完全通过链的元数据来识别链上有哪些模块、哪些可调用函数、哪些存储项,所以即使你还没写任何前端代码,只要改动 runtime,前端界面会自动跟着变。
3.4 新手最容易翻车的三个点
第一,cargo build和cargo b --release混用。Substrate 的 runtime 编译依赖WASM_BUILD环境变量,release 和非 release 的构建产物不一致容易导致你改了代码却没有生效。固定用一个命令,按项目文档来。
第二,编译报错先看版本。假如你从网上找了个自定义 pallet 代码,粘贴之后疯狂报错,八成不是逻辑问题,而是它基于老版本 Substrate 写的。特别注意decl_module!这种老宏,新版本里已经被 attribute 宏方案替代,见到直接放弃,别试着兼容。
第三,链刚启动完马上发交易测试,偶尔会遇到交易不打包的情况。先等几个区块稳定,再连上前端操作。
4. 手写一个 pallet:存储、调用与事件的三件套
模板跑通之后,真正的工程才刚刚开始。接下来我会带你在 runtime 里加一个自己的 pallet,实现一个简单的"链上凭证记录"模块:用户可以把一段数据的哈希登记到链上,作为存在性证明,之后还能把这个凭证转给其他人。
4.1 用 pallet 组织业务逻辑
pallet 是 Substrate runtime 的基本模块单元。以太坊合约里你写一个contract,在 Substrate 里你写一个 pallet。pallet 可以访问系统级功能,比如读取调用者身份、访问链上时间、操作存储,并且天然支持事件和错误处理。
模板自带一个pallet-template,最简单的做法是复制它的目录,改掉 crate 名字,然后在 runtime 里挂载。但我建议你也看看它的结构,因为一个标准 pallet 通常由四大部分组成:
Configtrait:声明这个 pallet 依赖哪些外部类型;Storage:声明链上持久化数据;Call:可被交易调用的业务函数,也叫 dispatchable call;Event和Error:描述链上发生的事件和可预期的失败原因。
4.2 一个链上凭证记录模块
这个模块的存储结构不复杂,就是一个"账号 -> 凭证记录"的映射。凭证记录包含所有者、内容哈希、登记区块号。
#[derive(Clone, Encode, Decode, PartialEq, RuntimeDebug, scale_info::TypeInfo)] pub struct Record<AccountId, BlockNumber> { pub owner: AccountId, pub hash: Vec<u8>, pub created_at: BlockNumber, }存储部分用StorageMap:
#[pallet::storage] #[pallet::getter(fn records)] pub type Records<T: Config> = StorageMap< _, Blake2_128Concat, T::AccountId, Record<T::AccountId, T::BlockNumber>, OptionQuery, >;先别急着跳进函数,注意T::AccountId和T::BlockNumber这种写法。pallet 本身不关心账号具体是哪种类型,它通过Config: frame_system::Config拿到 runtime 给它的类型。这套"类型参数化"机制,让你的 pallet 可以在不同 runtime 里复用。
登记的调用长这样:
#[pallet::call] impl<T: Config> Pallet<T> { #[pallet::weight(10_000)] pub fn create_record( origin: OriginFor<T>, hash: Vec<u8>, ) -> DispatchResult { let who = ensure_signed(origin)?; ensure!( !Records::<T>::contains_key(&who), Error::<T>::AlreadyExists ); let record = Record { owner: who.clone(), hash, created_at: frame_system::Pallet::<T>::block_number(), }; Records::<T>::insert(&who, record); Self::deposit_event(Event::<T>::RecordCreated(who)); Ok(()) } #[pallet::weight(10_000)] pub fn transfer_record( origin: OriginFor<T>, to: T::AccountId, ) -> DispatchResult { let who = ensure_signed(origin)?; let mut record = Records::<T>::get(&who).ok_or(Error::<T>::NotFound)?; ensure!(record.owner == who, Error::<T>::NotOwner); Records::<T>::remove(&who); record.owner = to.clone(); Records::<T>::insert(&to, record); Self::deposit_event(Event::<T>::RecordTransferred(who, to)); Ok(()) } }这里出现了一个很关键的宏:#[pallet::weight]。Substrate 要求每个可调用函数必须估算自己的计算成本,它决定了这条交易在整个区块里占多大的"资源额度"。开发阶段图省事可以写固定值,但上线前建议用官方的 benchmark 工具做真实测速,不然区块生产者很容易被设计超重或过轻的交易影响。
4.3 深入理解 weight 与 origin
weight和origin是 Substrate pallet 开发里绕不开的两个概念。
origin是调用来源的认证信息。ensure_signed表示"调用者必须是一个真实签名的账号",如果调用来自 root(链上治理)或者未认证来源,这里就会返回错误。这套访问控制是每条 call 的第一道门槛。
weight除了关系到区块资源,还牵扯到交易费计算:权重越高,费用通常越高。一个设计良好的 pallet,每个 call 都应该给出合理权重。别以为权重只是数字,它直接影响链的吞吐能力。
5. 测试 pallet 不能靠 println:mock runtime 与几个绕不开的坑
pallet 写完之后,你面临一个现实问题:它跑在哪?pallet 不能像普通 Rust 二进制一样有 main 函数直接运行,它必须嵌入 runtime 才能工作。如果每次都启动节点来测试,效率太低。Substrate 的标准做法是构建一个 mock runtime。
5.1 为什么必须 mock 一个 runtime
mock runtime 是一个"最小可运行"的 runtime 环境,它只包含测试需要的 pallet,比如frame_system加上你的自定义 pallet。它跑在普通 Rust 测试进程里,不需要网络、不需要共识、不需要真实交易池,所以测试可以飞快执行,还能精确断言链上存储和事件。
5.2 mock 代码骨架
在 pallet 的src/mock.rs里,你通常需要做两件事:实现 runtime 配置、构建TestExternalities。
construct_runtime!( pub enum Test { System: frame_system, TemplateModule: pallet_template, } ); impl frame_system::Config for Test { // 模板里有完整版本,这里省略大部分 type 绑定 type AccountId = u64; type BaseCallFilter = frame_support::traits::Everything; // ... } pub fn new_test_ext() -> sp_io::TestExternalities { let storage = frame_system::GenesisConfig::default() .build_storage::<Test>() .unwrap(); storage.into() }这段代码里最不起眼但最容易出错的是依赖版本一致性。sp-io、sp-core、sp-runtime这些 crate 的版本必须和 runtime 使用的完全一致,差一个 minor 版本都会编译出一堆类型不匹配的报错。
5.3 典型测试用例
写完 mock 之后,测试就变得很直观了。以之前的凭证模块为例:
#[test] fn test_create_record() { new_test_ext().execute_with(|| { assert_ok!(TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![1u8, 2u8, 3u8], )); assert!(TemplateModule::records(1u64).is_some()); }); } #[test] fn test_create_record_duplicate_fails() { new_test_ext().execute_with(|| { assert_ok!(TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![1u8, 2u8, 3u8], )); assert_noop!( TemplateModule::create_record( RuntimeOrigin::signed(1u64), vec![4u8, 5u8, 6u8], ), Error::<Test>::AlreadyExists ); }); }execute_with里的闭包相当于替你搭好了"链上状态沙盒",每次测试进来都是干净状态,修改存储不会污染其他用例。assert_ok!和assert_noop!是框架提供的测试宏,前者断言调用成功,后者断言失败并检查具体错误值。
5.4 我踩过的几个坑
第一个坑是"事件没实现,错误提示却指向宏"。如果你在 pallet 里用了事件,但没有正确声明RuntimeEvent类型,编译错误经常出现在construct_runtime!附近,一时很难联想到是 pallet 内的事件定义不完整。解决方法是先检查#[pallet::event]块是否存在,再检查 runtime 的construct_runtime!里是否已经加入你的 pallet。
第二个坑是"存储键顺序不能乱调"。编译器不会帮你检查construct_runtime!里 pallet 的顺序,但存储编码会受顺序影响。你在开发中发现存储数据错乱、读不到旧数据,大概率是调整了 pallet 顺序而没有执行存储迁移。开发阶段还好,正式链上这属于高危操作。
第三个坑是"测试全绿但主链报错"。mock runtime 和真实 runtime 的配置存在差异,比如 mock 里AccountId = u64,真实 runtime 却是AccountId32。测试能通过的逻辑,主链上因哈希前缀等细节不同仍然可能出错。所以 mock 测试只是第一道防线,合入前一定要在--dev节点上手动走一遍关键流程。
6. forkless 升级:把链上代码和链上数据解耦
Substrate 最有魅力、也最反直觉的特性,是链上升级。传统区块链改成逻辑,基本等于硬分叉:节点必须全员换新二进制,共识网络在某个高度分道扬镳。Substrate 提供了一条完全不同的路。
6.1 硬分叉与无分叉升级
普通链的规则被写进节点客户端的二进制里,所以改规则必须改节点、动共识。而 Substrate 把 runtime 编译成 Wasm 并存在链上,节点执行的其实是链上这份 Wasm。修改代码时,你只需要编译一份新的 Wasm,再通过一个特殊调用把它写到链上,之后全链节点会自动执行新逻辑,不需要替换二进制。
我把这个过程理解为"换发动机不换车架":数据、账号、历史区块都在原地,只有链上代码被原子地换掉了。
6.2 实操一次升级
升级流程不复杂。修改局部代码后,先编译:
cargo build -p node-template-runtime --release构建产物会生成新的 Wasm 文件。然后在 polkadot-js 前端用 sudo 模块提交setCode调用,把新的 Wasm 上传上去。几秒后,链上代码就换成了新版本。
如果只是新增了一个 call,不会影响现有数据。但如果你的存储结构变了,例如从StorageMap改成StorageDoubleMap,那么旧键值不能被自动读取,你需要写一个存储迁移逻辑,在升级后把旧数据搬移到新键下。这个迁移逻辑往往比业务代码本身更考验人,因为它要兼容"链上可能跑了很久、积累了大量数据"的现实情况。
6.3 升级的风险与备份习惯
开发期随便升级没关系,但正式链上,任何一次升级都必须先备份。我现在的习惯是:先在--dev节点完整走一遍升级流程,再对着测试网做一次带真实数据的演练,最后才考虑生产链。升级过程中如果出现问题,优先用快照回滚,而不是慌张地继续发交易。
另外,升级后立刻检查前端元数据是否刷新。因为前端依赖元数据分析链上接口,如果缓存没有清除,你会看到一堆"接口不存在"的报错,实际链上却一切正常。这个小问题在开发期往往比复杂的共识问题更浪费时间。
最后再分享一个我很个人的体会:Substrate 的学习曲线之所以陡,不是因为它比别的框架复杂太多,而是因为它给了你太多可以用"自己的方式"去做的事情。开发链和写普通后端的思维方式不太一样——你不仅关心功能对不对,还要关心链上状态怎么演进、升级怎么平滑、数据怎么迁移。如果一开始被宏和类型绕晕了,别着急,那都是必经之路。多用 mock 测试兜底、多跑 dev 节点、把版本这个锚点牢牢锁住,后面你会慢慢发现,这套框架真正值钱的地方,不是那些花哨的功能,而是它把"一条链要跨越长期运行这道坎"这件事,实实在在地拉近了。