尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Substrate区块链开发框架:从pallet到Runtime的定制链实践

发布时间:2026/9/28 16:31:15

资讯中心
01
ARTICLE

Substrate区块链开发框架:从pallet到Runtime的定制链实践

Substrate区块链开发框架:从pallet到Runtime的定制链实践
1. substrate这个单词为什么值得单独写一篇在刚接触这个词的时候我一度以为它只是个不起眼的专业术语。查了一圈才发现substrate在不同的领域里都承担着“底层承载物”的角色从生化反应中的酶底物到材料学里的基板再到区块链圈子里的Substrate开发框架同一个词三套完全不同的语境。更巧的是这三层含义之间有一条暗线所有能被叫做substrate的东西都不是最终产品而是托住最终产品的那个“底座”。如果你在技术社区看到“substrate”被单独提起现在绝大部分情况下指的是用Rust语言编写的区块链开发框架。它解决的问题很直接想让一条区块链从创意变成可运行的主网不需要从零写P2P网络、状态库、共识算法和交易池而是趁一个月反馈机制齐全的底层框架把链当成一组可插拔的模块来组装。这类需求其实由来已久但Substrate做得最彻底它不只是把节点外壳打包好还把链上状态的管理、区块生产、最终性、Runtime升级这些环节全部抽象出来交给开发者自己定义。这篇文章不是官方文档的复述而是我以一个实际使用者的身份从理解它、搭建定制链、手写pallet、到踩坑排错的全过程记录。如果你正准备评估Substrate是否适合你的项目或者已经clone了模板但不知道从哪下手这篇文章会把那些文档里写得含糊、实际却非常关键的细节补全。我尽量用大白话解释设计原理同时给出可以直接复制的命令和代码片段。看完之后你至少会知道一条用Substrate构建的链节点进程起来之后究竟发生了什么pallet和runtime是什么关系为什么说链升级可以不分叉以及哪些坑是我最早掉进去、后来花费大量时间才爬出来的。2. 三层结构拆解我理解Substrate时的第一个突破口2.1 核心架构Client、Runtime、节点外围理解Substrate最快的方式是把一条链想象成一家餐厅。餐厅的装修、桌椅、门面属于外围你可以随便改不影响菜品本身厨房的设备属于基础设施负责把食材变成菜品而菜单是食客真正关心的东西每天更新菜单不影响厨房设备运转。Substrate里对应的概念分别是节点外围、Client和Runtime。Substrate把区块链的运行环境拆成三层外层是节点程序负责P2P网络通信、交易导入、RPC服务、数据库存储。这些部分基本是通用实现绝大多数链可以共用同一套代码。中间是Client也就是区块执行引擎。它负责把交易送进Runtime去执行把执行结果写进状态库并根据共识算法把新区块广播出去。Client不知道你的链具体有哪些业务逻辑它只知道怎么调用Runtime。内层是Runtime这是开发者真正要写的地方。Runtime里保存着链的“状态转换函数”也就是一条交易进来如何修改链上状态。账户余额怎么变动、某笔交易是否合法、某个业务字段能不能被写入全都由Runtime决定。这套分层最直接的好处是业务逻辑与网络细节彻底解耦。对大多数开发者来说你不需要关心Kademlia寻址表怎么维护也不需要理解周期性的区块同步怎么抓取只需要把注意力放在Runtime里回答一个问题什么操作允许做做了之后状态怎么变。有意思的是Substrate里的Runtime会编译成两种形态。第一种是在开发阶段作为本机原生代码native运行方便编译调试第二种是编译成Wasm字节码存储到链上。每当节点收到区块对区块内交易的实际验证默认使用Wasm版本的Runtime这就带来一个很关键的效应由于新逻辑连同新代码一起写进了链上通过一次链上投票区块链可以在没有分叉的前提下升级Runtime。这在以太坊那条路上是难以想象的但在Substrate里却是常态。2.2 FRAME与pallet为什么把业务拆成模块会更好写如果你顺着Substrate的官方仓库往下看会发现它上面站着一套叫做FRAME的东西。FRAME的定位是“一组用来组装Runtime的标准零件”而它里面最基本的单元就是pallet。pallet这个词源自palette调色板暗示着你可以像选色一样挑选需要的模块。一个pallet往往只负责一件明确的事有的是Balances管余额账本有的是Scheduler管任务的定时调度有的是Identity管链上身份。你自己要写的业务逻辑几乎都是写成一个新的pallet然后像插卡一样把它装进Runtime。我在第一次尝试使用Substrate时最大的误区是把它当成“区块链操作系统”试图先理解所有内置模块再动手。后来真正写起来才明白Substrate文档里把pallet之间的依赖关系描述得很清楚一般只需要保证system和balances这两个基础模块存在其他任何pallet都可以按需添加。即使你的业务非常复杂也建议拆成多个职责独立的pallet而不是在一个pallet里堆几千行代码。pallet之间通过Config trait相互依赖。说得直白一点每个pallet都会有类似这样的声明#[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; type Currency: CurrencySelf::AccountId; }上面这段代码的意思是这个pallet依赖system模块并且要使用一个能被转换成RuntimeEvent的事件类型还需要一个实现了Currency接口的模块来操作账户余额。这种依赖关系在编译期就会被检查如果Runtime里装的pallet不满足条件编译器会直接报错。正因为如此有人会觉得Substrate的学习曲线陡峭但从另一个角度看这种做法让错误在编译期就大量暴露真正到链上跑起来时的意外反而少了很多。2.3 Runtime与Wasm一次“热更新”是如何发生的很多刚接触的人会下意识把“升级”理解为重新部署一个节点。在传统服务器架构里这确实是常规操作改代码停服务重新启动数据迁移。但区块链网络没有“全部停服”这个说法节点们由不同的运营方运行你不可能保证所有节点同时更新代码。Substrate的解决方案是把Runtime当作一份“罐头菜单”存到链上。当a new runtime upload到链上时后续区块里的交易将开始按新代码执行。这时候运行旧代码的节点并没有退出网络它们会在下一个区块看到新的Runtime字节码自动用新字节码执行区块验证不再执行本地旧代码。这个过程就是所谓的“不分叉的运行时升级”。这里有个容易忘的细节虽然Runtime代码是链上的一部分不代表升级不需要治理。一部区块链总得有人决定“什么时候升级、升级到什么版本”Substrate常见做法是通过民主模块Democracy发起公投或者通过技术委员会模块快速施行紧急修复。哪些模块有权限发起升级完全取决于runtime的配置。我的建议是在早期测试阶段别过度设计治理流程精简到一个sudo模块加一个管理员账号就够了否则每次升级都要搞一次投票流程开发效率会被拖得很惨。3. 第一次跑通定制链从环境准备到节点控制台3.1 环境准备与依赖安装Substrate的编译坑很多但大部分集中在环境配置。官方推荐使用rustup管理Rust工具链别自己手工装Rust否则版本错乱会让人怀疑人生。先装Rust工具链curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh source $HOME/.cargo/env然后添加Substrate需要的目标平台。Substrate大量使用Wasm要确保Wasm目标被安装rustup toolchain install stable rustup target add wasm32-unknown-unknown --toolchain stable接下来安装编译依赖。Ubuntu/Debian系统上通常需要这些包sudo apt install build-essential clang curl git make libssl-dev protobuf-compiler其中protobuf-compiler最容易被漏掉。没有它Substrate相关依赖的编译会在很后面报一个莫名其妙的错提示找不到protoc第一次遇到时完全不知道应该往哪个方向排查。安装完成后建议直接把Node模板仓库clone下来而不是自己从零创建工程。Node模板是一个最小可运行的Substrate链默认带system、balances、sudo等基础pallet目录结构清晰适合作为起点。git clone -b latest https://github.com/substrate-developer-hub/substrate-node-template.git cd substrate-node-template cargo build --release第一次编译会很久正常情况下10到30分钟取决于机器性能和网络状况。期间会下载大量crate如果网络不稳定建议用crates.io镜像方案。很多新人看到编译界面长时间不动就以为卡死了其实是在下载依赖库。别中途强制终止否则缓存不完整下次继续容易出更奇怪的问题。3.2 启动开发链--dev参数背后发生了什么编译完成后用开发模式启动节点./target/release/node-template --dev这个--dev参数非常友善。它表示节点以单节点模式启动不连接任何外部网络不使用固定密钥每次启动都会生成一个临时的dev账号区块自动出块不需要等共识节点产生签名。对于本地测试和pallet开发这是最高效的运行方式。节点启动后日志会持续滚动。其中几行值得关注 Initializing Genesis block表示正在进行初始化创世块。 Local node identity is...给出节点的网络身份。 Highest known block之后会持续增长表示正在出块。这时你可以开启第二个终端进入控制台操作链。使用模板自带的交互工具cargo run -- --dev再另开一个终端执行./target/release/node-template --dev --tmp--tmp参数会让节点使用一个临时数据库目录一旦节点退出数据会被删除。这样即使你反复调参数、反复启动也不会留下脏数据影响下一轮测试。开发阶段我几乎每次都带--tmp这习惯救了我很多次。3.3 与链交互从Polkadot JS看状态变化单纯把节点跑起来不算真正入门因为你还感受不到“链上状态”。这时候用浏览器端的Polkadot JS Apps来连接本地节点比较直观。打开应用切到Development页面输入本地节点的WebSocket地址例如ws://127.0.0.1:9944连接上之后你应该能看到区块持续增长账户余额可以转账。首次连接可能遇到的问题是端口不对。Substrate节点默认RPC监听9944端口如果你的节点改过端口或者运行了多个实例需要带上--rpc-port参数指定。另一个常见问题是跨域限制如果前端页面要求连接非本地节点需要用--rpc-cors参数开启允许的来源./target/release/node-template --dev --rpc-corsall开发模式下用all问题不大但正式环境千万别这么做否则任何网页都能向你的节点发起RPC请求会被当成免费公开节点滥用。这里我只是给个提醒具体CORS策略应当和你的产品形态匹配。4. 手写一个存证pallet从需求到可复现代码4.1 需求设定存证流程与链上数据结构我建议第一个手写pallet选一个足够简单但完整的场景。内容存证是我比较推荐的选择因为它的逻辑链条短但足够覆盖storage、event、error、dispatchable call这四大核心概念。需求是这样一个账号可以把一段文本的哈希值登记上链声明自己对某个内容拥有存在性证明。其他人可以查询某个哈希是否被登记以及是什么时候由谁登记的。这个功能在版权保护、电子合同、供应链溯源里都能找到落地场景。需要一个存储映射键是哈希值值是存证记录记录里包含存证者账号和区块号。调用接口只需要一个参数即内容的哈希。防止重复登记需要检查哈希是否已经被占用。4.2 pallet目录与Cargo依赖在node模板里pallets目录下新建一个名为poe的子目录Proof of Existence的缩写。目录结构如下pallets/ └── poe/ ├── Cargo.toml └── src/ ├── lib.rs └── mock.rsCargo.toml里写下依赖。注意版本对齐模板自带的frame-support和frame-system版本不需要额外指定分支直接继承workspace即可。[dependencies] frame-support { workspace true } frame-system { workspace true } sp-runtime { workspace true }在模板根目录的Cargo.toml里找到workspace依赖定义里面已经把frame-system等常见依赖列好你直接引用workspace true是最安全的。手动指定版本而没对齐是编译期报错的高发原因。4.3 实现代码完整lib.rs骨架pallet的核心代码写在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: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::storage] #[pallet::getter(fn proofs)] pub type ProofsT: Config StorageMap _, Blake2_128Concat, T::Hash, (T::AccountId, T::BlockNumber) ; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { ProofClaimed { claimer: T::AccountId, hash: T::Hash }, } #[pallet::error] pub enum ErrorT { ProofAlreadyExists, ProofNotExist, NotOwner, } #[pallet::call] implT: Config PalletT { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn claim( origin: OriginForT, hash: T::Hash, ) - DispatchResult { let claimer ensure_signed(origin)?; ensure!(!Proofs::T::contains_key(hash), Error::T::ProofAlreadyExists); let block_number frame_system::pallet::Pallet::T::block_number(); Proofs::T::insert(hash, (claimer.clone(), block_number)); Self::deposit_event(Event::ProofClaimed { claimer, hash }); Ok(()) } #[pallet::call_index(1)] #[pallet::weight(10_000)] pub fn revoke( origin: OriginForT, hash: T::Hash, ) - DispatchResult { let claimer ensure_signed(origin)?; ensure!(Proofs::T::contains_key(hash), Error::T::ProofNotExist); let (original_claimer, _) Proofs::T::get(hash).ok_or(Error::T::ProofNotExist)?; ensure!(claimer original_claimer, Error::T::NotOwner); Proofs::T::remove(hash); Ok(()) } } }这个pallet有两个对外操作claim用于登记哈希revoke用于撤回自己的登记。revoke里最关键的是校验“只有存证者本人能撤销”对应Error::NotOwner。如果不做这层校验任何人拿到哈希都能删掉别人的存证那整个存证逻辑就失去意义了。4.4 把pallet装进runtime写完了pallet接着要把它注册到runtime里这一步很多人卡住。打开runtime/src/lib.rs按照顺序做三件事。先增加依赖配置[dependencies] pallet-poe { path ../pallets/poe, default-features false }再在construct_runtime宏里把模块加进去。通常放在Sudo模块之前或之后位置不影响功能但会影响调用的pallet前缀construct_runtime!( pub enum Runtime { System: frame_system, Balances: pallet_balances, Poe: pallet_poe, Sudo: pallet_sudo, } );最后在impl pallet_poe::Config for Runtime中配置关联类型。这里只需要配置RuntimeEventimpl pallet_poe::Config for Runtime { type RuntimeEvent RuntimeEvent; }如果忘了这一步编译会报一个类似“the trait bound not satisfied”的错误。这其实是Substrate的常规要求每个pallet都要在runtime层实现一次它的Config把抽象的关联类型绑定到当前Runtime的具体类型上。理解这一点后报错就不再可怕。4.5 编译与链上验证重新编译cargo build --release启动节点后打开Polkadot JS Apps切到“Extrinsics”页面选择Poe模块调用claim方法填入一个哈希。可以用任意在线SHA256工具计算一段文本的哈希也可以直接填入一个32字节的数组。提交后再切换到“Chain state”页面选择poe.proofs填入相同的哈希应该能看到存证记录里面有账号地址和出块时的区块号。这个完整闭环跑通意味着你对Substrate最重要的几个概念都建立了直观认识extrinsic是用户发起的链上操作storage是链上的状态存储event是操作结果的日志化通知而pallet则是这些元素的容器。接下来遇到的问题基本都是在这些概念上叠加更复杂的业务体系。5. 编译、测试、升级我在实操里反复踩的坑5.1 版本对齐为什么“换个机器编译失败”很常见Substrate项目的迭代节奏非常快pallet代码里用到的API经常有细微变化。比如某些trait方法换个名称某些宏属性被弃用再加上模板仓库自带的版本与最新crates.io版本不一定完全一致导致一个非常恼人的问题同一个代码在别人的机器上能编译在你这边就报错上周能编译这周更新了依赖就挂了。解决思路是把版本锁死。模板仓库本身通常会带一个Cargo.lock首次编译后会把所有依赖版本固定下来。建议不要把整个仓库当作依赖库而被其他项目引用在自己的应用项目里直接使用模板仓库的版本。如果你需要升级Substrate版本不要手动改几个依赖号而是等官方发布新模板后再基于新模板迁移自己的代码。另外编译报错时优先看第一行不要被后面几十行重复的crate错误吓到。Rust编译器通常会把真正的错误信息打印在最前面后面的螺旋错误只是同一个错误的连锁反应。5.2 存储版本与迁移升级runtime时状态怎么办在开发阶段你可能会频繁调整storage的数据结构比如把一个StorageValue从一个具体的Vec 改成自定义结构体。如果只是改了类型但没有为新类型提供从旧存储解码的方案下次启动节点时会报解码错误。Substrate里有一个专门的迁移机制。典型的做法是在pallet里增加一个StorageVersion常量每次数据结构变化时递增版本号。然后在on_runtime_upgrade钩子里编写旧版本到新版本的迁移逻辑。这个机制听起来很工程化但实际开发常常被忽略因为在测试链上数据不多删掉数据库重来一遍似乎成本更低。这个做法在开发期没问题但千万别因此形成习惯。一旦上了生产环境所有在线用户的账户和状态都存储在链上你不可能让所有人都重新注册一遍。我的建议是在开发早期就给每个重要的pallet配置StorageVersion哪怕第一版就写一个#[pallet::storage_version] pub const STORAGE_VERSION: StorageVersion StorageVersion::new(1);这样当未来数据结构变化时有明确的版本基线可以对照。5.3 权重的意义为什么每条extrinsic都要给一个weightpallet call上方的#[pallet::weight(10_000)]不是随便填的。weight代表这条交易消耗的计算资源既要防止单条交易让节点陷入超长计算也影响交易费用排序。我的习惯是先给一个保守值等业务跑起来后用基准测试工具校准。如果你完全不关心费用在开发链上填一个固定值也无所谓。但在支付权重模块存在的正式链上weight填得太低可能导致交易总是执行失败或者被网络拒绝因为在执行前区块生产者会做预检查认为你给的weight不足以覆盖计算开销。教训是weight不是摆设不要让它在业务上线后成为隐故障源。5.4 常见报错快速对照表我把开发过程中反复出现的几类报错整理成了表格方便你在遇到问题时快速定位。报错现象最可能原因处理方式wasm build时报linker错误缺少protobuf或clang安装系统依赖后重新编译call requires a runtime upgrade新写的extrinsic还没有被链上旧runtime接受先通过sudo触发runtime升级或者重启dev链ProofAlreadyExists交易失败调用接口重复提交相同哈希业务层面先做查询校验启动节点后状态查询全为空用了旧的数据库目录改用--tmp或被污染数据库数据清空编译时所有crate都显示版本不一致本地Cargo依赖缓存被破坏删除Cargo.lock中相关条目后重新锁定5.5 一条被忽略的“逻辑坑”外部输入别直接当哈希存存证pallet花了很大篇幅校验存证者权限却还有一类安全隐患容易漏掉你在业务设计里以为传入的是哈希但调用方实际上传的可能是任意字节串。哈希长度在运行时是可以校验的但更稳妥的做法是让用户传入任意字节由pallet内部计算哈希再拿哈希去存证。这样既能验证输入长度也能保证链上存储的是真正有密码学意义的哈希。我在迭代存证模块时后来就改成用户传入原始内容长度的上限和内容本身pallet内部用blake2_256计算摘要。这样用户根本不需要知道用什么哈希算法也避免了不同客户端对同一内容算出不同哈希的混乱局面。6. 测试与前端连接上线前最后的自我检查6.1 单测与mock让pallet先于整链验证一个pallet能不能直接拿整条链来测当然可以但每次都要启动节点回报周期太慢。Substrate的pallet-test suite提供了一套mock runtime机制让你在纯Rust环境里合成一个最小runtime来测试pallet。在mock.rs中定义一个最小Runtime再在tests.rs中写测试。下面是一个很典型的claim并断言存储被更新的测试思路#[test] fn claim_works() { new_test_ext().execute_with(|| { let hash sp_runtime::testing::H256::repeat_byte(1); assert_ok!(Poe::claim(RuntimeOrigin::signed(1), hash)); assert!(Poe::proofs(hash).is_some()); }); }execute_with之间的状态是隔离的每个测试用例运行前都会重新生成创世状态。因此断言可以放心地依赖前一步的结果。mock runtime的构造比较繁琐不过模板里有一份现成的参考多数场景可以直接照搬改一改。6.2 try-runtime升级前的模拟演练跑完整条链之后如果有一次真正的runtime升级需要做不要直接在生产链上执行。Substrate提供了try-runtime工具可以在离线状态下用当前数据库模拟区块执行和升级过程。基本的操作是把链上状态导出一份再让try-runtime用待升级的runtime来执行几个区块。如果迁移逻辑有错误通常在这里就会暴露出来而不是等到链上所有人看到panic之后才救火。这个工具我最开始没使用结果一次storage迁移把测试链状态搞乱不得不回滚数据库重新同步。从那以后凡是涉及storage结构变化我都会先跑一遍try-runtime。6.3 从RPC到前端如何让业务页面真正读到链上数据pallet开发完毕后前端页面通常通过Polkadot JS API库来交互。连接本地节点const { ApiPromise, WsProvider } require(polkadot/api); const provider new WsProvider(ws://127.0.0.1:9944); const api await ApiPromise.create({ provider });读取存证记录const hash 0x...; const result await api.query.poe.proofs(hash); console.log(result.toHuman());调用交易const tx api.tx.poe.claim(hash); await tx.signAndSend(account, ({ status }) { if (status.isFinalized) { console.log(finalized); } });这里最容易踩的坑是类型定义。自定义pallet如果在链上注册了类型默认情况下前端库不一定认识。官方推荐使用TypeBundle或Typegen自动生成类型定义但最简单的方式是先手动在初始化时补充类型const api await ApiPromise.create({ provider, types: { Proof: { claimer: AccountId, block_number: BlockNumber, }, }, });如果不补类型很多返回值会显示成原始的hex序列。看起来“读到了数据”实际上没法解析成可读字段容易造成一种“链上查询成功”的假象但业务逻辑完全对接不上。6.4 调试日志用最小的方式观察pallet内部状态当链上交易失败但报错信息不够直观时可以在pallet代码里临时插入日志输出log::info!(claim happened: {:?}, claimer);需要在lib.rs顶部引入log模块并且在Cargo.toml里开启std特性下的log支持。节点日志里如果加入这些信息配合tracing工具排查问题会容易得多。临时的日志代码上线前记得去掉否则信息量太大会淹没真正的关键日志。我曾遇到一个很邪门的问题同一笔交易在本地节点提交成功换到测试网却一致失败。后来才发现测试网的pallet版本比本地新内部逻辑已经改了校验规则而前端还是按老版本传参数。日志里发现传入的哈希经过了一层变换根本对不上。这种问题不看log是排查不明白的。最后说点实在话单独看“substrate”这个词你很难想象它背后有这么多内容。但正因为它既普通又深邃才有必要专门花时间去拆解。从生物化学里的底物到材料学里的基板再到区块链领域的Substrate框架名字只是一个入口真正重要的是它代表的那层“支撑层”如何被设计和利用。如果你准备把Substrate用在自己的项目里我的体会有三条第一别怕编译慢别怕报错密集大多数问题都是版本造成的耐心锁定版本比反复试各种分支更有价值第二先把最小闭环跑通再逐步加业务模块一上来就设计几十个pallet的架构很容易把自己绕进去第三测试工具链一定要用足mock测试、try-runtime、日志三板斧加起来能让你在正式网络部署前就解决掉八成潜在问题。这也是我在接下来的自定义链开发里会持续使用的节奏。多给自己留出调试时间少在最后一刻突然做大规模升级比任何框架层面的技巧都实在。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。