ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Substrate区块链开发框架入门:从pallet模块化到runtime无分叉升级实战

Substrate区块链开发框架入门:从pallet模块化到runtime无分叉升级实战 1. 从“substrate”这个词说起它到底指什么第一次看到“substrate”这个词很多人会愣一下。它在不同圈子里含义差别很大做区块链的人第一反应是 Parity 那套区块链框架做材料、化学、生物的人想到的是“基底”“底物”“培养基”做半导体和微电子的人想到的是“衬底”做软件架构的人可能想到的是“底层基座”。所以单看一个标题“substrate”如果不结合上下文几乎没法判断要聊什么。我这次就按最主流、讨论度最高的方向来展开——区块链开发框架 Substrate。如果你是因为材料、半导体方向点进来的也别急着走我在第 2 节会把“substrate 作为通用概念”的几种含义先理清楚帮你判断自己到底要找的是哪一个然后再聚焦到区块链框架这条主线上。Substrate 是 Parity 团队开源的一套区块链开发框架用 Rust 写成。它的核心价值一句话概括让你不用从零造一条链而是像搭积木一样通过组合现成的模块pallet快速拼出一条符合自己业务需求的区块链。这跟“从比特币源码 fork 改一改”或者“从以太坊客户端魔改”是完全不同的思路——前者是框架化、模块化后者是复制粘贴式改造。为什么它值得单独拿出来讲因为绝大多数人第一次接触 Substrate 时都会经历三个阶段第一阶段觉得“哇模块化真香”第二阶段发现“编译一次半小时报错看不懂”第三阶段才慢慢摸到“哪些 pallet 能直接用、哪些必须自己写、runtime 升级到底怎么玩”。这篇就把这三个阶段里最容易卡住的地方按我自己的实操经验拆开讲。适合谁看想入门区块链底层开发、想评估“要不要用 Substrate 做一条业务链”、或者已经在写 pallet 但被 runtime 和存储搞晕的人。不需要你精通 Rust但至少要能看懂基本的 Rust 语法否则后面会有点吃力。2. 先把“substrate”的几种含义分清楚别走错门2.1 材料与半导体语境下的 substrate在材料科学里substrate 通常翻译成“基底”或“衬底”指的是承载某种反应、生长或沉积过程的底层材料。比如化学气相沉积CVD里薄膜是长在衬底上的生物实验里培养基就是微生物生长的 substrate半导体制造里硅片本身就是 substrate上面再外延、光刻、镀膜。这个语境下的核心关注点是表面特性、晶格匹配、热膨胀系数、洁净度。举个具体例子在硅衬底上外延生长氮化镓GaN晶格失配率大概在 17% 左右这个数字直接决定了外延层里会积累多少位错密度。工程师要做的就是在衬底和外延层之间加缓冲层来缓解应力。这类问题的分析逻辑是“材料参数决定工艺窗口”跟软件框架完全是两套语言。2.2 生物化学语境下的 substrate生物化学里 substrate 一般叫“底物”指酶催化反应中被作用的那个分子。比如淀粉酶作用于淀粉淀粉就是底物蛋白酶作用于蛋白质蛋白质就是底物。这里的关键概念是“酶与底物的特异性结合”也就是锁钥模型。做实验的人关心的是 Km 值、Vmax、抑制类型这些动力学参数。2.3 区块链框架 Substrate回到主线。区块链领域的 Substrate 是一个用于构建区块链的 Rust 框架由 Parity Technologies 开发Polkadot 中继链本身就是用它写的。它的设计哲学是“链即 runtimeruntime 即状态转换函数”。你写的所有业务逻辑最终都编译成一个 Wasm 二进制作为链的“状态转换函数”存在。这三种含义虽然都叫 substrate但底层逻辑完全不同。下面所有内容都聚焦在区块链框架 Substrate上。如果你要找的是材料或生物方向那这篇可能帮不上太多但至少你现在知道该往哪个方向搜了。3. Substrate 的模块化设计pallet 到底解决了什么问题3.1 从“改源码”到“拼模块”的思维转变传统做链的方式是 fork 一个成熟项目然后在源码里改。改着改着你会发现升级一次上游版本你的改动全冲突想加个新功能得在几千行代码里找插入点团队里两个人改同一个文件合并起来痛不欲生。Substrate 的思路是把链拆成一个个功能独立的 pallet托盘/模块每个 pallet 负责一块业务比如余额、治理、质押、身份。这种设计带来的直接好处是功能边界清晰升级和替换成本低。你想加一个“资产发行”功能直接用现成的pallet-assets想加“多签账户”用pallet-multisig想加“治理投票”用pallet-democracy。每个 pallet 都有自己的存储、事件、错误类型和可调用函数extrinsic。我个人的体会是Substrate 最值钱的地方不是它自带的那些 pallet而是它强制你用模块化的方式思考链的设计。以前你可能把“转账”“手续费”“治理”混在一个大合约里现在你必须想清楚哪些逻辑属于哪个 palletpallet 之间怎么通过 trait 通信。3.2 一个 pallet 的最小结构长什么样一个标准 pallet 通常包含这几块Config trait定义这个 pallet 依赖哪些外部类型和参数比如关联类型RuntimeEvent、Currency、常量MaxLocks等。Storage用#[pallet::storage]声明的链上存储比如StorageMap、StorageValue、StorageDoubleMap。Events用#[pallet::event]声明链上发生重要动作时抛出方便前端和索引器监听。Errors用#[pallet::error]声明交易失败时返回具体原因。CallsExtrinsics用#[pallet::call]声明用户或治理可以调用的函数。Hookson_initialize、on_finalize、on_runtime_upgrade等生命周期钩子。下面是一个极简的 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 { new_value: u32 }, } #[pallet::error] pub enum ErrorT { Overflow, } #[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 next current.checked_add(1).ok_or(Error::T::Overflow)?; CounterValue::T::put(next); Self::deposit_event(Event::CounterIncremented { new_value: next }); Ok(()) } }这段代码看起来简单但里面有几个新手最容易忽略的点call_index必须唯一且一旦上线就不能改否则前端编码会错位weight是预估值写小了会导致交易被拒写大了浪费区块空间ensure_signed用来校验调用者签名不写的话任何人都能调。3.3 pallet 之间怎么通信pallet 不是孤岛。比如你的自定义 pallet 想给用户转账就需要依赖pallet-balances提供的Currencytrait。做法是在 Config 里声明关联类型#[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; type Currency: CurrencySelf::AccountId; }然后在 runtime 组装时把pallet_balances::PalletRuntime传进去。这种通过 trait 解耦的方式让 pallet 可以独立测试、独立升级也方便你在不同链之间复用同一套业务逻辑。注意pallet 之间的依赖关系要尽量保持单向避免 A 依赖 B、B 又依赖 A 的循环。真遇到循环依赖通常说明业务边界没切干净需要重新拆分。4. Runtime 组装与升级Substrate 最容易被低估的能力4.1 Runtime 是什么为什么它这么特殊在 Substrate 里runtime 就是链本身。它定义了账户怎么创建、交易怎么执行、状态怎么变更、区块怎么出。更关键的是runtime 被编译成Wasm 二进制以“状态”的形式存在链上。这意味着你可以通过链上治理投票直接替换 runtime 代码实现无分叉升级。这一点是 Substrate 相比很多传统链最大的差异化能力。传统链升级往往要硬分叉社区吵半天节点运营者手动升级客户端。Substrate 的链上 runtime 升级只要提案通过新 Wasm 被写入链上下一个区块就按新逻辑执行节点软件本身都不用动。4.2 组装 runtime 的实操要点组装 runtime 的核心文件是runtime/src/lib.rs主要做几件事用construct_runtime!宏把所有 pallet 拼起来并给每个 pallet 分配一个索引。为每个 pallet 实现它的Configtrait把关联类型填上。定义Runtime类型、Executive、Block、UncheckedExtrinsic等。配置parameter_types!里的各种常量比如出块时间、手续费、押金。一个典型的construct_runtime!片段construct_runtime!( pub enum Runtime { System: frame_system, Timestamp: pallet_timestamp, Balances: pallet_balances, TransactionPayment: pallet_transaction_payment, Sudo: pallet_sudo, Template: pallet_template, } );这里每个 pallet 前面的名字如System、Balances会成为它在链上存储和事件里的前缀。顺序和索引一旦上线就不能随意改因为 extrinsic 的编码里包含 pallet 索引和 call 索引。改索引等于让所有历史交易的编码失效。4.3 无分叉升级的完整流程我实际跑过一遍的流程大致是这样改完 runtime 代码编译出新的 Wasm 文件cargo build --release后产物在target/release/wbuild/...。计算 Wasm 的 blake2-256 哈希作为版本标识。通过治理提案或 sudo测试网常用提交system.set_code调用把新 Wasm 写进去。提案通过后on_runtime_upgrade钩子被触发你可以在里面写存储迁移逻辑。下一个区块开始链就按新 runtime 运行了。这里最容易踩的坑是存储迁移。如果你改了某个存储项的类型或结构旧数据不会自动适配必须在on_runtime_upgrade里手动迁移。我见过有人改了StorageValue的类型但忘了迁移结果链上读出来的数据全是乱码只能回滚。提示每次 runtime 升级前务必在本地用try-runtime工具跑一遍迁移测试。它能模拟升级前后的状态帮你提前发现存储不兼容问题比上线后回滚省事得多。5. 开发环境搭建从零到跑通一条本地链5.1 工具链安装里那些“文档没写”的细节Substrate 官方推荐用rustup管理 Rust 工具链。但直接装最新版 Rust 往往会编译失败因为 Substrate 对 Rust 版本有要求。我的做法是rustup install nightly-2024-01-01 rustup target add wasm32-unknown-unknown --toolchain nightly-2024-01-01 rustup component add rust-src --toolchain nightly-2024-01-01为什么要指定日期因为 Substrate 的 Wasm 编译依赖 nightly 的某些特性而 nightly 每天都在变用固定日期能保证团队里每个人编译结果一致。wasm32-unknown-unknown这个 target 必须装否则 runtime 编译不出 Wasm。另外protobuf和clang这两个系统依赖经常被忽略。在 Ubuntu 上sudo apt install protobuf-compiler clang没有它们编译到一半会报链接错误而且报错信息很隐晦新手很难定位。5.2 用模板快速起一条链Parity 提供了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模式会启动一条单节点开发链自动出块预置了 Alice、Bob 等测试账户。第一次编译大概要 20 到 40 分钟取决于机器性能。编译完之后你可以用 Polkadot.js Apps 连到本地ws://127.0.0.1:9944直接看到链上状态、发起交易、查事件。我建议新手先别急着改代码而是先用模板链把“转账、查余额、看事件”这套流程走一遍。这样你对“extrinsic 怎么进块、事件怎么产生、状态怎么变”会有一个直观感受再去读 pallet 代码就顺很多。5.3 前端交互的最小验证跑通链之后用 Polkadot.js Apps 做几个验证在“开发者 - 链状态”里读System.Account看 Alice 的余额。在“开发者 - 交易”里调Balances.transfer给 Bob 转 100 个单位。在“网络 - 浏览器”里看刚出的块找到你发的交易和对应事件。这套操作看起来简单但它把“签名、编码、进块、执行、出事件”整条链路串起来了。很多人卡在“我写的 pallet 为什么没反应”其实是因为没搞清楚 extrinsic 从提交到执行中间经过了哪些环节。6. 写第一个自定义 pallet从需求到上链的完整链路6.1 需求拆解先想清楚“状态怎么变”假设我要做一个“打卡”pallet用户每天可以打卡一次累计打卡次数存在链上。这个需求拆成状态和动作状态每个账户的打卡次数StorageMapAccountId, u32、上次打卡时间StorageMapAccountId, BlockNumber。动作check_in校验距离上次打卡是否超过一天更新计数和时间抛出事件。错误AlreadyCheckedIn、Overflow。这里的关键设计决策是“一天”怎么定义。用区块高度还是时间戳区块高度在不同链上出块速度不同时间戳更直观但依赖pallet-timestamp。我选时间戳因为业务语义更清晰。6.2 存储设计里的取舍存储是要花钱的押金所以设计时要考虑用StorageMap还是StorageDoubleMap单键够用就别用双键。值类型能不能压缩u32够用就别用u128。需不需要ValueQuery如果读不存在的键要返回默认值就用ValueQuery如果要区分“不存在”和“零”就用OptionQuery。我的打卡 pallet 存储这样写#[pallet::storage] pub type CheckInCountT: Config StorageMap _, Blake2_128Concat, T::AccountId, u32, ValueQuery, ; #[pallet::storage] pub type LastCheckInT: Config StorageMap _, Blake2_128Concat, T::AccountId, T::Moment, OptionQuery, ;Blake2_128Concat是哈希策略128表示哈希长度Concat表示把原始键拼在哈希后面。这样既能防碰撞又能在需要时还原原始键。选哈希策略时如果键本身很短且唯一可以用Twox64Concat省点计算如果键是用户可控的用Blake2更安全。6.3 权重计算别让交易把区块撑爆每个 extrinsic 都要声明weight它代表这个调用消耗的计算资源。写小了交易可能因为超出区块上限被拒写大了区块空间被浪费。Substrate 提供了#[pallet::weight]属性可以写固定值也可以用T::DbWeight动态计算。对于打卡这种简单操作我一般先估一个保守值#[pallet::weight(T::DbWeight::get().reads_writes(2, 2).saturating_add(10_000))]意思是读两次存储、写两次存储再加一点计算开销。上线前最好用 benchmark 工具跑出真实值但在开发阶段保守估计能让你先把功能跑通。6.4 测试别等上链才发现逻辑错pallet 的单元测试用mock.rs搭一个最小 runtime然后写测试用例。我习惯至少覆盖这几种情况正常打卡成功计数加一。同一天重复打卡返回AlreadyCheckedIn。跨天后打卡成功。计数溢出时返回Overflow。#[test] fn check_in_works() { new_test_ext().execute_with(|| { assert_ok!(Template::check_in(RuntimeOrigin::signed(1))); assert_eq!(CheckInCount::Test::get(1), 1); }); }测试跑通再上链能省掉大量“上链后发现逻辑不对、又要重新升级”的时间。我踩过的坑是本地测试用的是 mock 时间上链后用的是真实时间戳结果“一天”的判断逻辑在两条链上表现不一致。后来我在 mock 里也模拟了时间推进才把问题复现出来。7. 那些让我卡了半天的坑以及怎么爬出来7.1 编译报错看不懂从错误信息里找线索Substrate 编译报错经常是一大串 trait bound 不满足。新手看到几百行报错直接懵。我的经验是从最后一行往前看找第一个提到你自己代码文件位置的错误。前面的报错往往是连锁反应真正的根因在最后。另一个常见问题是wasm32target 没装报错会说找不到core或std的 wasm 版本。这时候回去检查rustup target list --installed确认wasm32-unknown-unknown在列表里。7.2 存储迁移忘了写链上数据读不出来前面提过改存储结构必须写迁移。我具体踩的坑是把StorageValueu32改成StorageValueu64以为只是类型变宽旧数据能自动读。实际上 SCALE 编码变了旧数据按新类型解码会出错。正确做法是在on_runtime_upgrade里读旧值、转新值、写回去并用StorageVersion标记迁移状态防止重复执行。#[pallet::storage] pub type StorageVersionT StorageValue_, Releases, ValueQuery; #[pallet::hooks] implT: Config HooksBlockNumberForT for PalletT { fn on_runtime_upgrade() - Weight { // 读旧值、迁移、更新版本号 T::DbWeight::get().reads_writes(1, 1) } }7.3 事件没抛出来前端看不到事件要在deposit_event里抛而且RuntimeEvent的关联类型要正确配置。我遇到过事件定义了但前端查不到最后发现是construct_runtime!里 pallet 的Event没带上或者 runtime 的Event枚举没包含这个 pallet。检查方法是看编译后的 metadata确认事件类型在列表里。7.4 权重估小了交易被拒开发阶段用固定权重没问题但上线后如果实际消耗超过声明值交易会被区块拒绝。表现是“交易提交成功但一直不打包”。解决办法是用frame-benchmarking跑真实权重或者把权重估得保守一些宁可浪费一点区块空间也别让交易失败。8. 关于 Substrate 学习路径的一点个人建议如果你是从其他语言转过来的我建议的顺序是先跑通模板链再读一个简单 pallet 的源码然后照着写一个自己的 pallet最后再碰 runtime 组装和升级。别一上来就啃construct_runtime!和Executive那些是框架最复杂的部分等你对 pallet 有感觉了再回头看会轻松很多。Rust 这块不需要你精通所有权和生命周期才能开始但至少要能看懂Result、Option、trait、泛型这些基础。遇到编译错误别慌Substrate 的报错虽然长但信息量其实很大耐心读往往能找到线索。最后分享一个我自己的习惯每次改完 pallet先在本地cargo test跑一遍再用try-runtime模拟升级最后才上测试网。这套流程看起来多花时间但比起上线后回滚省下来的时间多得多。链上代码不像普通应用改错了代价很高慢一点反而快。
返回列表