我第一次打开 Substrate 的 node-template 时,第一反应是:这玩意儿到底能干什么?后来我才慢慢搞清楚,它不是一条现成的链,而是一套可以让你把链“拼”出来的开发框架。简单说,Substrate 是用 Rust 编写的区块链开发框架,目标是把 P2P 网络、共识、存储、Runtime 执行这些底层能力都打包好,让你把精力放在业务逻辑上。如果你是想做一条自定义链的开发者,或者需要给企业设计存证、溯源、积分系统的架构师,再或者只是好奇区块链底层到底是怎么转起来的,这篇文章应该能帮你少走很多弯路。
Substrate 最打动我的地方,是它把“节点”和“Runtime”分得特别清楚。这个划分在很多资料里被一句话带过,但其实它就是整个框架的命门。这篇文章我会结合我自己跑通链、写 Pallet、踩坑排错的经验,把这个框架从原理到实操拆开讲一遍。
1. Substrate 到底是什么:一台可以自由组装的“区块链积木机”
1.1 为什么它值得学:底层能力和业务逻辑被彻底分离
很多人第一次接触 Substrate 会误以为它是一个“区块链”,其实不是。Substrate 更像是一个“区块链制造工具”。它预先实现了几乎所有区块链都需要的那套机械结构,比如节点之间的网络通信、交易广播、区块生成、交易执行、状态存储、RPC 接口,这些能力你开箱即用。你真正要写的,是“状态怎么变”的那部分逻辑。
这里有个关键术语:状态转换函数。区块链本质上是一个分布式状态机,每个区块都是一次确定性的状态转换。Substrate 把这套状态机拆成两个大块:外层是节点服务,包括 libp2p 网络、数据库、共识引擎、RPC;内层是 Runtime,它决定每一笔交易进来之后,账户余额怎么变、某个存证哈希是否被记录下来、投票结果如何更新。节点层和 Runtime 层通过 Wasm 接口隔开,Runtime 可以被编译成一段独立的 Wasm 字节码,甚至能通过链上交易直接替换,实现无分叉升级。
你可以类比成一辆汽车。底盘、轮子、车身结构是节点层,出厂前已经装好;方向盘、油门、刹车踏板则是 Runtime 里的各种模块,你可以按需求换装。有些车是家用轿车,有些是赛车,Substrate 允许你改的是驾驶逻辑,而不是重新造轮子。
从工程角度看,这种分离直接改变了开发范式。过去做一条链,要先解决网络层怎么组网、共识怎么保证一致性、区块数据存哪里,然后才轮到你的业务。现在这些变成了配置项和默认依赖,你只需要关心状态转换。这也是为什么很多团队把 Substrate 当成区块链领域的 Spring Boot——它不是一个框架,而是一整个生态体系的起点。
1.2 三条路线对比:从零写、分叉、用 Substrate 框架
我见过不少团队在立项时都会纠结:要不要从零写一条链,还是分叉一条现成的链,还是用 Substrate。我直接把三种路线的差异放在一张表里。
| 对比维度 | 从零编写 | 分叉现有链 | 使用 Substrate |
|---|---|---|---|
| 开发成本 | 极高,网络、共识、存储全要自己搞 | 起步快,但删功能比写功能更难 | 中等,底层已被框架覆盖 |
| 可控性 | 最高,每一行代码都可控 | 受原链设计约束较大 | 高,Runtime 可以完全自定义 |
| 升级方式 | 需要自己设计,通常要做硬分叉 | 依赖原链的升级机制,迁移麻烦 | 无分叉 Runtime 升级,替换 Wasm 即可 |
| 团队要求 | 需要懂 P2P、加密、共识、存储的复合团队 | 需要吃透原链所有逻辑 | 重点掌握 Rust 与 Substrate 抽象 |
| 适合场景 | 学术研究、特殊共识实验 | 只想快速改币种参数的需求 | 定制链、企业链、公链原型 |
从零写的优势是没有任何历史包袱,但代价是你需要维护密码学、网络协议、共识算法这些非常容易出错的组件。分叉现成链看似能快速上线,可一旦你想改掉原有链的某些根深蒂固的假设,反而会被原链的设计反向绑架。我见过一个项目分叉比特币源码想做一个带实体资产的平台,结果光是移除 UTXO 模型就花了两个月。
Substrate 的定位是:底层通用能力已经实现并且经过大量链验证,你在一个相对稳定的骨架上做定制。它不为业务逻辑设限,只约束“区块链基础设施”那一层。这个取舍,对大多数想把业务跑在链上的团队来说更经济。
1.3 谁适合用:典型场景和人群
适合用 Substrate 的场景,我总结下来大概有四类。
第一类是联盟链或企业内部系统,比如供应链溯源、存证、积分、卡券。联盟链不需要完全公开的 PoW 共识,通常用固定验证人列表和 Aura 共识就能满足需求,Substrate 正好可以通过模板快速配置出来。第二类是公链原型验证,比如你想验证一个社区币的经济模型、一个去中心化治理机制、一个 NFT 市场的撮合逻辑,不需要先搭网络,直接写 Runtime。第三类是学术和研究项目,比如测试新的共识算法、新的存储结构,Substrate 允许你替换共识引擎和 Runtime 实现。第四类是已经有中心化系统,想逐步增加“可信记录”能力的团队,可以先跑一条存证链,把关键操作哈希上链,与现有系统并行。
人群方面,有 Rust 基础当然最顺,但即使你只会一点 Python 或 Go,也能按模板先把链跑起来。Substrate 模板项目给你一条可运行的链,你在上面改业务模块,学习曲线比想象中平滑。前提是你要接受 Rust 类型系统和宏编译报错带来的挫败感。不过这种挫败感在第一次链上交易成功时,会瞬间变成很强的成就感。
2. 核心概念拆解:Runtime、FRAME 和 Pallet 是如何协作的
2.1 Runtime:链上逻辑的“操作系统”
在 Substrate 语境里,Runtime 是一切的中心。它编译出的 Wasm 字节码会被嵌入区块,并成为链的一部分。每个验证节点在验证区块时,实际上是在本地的 Wasm 沙箱里执行同一份 Runtime 代码。因为代码是确定性的,所以每个节点最终会得到相同的状态根。
Runtime 要处理的数据主要有几类:账户、余额、存储数据、事件日志、区块头和外部输入。外部输入就是 Extrinsic,包括用户提交的交易和节点产生的内在操作。一条链的业务规则,全都在 Runtime 中定义。比如转账时的手续费怎么算、单笔转账上限是多少、某个角色是否有权限修改参数,这些都在 Runtime 的 Pallet 里实现。
我建议新手先把“节点”和“Runtime”这两个词分开记忆。节点是那台一直在跑的进程,负责接收网络消息、打包区块、存储状态、提供 RPC;Runtime 是这台进程要执行的“内核”,可以被替换。你可以把 Runtime 升级想象成手机系统热更新,不用换手机,只要把系统镜像替换掉,手机就变成了新系统。而这条链的历史状态会保留下来,账本不会断。
2.2 FRAME:把常用功能做成标准件
FRAME(Framework for Runtime Aggregation of Modular Entities)是 Substrate 为开发 Runtime 提供的一套模块化框架。它包含一系列宏和工具,比如construct_runtime!、#[pallet]、frame_system、frame_support,用来把不同的 Pallet 组装成一个整体。
你可以把 FRAME 理解成预备好的标准件仓库。frame_system提供最基础的账户、区块头、交易索引能力;pallet_balances提供余额和转账;pallet_timestamp提供链上时间;pallet_sudo提供超级管理员权限。这些标准件是大多数链都需要的,直接复用即可。
FRAME 的另一大贡献是自动生成大量样板代码。存储结构、事件枚举、调用分发、错误类型,都可以通过宏自动生成。这让 Runtime 代码看起来非常精简,但也带来了一个副作用:宏报错信息经常很反直觉。遇到这种错误时,不要慌,优先看宏展开后的代码,或者回退到官方模板的同版本代码,逐个比对差异。
2.3 Pallet:写业务就是写 Pallet
Pallet 是 FRAME 的基本功能模块,也是开发者真正写业务的地方。一个 Pallet 通常由几个部分组成:配置接口 Config、存储项 Storage、调用入口 Call、事件 Event、错误类型 Error、权重 Weight。你可以为存证写一个 Pallet,为票据写一个 Pallet,为投票写一个 Pallet。链上的业务复杂度,最后都会分解成一个个 Pallet 的组合。
官方生态里已经有不少现成 Pallet,比如pallet_assets管理多种资产、pallet_contracts支持在链上部署 Wasm 智能合约、pallet_identity做链上身份。开发新链时,优先看看有没有现成 Pallet 可以组合,不要什么都自己写。组合出来的系统比自研的更容易维护,因为官方 Pallet 经过大量测试。
但要注意,Pallet 之间是有耦合关系的。比如你要用pallet_balances,就需要先实现frame_system的账户体系;你要用pallet_assets,又需要pallet_balances的支持。这种依赖关系通过 Rust 的 trait 约束表现,如果漏了某个 impl,编译时会给你一个很长的类型约束错误。解决办法是先跑通最小的 Runtime,再逐步加模块。
2.4 共识与网络层:出块、最终性、P2P
Substrate 的共识并不是一个黑盒,而是可以配置和替换的。最常用的组合是 Aura + Grandpa。Aura 负责出块,规则是验证人轮流生产区块,每一轮由一个验证人负责出块;Grandpa 负责最终性,验证人对区块进行投票,当超过阈值投票完成时,区块链支点就不容易被回滚。
也有一些链会使用 Babe 替代 Aura,Babe 是随机插槽出块,通过 VRF 在每个插槽中选出出块人,更适合验证人较多的公链场景。出块和最终性分开设计的好处是:出块可以快,最终性可以慢一点,两者互不阻塞。这和你写关系型数据库时把“事务提交”和“日志持久化”分开考虑类似,能带来更好的扩展性和可调试性。
网络层则完全封装在节点层里,基于 libp2p 实现节点发现、加密传输、消息广播。开发期间你基本不用碰底层网络,只需要知道链的节点之间通过多端地址互相连接,链在开发模式下会自动分配端口和 peer 信息。真正要自己改网络协议的情况极少,除非你在做底层性能优化或自定义传输层。
2.5 升级能力:Runtime 为什么能无分叉升级
区块链升级一直是个大痛点。传统链改一条规则,通常需要社区投票、全节点升级、硬分叉,处理不好就分裂成两条链。Substrate 通过把 Runtime 代码编译成 Wasm 并存在链上,绕开了这个难题。任何人提交新的 Runtime Wasm 代码,经过治理流程审批后,链上状态就切换到新版本。
这里的关键是 Wasm 沙箱。老合约在新合约切换后依然能确定性地执行,状态存储也可以跨版本保留。升级过程可以在运行时完成,不需要停止出块,因此叫无分叉升级。但无分叉不等于“无痛”。如果新 Runtime 改变了存储格式,你必须写迁移逻辑,否则旧数据可能会被错误解析。存储迁移是我见过最多人踩坑的地方,后面我会专门讲。
3. 实操:用 Substrate 模板搭一条带存证功能的自定义链
3.1 环境准备:Rust 和 Wasm 目标
先准备 Rust 环境。如果你还没有安装 Rust,用 rustup 安装,然后切换默认工具链到 nightly,并添加 wasm32-unknown-unknown 目标。
curl https://sh.rustup.rs -sSf | sh rustup default nightly rustup target add wasm32-unknown-unknown注意,Substrate 的 Runtime 编译成 Wasm 时依赖 Rust 的 nightly 特性,所以不能用 stable。不同版本的 Substrate 对 nightly 版本也有要求,模板仓库里通常有一个 rust-toolchain 文件来锁定版本。我建议你跟随模板的锁定版本,不要自己升级到最新 nightly,否则很容易遇到 Rust 特性变化导致的编译失败。
在 Ubuntu 类的系统上,还需要提前装好系统依赖,包括 build-essential、clang、cmake、libssl-dev、pkg-config。这些是编译 RocksDB 和 Wasm 工具链时需要的。如果你在 Mac 上开发,通常不需要额外装太多,但最好先安装 Xcode Command Line Tools。
sudo apt install -y build-essential clang cmake libssl-dev pkg-config这里要提醒一句:第一次编译 Substrate 项目的时间会比较长,全量编译可能要十几分钟甚至更久,取决于机器性能。这不是程序卡住了,而是依赖树非常大。建议编译时开一个单独的终端,用cargo build --release慢慢跑,中间不要 Ctrl+C,让 Cargo 缓存后续复用。
3.2 从模板生成节点
官方提供了一个最常用的上手模板:substrate-node-template。它包含一个可以运行的节点、一个默认的 Runtime 和一个示例 Pallet。克隆下来后,你得到的是一条最小可用链。
git clone --depth 1 https://github.com/substrate-developer-hub/substrate-node-template.git cd substrate-node-template模板结构大概是这样的:
- node:节点客户端代码,包括 CLI、RPC、P2P 配置。
- runtime:Runtime 源码,包括 construct_runtime! 宏里面注册的所有 Pallet。
- pallets:业务模块目录,默认有一个 template 示例。
- scripts:一些帮助脚本。
打开runtime/src/lib.rs,你会在construct_runtime!宏里看到默认注册的 Pallet。这个宏看起来像配置清单,但实际上是代码生成器。它把 Pallet 名称映射到 Rust 模块,并生成大量支撑代码。如果你想新增一个 Pallet,这里必须同步注册,否则链上不可用。
模板默认使用--dev开发模式,本地跑是免权限的。Alice 和 Bob 这些预置账号都有余额,你可以直接拿来测试。模板里还带了一个前端模板,叫 substrate-front-end-template,方便你通过网页连接节点。
3.3 写一个存证 Pallet
我来演示一个最常见的业务:存证。用户可以提交一段数据的哈希,链上记录“谁在什么时间提交了哪个哈希”,之后可以验证。只存哈希不存原文,既节省链上空间,也避免把敏感业务数据直接公开。
下面是一个简化版的 Pallet 代码,基于新版本的 FRAME 宏。不同 Substrate 版本之间宏有细微差异,你以当前模板中的实际写法为准,但核心结构是稳定的。
use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; #[frame_support::pallet] pub mod pallet { use super::*; #[pallet::pallet] #[pallet::without_storage_info] pub struct Pallet<T>(_); #[pallet::config] pub trait Config: frame_system::Config {} #[pallet::storage] #[pallet::getter(fn claims)] pub type Claims<T: Config> = StorageMap<_, Blake2_128Concat, T::AccountId, T::Hash, OptionQuery>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { ClaimCreated(T::AccountId, T::Hash), ClaimRevoked(T::AccountId, T::Hash), } #[pallet::error] pub enum Error<T> { AlreadyClaimed, NoSuchClaim, } #[pallet::call] impl<T: Config> Pallet<T> { #[pallet::weight(10_000)] pub fn create_claim(origin: OriginFor<T>, claim: T::Hash) -> DispatchResult { let who = ensure_signed(origin)?; ensure!(!Claims::<T>::contains_key(&who), Error::<T>::AlreadyClaimed); Claims::<T>::insert(&who, claim); Self::deposit_event(Event::ClaimCreated(who, claim)); Ok(()) } #[pallet::weight(10_000)] pub fn revoke_claim(origin: OriginFor<T>) -> DispatchResult { let who = ensure_signed(origin)?; ensure!(Claims::<T>::contains_key(&who), Error::<T>::NoSuchClaim); let claim = Claims::<T>::take(&who); Self::deposit_event(Event::ClaimRevoked(who, claim)); Ok(()) } } }解释几个关键点。
Claims是一个 StorageMap,key 是账户地址,value 是哈希。Blake2_128Concat是存储 key 的哈希方式,它有很好的均匀分布性,又可以反向读取完整 key。OptionQuery表示查询不到时返回 None,方便前端判断。
create_claim会先确认调用者是链上签名账户,再检查该账户是否已经提交过哈希,防止重复。revoke_claim则是撤销自己的存证。整个逻辑非常简单,但它已经具备一个业务模块的完整骨架:有存储、有校验、有错误、有事件。
在实际项目中,你通常会根据业务需要调整存储结构,比如不只是单账户对应单哈希,而是一个哈希可以对应多个账户,或者记录提交时间。你可以在 StorageValue 或 StorageMap 之外使用 StorageDoubleMap,也可以把块高度存进值里,这些都不难,换来的是更贴合业务的存储结构。
3.4 把 Pallet 装进 Runtime
写完 Pallet 后,还需要把它注册到 Runtime。首先在runtime/Cargo.toml中添加依赖。模板中默认已经引入了 pallet-template,所以如果你是直接改模板 Pallet,这一步可以跳过;但如果是新建 Pallet,需要手动加。
pallet-template = { path = "../pallets/template", default-features = false, features = ["std"] }然后在runtime/src/lib.rs中注册 Pallet。在 construct_runtime! 宏里添加一行:
TemplateModule: pallet_template,同时还需要实现它的 Config trait。由于我们定义的 Config 没有额外的关联类型,通常这样写:
impl pallet_template::Config for Runtime {}这里最容易被忽略的是 Cargo.toml 里的 std feature 传递。Runtime 编译成 Wasm 时是不能有标准库的,所以 Pallet 默认使用 no_std。而链上运行时又需要使用 std 做测试。正确做法是 Pallet 的 Cargo.toml 里要有:
[features] default = ["std"] std = ["frame-support/std", "frame-system/std"]这样模板在构建 Wasm 时关闭 std,在本地测试时开启 std。很多新人第一次编译报一堆“the trait bound was not satisfied”的错误,基本都是这里漏了 feature 传递。
3.5 编译、启动和手工验证
在项目根目录执行:
cargo build --release编译成功后,用开发模式启动节点:
./target/release/node-template --dev --tmp--dev表示开发模式,使用固定的 Alice 等预置账号;--tmp表示临时数据目录,退出后数据即销毁,适合测试。启动后你会看到不少日志,其中最关键的是 JSON-RPC WebSocket 地址,默认是 127.0.0.1:9944。
验证方式有两种。
第一种是直接用命令行,通过一个简单的脚本调用节点。不过没有 web 页面直观。第二种是把节点连到 Polkadot JS Apps 网页端。打开浏览器进入 polkadot.js.org/apps,添加自定义节点,填入 ws://127.0.0.1:9944,连接后就能看到链的信息。然后切到“开发者 -> 交易”,选择我们的 Pallet 调用 createClaim,填一个哈希值。提交后,在“开发者 -> 链状态”里选择 Templates 模块的 claims 存储,就能看到刚刚写入的记录。
我第一次在这个界面看到自己的自定义存储时,还是有点兴奋的。因为从“写代码”到“链上状态”的距离,在这一刻被压缩得极短。前端调用在后端 Pallet 里执行,最终写入链上存储,整个过程就是一个状态转换函数的实例。
这里也要补充一个小提醒。#[pallet::weight(10_000)]只是开发阶段的临时手法,代表这个调用消耗 10000 个 weight 单位,生产链上不能这样写。真实项目的 weight 需要通过 benchmark 生成,否则手续费估算和出块时间预算都会失控。我会在后面的常见问题里再说。
4. 常见问题与排查技巧实录
4.1 编译失败的几类典型原因
Rust 编译 Substrate 项目,报错信息经常又臭又长,但绝大多数情况归纳下来就几个原因。
链接器错误,例如linker command failed with exit code 1。这通常不是代码问题,而是系统缺少 clang、cmake 或 libclang-dev。特别在使用 RocksDB 相关依赖时,系统库缺失会直接中断链接。解决方案是装好系统包后重新编译,不用删 Cargo 缓存。
用了错误的 Rust 工具链。Substrate 的 Runtime Wasm 目标只支持 nightly,如果你用 stable 编译,会看到can only be compiled with the nightly toolchain。用 rustup 切换工具链,或者依赖模板中的 rust-toolchain 文件自动切换。
宏语法版本不对。FRAME 宏更新很频繁,不同版本的模板写法不一样。如果你复制了一段网上老教程的代码,贴到新模板,经常会报找不到类型或 trait。解决方案不是硬调代码,而是打开当前模板里的 pallet 源码,和新代码逐行对照。新版模板的宏结构比旧版清晰很多,但细节差异仍然存在。
编译 Wasm 时内存不足。Substrate 的全量编译非常吃内存,尤其是 linking wasm 时,老电脑很容易出现 OOM。关闭其他软件,或者把 Cargo 的并行编译数调低:cargo build --release -j 2。如果你在容器里编译,确保 Docker 内存限制给到至少 8GB。
4.2 Runtime 升级时的存储迁移问题
无分叉升级很香,但很多人只注意到了功能升级,没注意到存储变化。当新 Runtime 引入一个新的存储项,或者改变了某个字段的含义时,已经存在于旧区块状态里的数据并不会自动“具象”成新格式。如果新代码按新的格式去读旧数据,轻则读到默认值,重则逻辑错乱。
解决方法是使用 pallet 的存储版本机制和迁移函数。每个 Pallet 可以记录一个 StorageVersion,当版本号变化时,在执行升级时运行迁移逻辑。迁移逻辑写在 pallet 的 Hooks 实现里,供 Runtime 在 setCode 时调用。
#[pallet::hooks] impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> { fn on_runtime_upgrade() -> Weight { if StorageVersion::get::<Pallet<T>>() != <CurrentVersion as Get<u16>>::get() { // 执行旧数据迁移 StorageVersion::put::<Pallet<T>>(&<CurrentVersion as Get<u16>>::get()); } T::DbWeight::get().reads_writes(1, 1) } }我只给出骨架,实际迁移逻辑根据业务千差万别。但原则是一样的:先在测试网上跑升级,导出旧状态并检查新状态,再上主网。链上数据不像普通数据库有 undo 日志,一旦迁移函数写错了,可能造成不可逆的损坏。建议上线前准备一个“回滚用 Runtime”,如果迁移出问题,马上用 setCode 切回旧版本。这个过程我现在每次做升级都会准备一遍,虽然看起来笨,但能救命。
4.3 前端读取节点数据时的顺序问题
用 Polkadot JS 连接节点时,很多人会遇到存储读不到预期值的情况。一种常见原因是查询区块高度不对。存储查询默认读取当前最佳区块的状态,而如果你刚提交了一笔交易,但它还处于 pending 状态,存储里当然查不到。这时候应该先等待交易被包含进区块,或者使用订阅接口,监听区块头更新后再查询。
另外一个坑是事件过滤。通过 system.events 可以拿到当前区块的所有事件,但你需要过滤出自己 Pallet 的 Event 枚举。因为所有 Pallet 共享同一个事件 Topic,如果业务里用了多个 Pallet,事件枚举会嵌套多层,过滤时要注意解包。前端代码里我一般用api.events.custom.claimCreated这种类型安全又有可读性的接口。如果直接手写events.filter去匹配裸对象,很容易在字段结构变化时踩空。
还有一个小技巧。启动节点时加上--rpc-external或调整 RPC 监听端口会影响前端连接。默认情况下节点只监听 localhost,如果你在远程服务器上跑节点,前端连不上,不要怀疑 API 代码,先检查节点的 RPC 监听地址和防火墙。
4.4 关于性能和确定性的提醒
Substrate Runtime 是确定性的,这意味着同一个区块在任何节点执行,结果都必须完全一致。因此你不能在 Pallet 里使用系统时间、随机数、环境变量、文件读取这类无法保证一致的操作。链上时间要通过pallet_timestamp的 Now 获取,随机性要通过 VRF 或链上可验证的随机数接口获取。
很多人在这里栽过跟头。比如想写一个游戏 Pallet,直接用了std::time::SystemTime::now()生成随机奖励,本地测试时一切正常,一旦多节点运行,不同节点计算出不同结果,共识直接崩掉。这种问题很难排查,因为它不是编译错误,而是运行一致性错误。所以从一开始,就要把“确定性”当成编码纪律。
性能方面,Pallet 里不要写过于复杂的循环,更不要把大列表一次性加载到内存做遍历。链上存储的每次读写都有成本,复杂的存储结构会让手续费不可控。我通常的做法是先在链下把计算做完,把结果作为交易参数提交上来,链上只做校验和落库。这样既保证安全,又减少链上计算压力。
权重系统也值得重视。weight 是 Substrate 衡量交易消耗的机制,节点会统计一个区块内所有交易的 weight,然后按最大区块 weight 出块。如果权重标注远小于实际执行成本,区块可能装进太多重交易,导致出块超时。生产环境一定要用 benchmark 生成 weight,而不是我上面示例里固定的 10_000。
5. 实操心得与后续扩展方向
5.1 三条过来人的经验
我亲手跑通过好几种自定义链,也折腾过不少模块。如果让我总结三条最值得记住的经验,第一是“先跑通最小闭环,再加复杂度”。不要第一天就想把什么资产系统、合约系统、治理系统全部塞进去。先用模板跑一条只有 System、Balances 和你自己的业务 Pallet 的链,确认账户、转账、事件都能正常工作,再逐步引入其他模块。
第二是“严格跟随官方模板版本”。Substrate 的迭代速度很快,网上很多资料是半年之前的,放到当前版本可能编译不过。你应该以官方仓库的当前模板为锚点,代码和依赖版本都尽量对齐。自己手工升级 Substrate 依赖,尤其是跳过多个大版本时,会陷入无穷无尽的宏报错里,那种痛苦我太熟悉了。
第三是“养成写测试的习惯”。Pallet 可以用 Rust 单元测试直接构造外源、调用函数并检查存储结果。运行cargo test -p pallet-template就能跑。测试不用多复杂,但至少覆盖正常提交、重复提交、错误账户撤销这几个核心路径。因为 Runtime 改动频繁,有测试兜底,升级时才不至于把已有功能改崩。
5.2 还能往哪些方向扩展
跑通一条最小链之后,扩展方向基本是开放的。
如果你想支持智能合约,可以集成 pallet-contracts。它会给你的链提供 Wasm 合约执行环境,用户可以上传合约代码、实例化、调用合约。这会增加 Runtime 复杂度,但如果你希望链具备可编程性,这是必经之路。
如果你想做资产或 NFT,可以研究 pallet-assets。它支持在一条链上创建多种自定义资产,每种资产可以有自己的管理员、冻结列表、元数据。配合你的业务 Pallet,可以实现完整的交易流转。
如果你想做链上治理,可以引入 pallet-democracy 和 pallet-treasury。代议制投票、公投、国库支出都能直接复用,不需要自己从零设计一套治理框架。
如果你想把链接入更完整的开发者工具,可以研究 Substrate API Sidecar 和 subxt。前者提供 REST 风格 API,后者是 Rust 的原生 Substrate 客户端,适合后端服务直接读取链上数据。
在我个人的实际体验里,Substrate 的学习曲线最陡的地方不在概念,而在“宏生成代码”和“版本变化”这两座山上。但只要你把一条最小链跑通,后面其实是不断拼装 Pallet 的过程。真正决定链好不好用的,永远是你对业务状态转换模型的思考深度,而不是工具本身。