ARTICLE DETAIL

资讯详情

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

Substrate区块链开发框架实战:从核心设计到pallet开发与链上升级

Substrate区块链开发框架实战:从核心设计到pallet开发与链上升级 1. 从“substrate”这个词说起它到底是什么为什么值得聊第一次听到“substrate”这个词很多人会愣一下。它在不同圈子里指向完全不同的东西做区块链的人第一反应是 Parity 那套区块链开发框架做材料的人想到的是衬底、基材做生物实验的人想到的是培养基做软件架构的人则可能联想到“底层支撑层”这个概念。这个词本身的意思就是“底层、基底、被承载的那个东西”——不管哪个领域它都指向同一个角色默默在下面撑着上面所有花哨的东西都跑在它上面。我最早接触 substrate 是在区块链开发场景里。当时想做一个带自定义业务逻辑的链第一反应是 fork 一条现成的公链代码来改结果发现改共识、改治理、改经济模型每一步都像在别人盖好的房子里砸承重墙。后来有人推荐 substrate说它把“搭一条链”这件事从“改别人的房子”变成了“自己搭积木”。这个比喻很到位也是我后来一直用它的核心原因。这篇内容我想聊的就是 substrate 作为一套区块链开发框架它到底解决了什么问题、核心设计思路是什么、实际动手搭一条链要经过哪些环节、以及我在实操中踩过的那些坑。适合两类人看一类是刚听说 substrate、想知道它和直接 fork 一条链有什么区别的开发者另一类是用过一点、但在配置和调试环节卡住、想找一份能直接抄作业的实操参考的人。我不会把它写成官方文档的复述而是按一个真实做过项目的人的视角把“为什么这么设计”和“实际怎么落地”讲清楚。需要先说明一点substrate 的版本迭代非常快不同大版本之间 API 变化不小。我下面讲的内容基于我实际用过的稳定版本实践具体到某个函数名或配置项你在自己动手时一定要对照当前版本的官方文档核对不要照搬。这是所有快速演进框架的通病也是我踩过的第一个坑。2. 核心设计思路拆解为什么是“框架”而不是“又一条链”2.1 从“改代码”到“拼模块”的思维转变传统做一条链的思路是找一条成熟的公链把代码拉下来然后改共识参数、改出块时间、改代币经济、改治理规则。这个思路在早期很常见问题也很明显——你改的每一处都和原链的其他部分耦合在一起改 A 可能影响 B升级一次代码要重新 merge 大量冲突。更麻烦的是你改完之后这条链的维护责任全落在你身上原链的更新你很难同步。substrate 的核心思路是把一条链拆成一个个可插拔的模块它管这些模块叫 pallet。共识、账户、资产、治理、质押每一个都是独立的 pallet你想要什么就装什么不想要就删掉。这就像从“买一套精装房自己砸墙”变成“用标准件自己搭房子”——承重结构、水电、门窗都是标准接口你只需要决定要几个房间、怎么布局。这个设计带来的直接好处是你只需要写自己业务独有的那部分逻辑通用的部分直接用现成的 pallet。比如你要做一条专注数字收藏品的链账户体系、余额、手续费这些直接用系统自带的你只需要写收藏品的铸造、转移、销毁逻辑。代码量可能只有 fork 方案的十分之一。2.2 Runtime 是整条链的“大脑”也是升级的关键substrate 里有一个概念叫 runtime中文一般叫“运行时”。它是整条链的业务逻辑核心决定了这条链能做什么、不能做什么。你可以把它理解成链的“操作系统”——所有交易最终都要经过 runtime 的处理才能生效。runtime 最特别的地方在于它是可以链上升级的。传统链要升级逻辑得让所有节点手动换二进制文件协调不好就分叉。substrate 把 runtime 编译成一个 Wasm 文件这个文件本身可以作为一笔交易发到链上节点收到后自动替换。这意味着你的链可以在不停机、不要求节点手动操作的情况下完成逻辑升级。这个能力在实际运营中价值极大——发现 bug 可以快速修想加功能可以平滑加。但这里有个坑我要提前说链上升级虽然方便但升级前必须做充分的本地测试。我见过有人直接把没测过的 runtime 发上链结果新逻辑和已有存储结构不兼容链直接卡住。升级的便利性是把双刃剑用不好反而更容易出事。2.3 为什么选 Rust 作为开发语言substrate 用 Rust 写runtime 也用 Rust 写。这个选择不是随便定的。Rust 有几个特性正好契合区块链开发的需求内存安全、没有垃圾回收带来的不确定性停顿、编译期就能发现大量错误、以及对 Wasm 的良好支持。对开发者来说这意味着学习曲线比写 JavaScript 或 Python 要陡一些。所有权、生命周期、借用检查这些概念新手第一次接触会很不适应。但换个角度想这些限制恰恰帮你避开了很多运行时才暴露的 bug。我在写 pallet 的时候好几次是编译器直接拦住了一个我逻辑上的疏忽如果换成动态语言这个 bug 可能要等到上链跑出问题才发现。提示如果你完全没有 Rust 基础不要一上来就啃 substrate。先用一两周把 Rust 的基本语法、所有权、trait、泛型过一遍再回来看 substrate 的代码效率会高很多。直接硬啃 substrate 源码很容易被各种泛型和 trait 约束劝退。3. 核心组件与实操要点一条链由哪些部分拼成3.1 节点、runtime 与 pallet 的关系一条 substrate 链在结构上分两层节点层和runtime 层。节点层负责网络通信、区块同步、交易池管理这些“基础设施”工作它用 Rust 编译成原生二进制。runtime 层负责业务逻辑编译成 Wasm。节点层调用 runtime 层来处理每一笔交易和每一个区块。pallet 则是 runtime 内部的组成单元。一个 runtime 由多个 pallet 组合而成每个 pallet 有自己的存储、自己的交易类型、自己的钩子函数。系统自带的 pallet 包括Pallet 名称作用是否必需System区块、账户、事件的基础设施必需Balances账户余额与转账通常需要Timestamp区块时间戳通常需要Sudo超级用户权限方便测试测试期常用Aura / BABE出块共识视共识方案而定GRANDPA最终性共识视共识方案而定Governance 系列治理、投票、提案按需你自定义的业务逻辑就写成一个新的 pallet然后把它注册进 runtime 的构造里。这个过程有点像在操作系统里装驱动——写好自己的驱动然后在配置里声明加载它。3.2 写一个 pallet 的基本骨架一个 pallet 通常包含几个固定部分。我用一个最简单的“计数器”例子来说明结构这个例子虽然简单但把 pallet 的核心要素都覆盖了。#[pallet::pallet] pub struct PalletT(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::storage] pub type CounterValueT StorageValue_, u32, ValueQuery; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { CounterIncremented(u32), } #[pallet::call] implT: Config PalletT { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn increment(origin: OriginForT) - DispatchResult { let who ensure_signed(origin)?; let current CounterValue::T::get(); let new_value current.saturating_add(1); CounterValue::T::put(new_value); Self::deposit_event(Event::CounterIncremented(new_value)); Ok(()) } }这段代码里有几个关键点值得展开。#[pallet::config]定义了这个 pallet 依赖的外部配置比如事件类型必须和 runtime 的事件类型兼容。#[pallet::storage]声明链上存储StorageValue是最简单的单值存储。#[pallet::call]里的函数就是可以被外部调用的交易ensure_signed检查调用者签名有效saturating_add防止溢出。#[pallet::weight(10_000)]这一行是权重声明它告诉链这个调用大概消耗多少计算资源。权重机制是 substrate 防止链被恶意交易拖垮的核心设计后面我会专门讲。3.3 存储设计链上存储是最贵的资源写 pallet 时最容易犯的错误是把链上存储当成普通数据库来用。链上存储的每一次读写都要所有节点同步执行成本极高。所以设计存储时要遵循几个原则能算出来的不要存。如果一个值可以通过其他存储推导出来就不要单独存一份。用合适的存储类型。单值用StorageValue映射用StorageMap双重映射用StorageDoubleMap。选错类型会导致查询效率低下。注意存储前缀。每个 pallet 的存储有独立前缀不会互相覆盖但前缀本身也占空间命名要短。清理无用数据。删除映射里的条目要用remove不要留空值占位。我踩过的一个坑是早期设计时把一个列表直接存成Vec想着方便遍历。结果列表一长每次读取都要把整个 Vec 加载进内存权重飙升交易费高得离谱。后来改成StorageMap加索引按需读取问题才解决。链上存储的设计思路和传统后端完全不同一定要有“每次读写都要付费”的意识。4. 完整实操流程从零搭一条能跑的链4.1 环境准备与依赖安装动手之前先把环境搭好。substrate 开发需要 Rust 工具链、Wasm 编译目标、以及一些系统依赖。以下是我在 Linux 环境下的标准流程其他系统大同小异。# 安装 Rust 工具链 curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh source ~/.cargo/env # 添加 Wasm 编译目标 rustup target add wasm32-unknown-unknown # 安装 substrate 相关的辅助工具 cargo install --force substrate-contracts-node系统依赖方面Ubuntu/Debian 系需要装build-essential、clang、libssl-dev、protobuf-compiler这些。缺了任何一个编译到一半报错都很常见。我的习惯是先把依赖一次性装齐再开始编译避免反复中断。注意substrate 项目首次编译非常慢我实测在普通开发机上要二三十分钟甚至更久。这不是你环境有问题是正常的。建议第一次编译时泡杯茶等着别以为卡死了就反复中断重来那样只会更慢。4.2 用模板起一个项目官方提供了几种模板最常用的是substrate-node-template。它自带一个最小可运行的链包含基本的账户、余额、共识适合作为起点。git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template cargo build --release编译完成后用开发模式启动./target/release/node-template --dev--dev模式会用一个预置的账户和一条临时链每次重启都从创世状态开始非常适合开发调试。启动后你会看到节点开始出块终端里不断打印区块信息。这时候链已经跑起来了虽然它现在什么业务功能都没有。4.3 添加自定义 pallet 并注册进 runtime模板里通常有一个pallets/template目录里面是一个示例 pallet。你可以复制它改成自己的也可以新建一个。关键步骤是把这个 pallet 注册进 runtime。注册分几步在 runtime 的Cargo.toml里加上依赖在lib.rs里实现Configtrait在construct_runtime!宏里声明这个 pallet。construct_runtime!是 runtime 的组装入口所有 pallet 都在这里列出来。construct_runtime!( pub enum Runtime where Block Block, NodeBlock opaque::Block, UncheckedExtrinsic UncheckedExtrinsic, { System: frame_system, Timestamp: pallet_timestamp, Balances: pallet_balances, Sudo: pallet_sudo, MyPallet: pallet_my_pallet, } );这里MyPallet就是我自定义的 pallet。注册完之后重新编译启动链你的 pallet 里的交易就可以通过前端或命令行调用了。4.4 权重计算别让一笔交易拖垮整条链权重是 substrate 里一个容易被忽视但极其重要的概念。每笔交易在执行前链需要知道它大概消耗多少计算资源以此决定收多少手续费、一个区块能塞多少笔交易。如果权重声明得太低恶意用户可以用一笔实际很重的交易占满区块声明得太高正常用户的交易费又太贵。权重的正确做法是用 benchmark 工具实测。substrate 提供了frame-benchmarking可以针对每个交易函数跑基准测试自动生成权重值。手动拍脑袋写权重只适合开发初期上生产前一定要跑 benchmark。#[pallet::weight(T::WeightInfo::increment())] pub fn increment(origin: OriginForT) - DispatchResult { // ... }用T::WeightInfo把权重抽象成可配置的benchmark 生成的权重文件通过配置注入。这样升级权重不用改业务代码只改配置即可。4.5 本地测试与链上升级演练在把任何东西发上主链之前本地测试必须做足。substrate 的测试分几层pallet 单元测试、runtime 集成测试、以及本地多节点测试网。pallet 单元测试用mockruntime把 pallet 单独拎出来测逻辑。runtime 集成测试则把整个 runtime 跑起来测 pallet 之间的交互。我一般会写这几类测试正常路径交易成功存储正确更新事件正确触发。边界条件数值溢出、空输入、重复操作。权限检查未签名调用、无权限账户调用是否被正确拒绝。存储一致性操作前后存储状态是否符合预期。链上升级演练则是把新 runtime 编译成 Wasm在本地测试网上走一遍升级流程确认升级后链能正常出块、旧数据能正常读取。这一步千万别省我见过太多“本地跑得好好的一升级就出事”的案例。5. 常见问题与排查技巧实录5.1 编译类问题速查substrate 项目编译报错是家常便饭尤其是版本不匹配的时候。下面这张表是我整理的高频编译问题。报错关键词常见原因解决思路wasm32 target not found没装 Wasm 编译目标rustup target add wasm32-unknown-unknownfailed to select a version依赖版本冲突检查各 crate 的版本约束统一到同一大版本cannot find traittrait 未导入或版本不匹配检查use语句和依赖版本overflow evaluating requirement泛型递归过深简化类型约束或增加编译递归限制linker not found缺系统链接器安装build-essential或对应系统的编译工具编译问题里最耗时的往往是版本冲突。substrate 生态的 crate 版本更新频繁不同 pallet 依赖的frame-support版本可能不一致。我的经验是整个项目统一用一个版本的 substrate 依赖不要混用不同来源的 pallet否则版本地狱会让你怀疑人生。5.2 运行时 panic 的定位方法runtime 里 panic 是最难查的问题之一因为链上环境看不到详细的调用栈。定位这类问题有几个手段本地复现用相同的输入在本地测试环境跑一遍本地能看到完整调用栈。日志输出在可疑位置加log::info!通过节点日志观察执行到哪一步。事件追踪在关键分支触发事件通过事件判断执行路径。二分排查把可疑代码一段段注释掉缩小范围。我遇到过一次典型的 panic一笔交易在特定输入下导致 runtime 崩溃。本地测试怎么都复现不了后来发现是测试用的输入和真实链上的输入在某个边界值上差了一点。加上边界值测试后问题立刻复现。这件事让我养成了一个习惯测试用例一定要覆盖边界值尤其是数值运算和集合操作。5.3 存储迁移的坑链上升级时如果新版本改了存储结构必须做存储迁移。比如把一个StorageValue改成StorageMap旧数据不会自动转换需要写迁移逻辑在升级时执行。迁移逻辑一般写在 runtime 的on_runtime_upgrade钩子里。这里有几个要点迁移要幂等万一执行两次不能出错。迁移要有版本标记通过存储版本号判断是否需要迁移避免重复执行。迁移要考虑数据量如果数据很多一次性迁移可能超出区块权重限制需要分批迁移。我踩过的坑是迁移逻辑写好了但忘了加版本判断结果每次升级都重新迁移一遍把已经转换过的数据又转了一次直接搞坏。后来加了StorageVersion检查才解决。存储迁移是链上升级里风险最高的环节一定要在测试网反复演练。5.4 权重与手续费异常有时候你会发现某笔交易的手续费高得离谱或者区块里塞不下几笔交易。这通常是权重声明有问题。排查思路用 benchmark 实测该交易的真实权重对比声明值。检查是否有循环或遍历操作这类操作的权重随数据量增长。检查存储读写次数每次读写都有固定权重成本。确认权重计算是否包含了所有分支最坏分支的权重才是应该声明的值。权重机制的设计初衷是让链的资源消耗可预测、可定价。理解这一点很多看似奇怪的限制就说得通了。6. 我个人的一些实操体会用 substrate 做项目这段时间最大的感受是它把“搭链”这件事的门槛从“改一条现成的链”降低到了“写业务逻辑”但这个降低是有代价的——你需要理解它的一整套抽象runtime、pallet、权重、存储、升级机制。这些概念刚接触时确实绕但一旦理顺后面写业务逻辑会非常顺。另一个体会是不要试图一次做完所有功能。我一开始想在一个 pallet 里把所有业务都塞进去结果代码越来越臃肿测试越来越难写。后来拆成多个小 pallet每个只负责一件事通过事件和存储互相通信维护成本立刻降下来。pallet 化的意义就在于解耦别自己把它又耦合回去。最后分享一个调试小技巧substrate 节点启动时可以加-lruntimedebug参数把 runtime 的日志级别调到 debug很多执行细节会打印出来。这个参数在我排查运行时问题时帮了大忙比盲目加日志高效得多。具体参数名不同版本可能略有差异用之前查一下当前版本的日志配置说明。
返回列表