ARTICLE DETAIL

资讯详情

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

Substrate搭链实战:无分叉升级与pallet模块化设计

Substrate搭链实战:无分叉升级与pallet模块化设计 1. 为什么我在2024年选择Substrate搭链而不是从零开发或直接fork其他链1.1 先说结论Substrate解决的是“业务链长期演进”这件事我大概从2023年下半年开始密集评估区块链底层方案当时接了一个偏业务性质的项目要把一套存证确权加积分流转的逻辑搬到链上同时又要保证后续能不断加新功能而不是上线即冻结。评估了一圈之后最后选定了Substrate这个决定到今天回看依然不后悔。Substrate给我的第一印象是它特别不像传统意义上的“公链代码库”。Parity Team维护的这套框架本质上是把区块链公共组件——存储、P2P网络、共识、交易队列、运行时执行环境——全部模块化让你能用搭积木的方式快速产出一条链。它不是一个给你“现成产品”的项目而是一套“生产工具”。这也是为什么很多团队会用“Substrate搭一条链”而不是“抄一份链代码”来形容这个过程。不过我在这里要强调一点选Substrate不等于选Polkadot。虽然两者关系紧密但Substrate可以独立使用你可以跑出完全属于自己的单链不接Polkadot中继链不用Polkadot的代币经济模型。这一点对内部项目、联盟链、私有业务链特别友好。很多人误以为用了Substrate就要发币、接波卡生态事实上完全可以切成 solo chain 模式自由度高得多。我最终敲定用Substrate核心原因是三个第一链上升级不需要硬分叉第二业务逻辑以pallet形式拆分模块间边界清晰第三Rust实现的性能和工程化成熟度在区块链框架里属于第一梯队。接下来这几段我会围绕这三点展开把这些年的实践过程记录下来也给想入坑Substrate的朋友一些能直接用的经验。1.2 为什么我坚决排除了“从零开发”和“fork一条现成链”先说从零开发。区块链最麻烦的不是链本身而是那些容易被忽略的周边网络层、序列化、密码学、存储引擎、共识协议、P2P同步、交易池。真要自己写光是把区块同步和双花防护做对就足够烧掉大半年人力。而且即便你把主线功能跑通了安全性验证又是另一座山。业务团队真正需要的是把业务逻辑放在链上而不是花一年时间证明你的共识实现没有bug。也有团队喜欢直接用EVM兼容链比如fork一份geth或者基于某种现成链改。这个方案对“纯智能合约”的业务确实省事但如果业务上需要自定义密码学、自定义存储结构、自定义共识规则、自定义出块逻辑你会发现智能合约抽象限制非常多。最典型的问题是合约只能跑在沙箱环境里访问不到链层状态定价、存储模型、手续费机制都受制于底层实现能做定制化的空间非常有限。这种时候Substrate的“framework”定位就显得非常合适。它把最复杂的底层封装好了同时把业务层彻底开放给你。你没必要关心P2P网络细节但你有权决定一条交易怎么处理、一个存储项怎么组织、一个区块的weight怎么计算。用我们工程上的话讲它把“可替换部分”和“不可替换部分”分得很清楚。1.3 Substrate最打动我的是哪三个设计先说无分叉升级。传统链一旦部署逻辑代码就冻结在创世区块里想改业务逻辑只能通过硬分叉协调全节点升级。而Substrate把链的“业务逻辑”放进了RuntimeRuntime是一个Wasm blob存在链上。于是升级变成了一次普通链上调用只要治理机制通过调用set_code就可以换上新的Runtime逻辑普通节点会自动同步到新区块并执行新逻辑。听起来像魔法但它就是这么运作的。对一个需要持续迭代的业务系统来说这个能力可以说是“续命”级别的。第二点是pallet机制。pallet可以理解成Substrate里的“业务模块插件”一个存储定义、一组事件、一组调用函数打包成一个独立的Rust模块。Substrate官方提供了几十个pallet比如Balances、Staking、Session、Sudo、Treasury业务上排名靠前的社区也都贡献了。自己的业务逻辑只要照着pallet的规范写就能像插USB设备一样把它集成进Runtime。模块内部可以独立测试模块间还可以用Config接口做依赖解耦工程体验非常舒服。第三点是共识的可插拔性。Substrate支持Aura、Babe、Grandpa、PoW等多种共识方案还能混合使用。我在项目初期用Aura加Grandpa跑后面切到Babe加Grandpa做更复杂的出块策略整个切换并没有伤筋动骨。对业务场景来说有好几种共识选项兜底就不用被锁死在某一种设计上。2. Substrate核心设计拆解Runtime、FRAME和pallet是如何协作的2.1 节点壳与Runtime先分清“外面”和“里面”Substrate的架构我习惯用“壳”和“核”来理解。壳就是节点客户端负责网络同步、交易池、数据库存储、RPC接口和共识引擎等外部工作。核心则是Runtime也就是链上状态机的业务规则它被编译成Wasm字节码后被节点加载执行。节点壳和Runtime之间通过一套Host接口通信比如读写链上存储、调用指定函数等。这种“壳核分离”最大的好处是Runtime升级后节点客户端完全不需要重新编译。旧的节点只需同步新的Runtime Wasm并通过执行环境运行新的业务逻辑即可。也就是说你不用催促全网节点手动升级软件业务升级就能生效。这在整个区块链系统设计里就是颠覆性的思路。从开发视角看Runtime代码不直接跑在物理CPU上而是通过Wasm解释器或JIT执行。这意味着你不仅不能用标准库里的std能力连浮点数都要小心不同Wasm运行时对浮点运算的舍入行为可能不同。这也是为什么Substrate的pallet源码里文件开头经常能看到#![cfg_attr(not(feature std), no_std)]这样的声明。它告诉编译器当作为链上Runtime编译时不要链接标准库只用core子集。2.2 FRAME用宏把业务代码变成链上模块FRAME是Substrate上最常用的一套Runtime构建框架它通过一系列过程宏procedural macro来批量生成样板代码。你只需要写业务关心的部分比如存储项、事件、调用函数、错误类型其余的模块注册、事件分发、存储读写接口宏都会帮你生成。举个例子在Substrate上写一个“篡改审计”pallet最核心的代码大概分为这么几块#[pallet::config]定义pallet对外需要的配置项和依赖的类型比如事件类型、外部账户类型。#[pallet::storage]定义链上存储结构可以用StorageValue存单个值StorageMap存键值映射StorageDoubleMap存双层映射。#[pallet::event]定义业务事件比如“某账户写入了一条存证”。#[pallet::call]定义可被外部触发的函数即链上业务接口。#[pallet::error]定义业务错误类型方便调用方理解失败原因。这个模式对后端工程师来说很友好因为本质上它就像写一个面向接口的Rust服务只不过持久化层由Substrate的存储封装好了并发安全也由Runtime执行环境保证了。你基本不用考虑数据库事务问题因为每次调用函数要么完整执行成功要么结果全部回滚——和数据库事务的原子性一个道理。2.3 无分叉升级为什么能改变链的运营方式传统项目的升级流程是发版、协调全节点、等待社区跟进、切到新逻辑。Substrate只要求你有合适的“链上治理”流程。一个常见的实践是用Sudo pallet设一个超级管理员账户来调用set_code完成Runtime替换。更成熟的做法是用Democracy pallet组织链上投票票数通过后自动执行升级。我在实际项目中早期用的是Sudo模式因为团队还在快速迭代每天可能升级两三次。等业务稳定后再引入多签治理或民主投票机制。这里有个经验升级前一定要备份链上状态并且在小范围测试网络里演练一遍全套升级动作。虽然Substrate升级本身是“轻量”的但你的业务数据迁移逻辑如果写成链上函数那它有bug同样会让整条链进入不健康状态。升级方案简单不等于可以不做预案。另外升级时还要注意Runtime版本号的维护。Substrate在Runtime中有一个RuntimeVersion结构包含specific_version、impl_name、authoring_version等字段。这些字段不只是抬显客户端会依据它们判断是否需要执行状态迁移State Migration。版本号不更新可能导致升级后旧节点无法同步新逻辑或者客户端直接从缓存里返回旧版本这种坑很隐蔽。3. 从零搭建并运行第一条Substrate链环境准备与工程结构3.1 Rust工具链和Wasm编译目标Substrate开发基本绑定Rust所以第一步是装好Rust工具链。我是用rustup管理的因为在Substrate项目里经常需要切换stable和nightly工具链。Substrate本身要求nightly版本编译Runtime原因是部分宏和编译器特性还没有完全稳定到stable。我这里给一份安装流程直接复制粘贴可以跑curl --proto https --tlsv1.2 -sSf https://sh.rustup.rs | sh rustup default stable rustup toolchain install nightly-2024-03-01 rustup target add wasm32-unknown-unknown --toolchain nightly-2024-03-01注意后面的版本号建议你打开Substrate官方仓库的rust-toolchain.toml确认推荐的nightly版本不要随意装最新。因为Rust编译器偶尔有回归Substrate生态里最稳妥的做法是锁定官方验证过的某一天版本。我吃过一次亏用了一个很新的nightly结果编译Wasm时报了一堆宏展开错误后来回退到官方指定版本就好了。装完工具链后还需要安装一些系统依赖比如Ubuntu上需要clang、libssl-dev、protobuf-compiler。这些主要是编译节点二进制和生成RPC代码需要的。如果是macOS一般只需要确保Xcode Command Line Tools装好。3.2 用node-template快速起项目初始化项目建议直接用官方模板省去自己配构建脚本的时间。Substrate官方维护了几个模板仓库最简单的是substrate-node-template。clone下来后目录结构里最关键的是pallets目录和runtime目录前者放业务pallet后者负责把pallet组合成完整的链。git clone -b latest --depth 1 https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template cargo build --release首次编译会非常久我印象中在一台8核的机器上要半小时以上主要是在编译几百个依赖项目。如果你只是想快速验证可以先用cargo check来检查代码能不能过编译速度会快很多。真正需要跑节点的时候再执行release编译。编译完成之后模板里自带了一个最简单的runtime包含Balances、Sudo、Template pallet等初始模块。它默认跑的是dev配置开发者不需要配置创世验证者就能直接启动。3.3 启动节点和前端调试台的完整过程本地启动节点很简单./target/release/node-template --dev --tmp--dev表示单节点开发模式--tmp表示临时存储重启后数据清空。跑起来后你会看到终端持续打印出块日志说明节点已经开始出块了。这时候需要有前端工具来交互。官方提供了substrate-front-end-template它基于React和polkadot.js API用起来非常方便git clone -b latest --depth 1 https://github.com/substrate-developer-hub/substrate-front-end-template cd substrate-front-end-template yarn yarn start启动前端后它会默认连接本地节点的ws://127.0.0.1:9944。你可以在页面上查余额、发交易、调用runtime的extrinsic还能看链上事件和存储的变化。这个工具在调试阶段几乎是必需品因为很多节点日志不会暴露的状态变化在它上面一眼就能看到。我第一次跑通的时候其实有点惊讶于整个体系的成熟度一个空模板就能让你直接在浏览器里操作链上状态后面写业务pallet时每次新增一个extrinsic前端模板几乎不需要改动就能调用到。这对团队里不熟Rust的同事非常友好他们可以用前端快速验证功能。4. 手写第一个业务pallet从需求到链上逻辑落地4.1 先设计业务再写存储存证场景的建模过程业务pallet听起来高大上但落到代码层面其实就是把业务规则翻译成“存储 调用函数 事件”。我在第一个实战项目里写的是一个简单的存证pallet用户提交数据的哈希值链上保存哈希、提交账户和提交时间任何人可以验证某条数据在某个时间点已经存在过。存储设计很直接我用的是一张StorageMap#[pallet::storage] #[pallet::getter(fn evidence_digest)] pub type EvidenceStoreT: Config StorageMap_, Blake2_128Concat, Vecu8, (T::AccountId, BlockNumberForT);存储结构里digest是值账户和块号用来记录提交者和提交时间。之所以用Blake2_128Concat哈希key是因为它适合随机key分布且能提供部分key信息避免遍历时的额外成本。设计存储时一定要想清楚未来会不会按某个维度查询会不会有删除操作会不会有数据增长上限存证场景数据只增不减所以我没有做清理逻辑后续如果担心膨胀再通过链上治理加一个归档机制即可。4.2 代码落地存储、事件、错误与调用函数pallet的核心代码我尽量写得精简实际开发时可以按自己风格组织。下面是我项目里一个简化版的核心逻辑#![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::config] pub trait Config: frame_system::Config { type RuntimeEvent: IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::pallet] #[pallet::generate_store(pub(super) trait Store)] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn evidence_digest)] pub type EvidenceStoreT: Config StorageMap_, Blake2_128Concat, Vecu8, (T::AccountId, BlockNumberForT); #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { EvidenceStored { who: T::AccountId, digest: Vecu8, block: BlockNumberForT }, } #[pallet::error] pub enum ErrorT { ExistingEvidence, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn store_evidence( origin: OriginForT, digest: Vecu8, ) - DispatchResult { let who ensure_signed(origin)?; ensure!(!EvidenceStore::T::contains_key(digest), Error::T::ExistingEvidence); let block frame_system::Pallet::T::block_number(); EvidenceStore::T::insert(digest, (who.clone(), block)); Self::deposit_event(Event::EvidenceStored { who, digest, block }); Ok(()) } } }这个调用的逻辑就是标准的三步确认调用者身份、检查数据是否重复、写入存储并发出事件。事件在Substrate里不只是日志它是客户端追踪链上变化的主要途径所以业务上重要动作一定要发出事件方便前端和索引服务监听。有两点是新手容易漏的。第一是ensure_signed如果没有这一步任何人都能以无签名方式调用这个函数安全模型直接崩掉。第二是存储key的哈希方式我选的是Blake2_128Concat如果业务key本身有规律切忌用Twox64Concat那个哈希强度弱容易被人为制造碰撞。4.3 集成进Runtime并跑通测试pallet写好后要把它“插”进runtime主要改两个文件。一个是runtime/src/lib.rs需要在construct_runtime!宏里注册模块同时给pallet配置Config接口impl pallet_evidence::Config for Runtime { type RuntimeEvent RuntimeEvent; }然后是construct_runtime!宏中添加一行Evidence: pallet_evidence,编译完成后用前面提到的前端模板就能看到新增的evidence.storeEvidence调用。首次集成时如果报错多半是Config接口的类型没对齐尤其是RuntimeEvent和RuntimeOrigin这两个关联类型很多新手在这里卡住。其实它们都是编译期检查只要根据编译器提示逐项补齐就行。测试方面Substrate推荐在pallet内建单元测试用sp_io::TestExternalities模拟运行时环境。一个最简单的测试是先创建测试外部环境用new_test_ext()初始化存储然后调用store_evidence断言事件是否正确发出。写测试的过程本身也是在加深对pallet执行流程的理解。这里我多说一句不要跳过测试就急着上链。链上逻辑一旦部署很难像传统服务那样随便回滚。pallet级别的mock测试成本很低但能挡掉大部分低级错误比如存储key写错、事件类型不匹配、权限校验缺失。框架级的测试工具已经给你准备好了不用白不用。5. 链级定制与升级实操共识、创世配置和链上升级5.1 不同共识方案的取舍Aura、Babe与Grandpa怎么选Substrate的一个优势就是共识可插拔。实际开发中我遇到过三种情况正好对应三种常见选择。第一种是内部开发链或测试链验证者少、可信度高直接用Aura最省事。Aura就是轮流出块每个验证者按照名单顺序依次生产区块逻辑简单延迟低调试也很直观。第二种是公链或半去中心化链希望出块更均匀、抗恶意验证者那就用Babe作为出块引擎。Babe通过VRF随机选择出块者每个slot可能有多个候选也可能一个都没有所以通常还要配合延迟出块机制来提高稳定性。它的代码复杂度比Aura高一截但更贴近真实公链的生态预期。第三种是所有链都必须搭配的确定性终结工具Substrate里常见的是Grandpa。Babe或Aura负责“出块”Grandpa负责“最终确认”。只有Grandpa确认过的区块才不可回滚。所以一般配置是“Aura/Babe Grandpa”组合单独只跑Babe或Aura是不完整的因为会面临链分叉时无法确定哪条是规范链的问题。我在模板里看到默认配置就是Aura Grandpa作为起步非常合理。如果团队目标不是做公链完全可以只用Aura单节点甚至跑一个“许可网络”——只有通过验证的节点才能参与共识这在企业场景里很常用。5.2 创世配置与本地多节点组网节点的创世配置位于chain_spec.rs文件中。模板默认提供的是development_config()和local_testnet_config()两个函数。我一开始以为多节点组网很复杂后来发现关键是改这几项name和id链的名称和标识所有节点必须一致。chain_type设置为Local或Custom不能是Development否则验证者数量会被外部强行限制。session相关key为每个初始验证者生成Aura和Grandpa的key。endowed_accounts预置账户余额方便测试交易。用本地多节点测试时我一般先起第一个节点./target/release/node-template --chain local --validator --alice --tmp再起第二个节点并让它连接到第一个节点的p2p地址./target/release/node-template --chain local --validator --bob --tmp \ --bootnodes /ip4/127.0.0.1/tcp/30333/p2p/alice-node-id这里需要从第一个节点日志里找到真实节点ID然后替换进去。依赖操作时需要多尝试几次因为它依赖P2P握手成功后验证者集合才真正开始协作。如果日志里出现“cannot finalize”之类的报错八成是验证者key配置不全或者两个节点用的chain_spec不一致。5.3 用sudo调set_code做运行时升级这是Substrate最吸引人的能力。我用最原始的方式演示一遍先把新Runtime编译成Wasm然后通过sudo pallet的setCode调用把新代码上传到链上。操作流程大概是这样的修改pallet代码更新RuntimeVersion里的specific_version。重新执行cargo build --release生成新的Wasm二进制。在前端模板里找到sudo.sudo调用填写参数目标方法为system.setCode代码内容选择刚才生成的wasm文件通常是target/release/wbuild/node-template-runtime/node_template_runtime.compact.wasm。提交交易等待出块之后链就会执行新逻辑。我第一次做的时候提心吊胆担心万一新Runtime有bug就把链跑挂了。后来发现测试阶段真的出现了类似情况我的处理方法是保持旧节点的数据库备份必要时直接删除本地数据重新同步。链上治理早期可以给sudo设置多签任何人不能单独升级。等业务成熟后再切换到民主投票这样操作更稳。这里必须提醒不要忽略RuntimeVersion更新。很多新手升级后链直接不认账就是因为新旧版本号一模一样客户端缓存了旧Wasm拒绝重新执行。这个字段就是Substrate区分“是否更新”的关键标记。6. 跑完一轮后我留下的几条经验与避坑总结6.1 编译期最容易摔的跟头第一次编译Substrate项目我差点被自己逼疯问题几乎都集中在环境上。最大的坑是Rust nightly版本不对。Substrate的宏展开非常依赖rustc内部行为版本差异可能导致宏直接展开失败。所以一定要用仓库里rust-toolchain.toml指定的版本不要自己随手装一个。我在三台不同环境上测试过遵循指定版本是复现成功最关键的变量。第二个坑是Wasm目标没装。Runtime编译时要把Rust代码编译成Wasm这一步如果报错target wasm32-unknown-unknown not found执行rustup target add wasm32-unknown-unknown --toolchain nightly-version装完后重新编译。很多时候编译失败并不是代码问题而是这个目标缺失。第三个坑是磁盘空间。一个release构建加缓存的依赖轻轻松松吃掉20GB以上。开发机至少预留50GB不然编译到一半磁盘写满非常崩溃。6.2 运行期存储、Weight与调试手段运行期最容易出问题的一个是存储设计一个是Weight计算。存储上我见过不少新人把很长的文本直接塞进StorageValue完全不管链上存储的膨胀。链上存储的读写成本远高于内存或磁盘设计时应该尽量只存精简状态和哈希引用。大文件数据放到链下存储链上只放哈希。Weight也就是手续费和计算资源配额直接写固定值是最省事的但不合理。如果调用函数逻辑变复杂固定的低Weight可能导致交易永远无法打包或者更糟被恶意用户滥用。Substrate提供了#[pallet::weight]注解方式配合计算函数来进行复杂估算。初期不追求精确但一定要给一个合理上限后续再通过基准测试优化。调试方面我强烈建议用substrate-contracts-node和前端模板而不是只靠终端日志。前端模板能直接看到链上状态变化、事件日志、交易详情还能直接调用最新加入的extrinsic调试效率高一个量级。如果要更底层的调试试试在runtime里加log输出或用frame_support::debug打印信息。运行期的另一个坑是区块生成的“时区感”。默认Aura出块时间是6秒你跨地域跑多节点时网络延迟可能导致出块不稳定。当时我调整了MinimumPeriod和出块slot时长让链适应跨机房部署。6.3 给计划用Substrate团队的几条建议回顾整个周期我认为如果团队要正式用Substrate做业务链有几件事最好在早期就想清楚。一是不要贪多。Substrate官方的pallet非常丰富但每引入一个pallet就会增加runtime体积和治理复杂度。初期能用最小集解决问题就尽量保持精简Balances、Sudo、System加上你自己的业务pallet足够了。二是升级设计要提前。既然Substrate支持无分叉升级那业务上就要设计好“旧数据兼容策略”比如存储key变更时的迁移函数。不要等上线后再去想。三是多节点多环境测试一定要前置。我见过太多项目在开发机上跑得漂亮一上测试网就出各种共识问题。本地至少要从三节点起验证账本一致性和重启稳定性。最后对团队现有的技术栈要做个判断。Substrate开发依赖Rust和宏编程团队里至少要有两位能看懂Rust宏的成员否则后期遇到宏报错会很痛苦。如果团队是Java或Go背景也不是不能用只是要提前预留学习时间Rust的所有权模型和trait系统跟常见后端语言差异很大。如果用一句话总结我的体会就是Substrate不是一条链而是一套搭链的基础设施它把“自定义区块链”的门槛从底层协议拉低到了业务模块层。只要你能设计好业务pallet剩下的大部分脏活累活框架已经帮你扛了一半。那些在草稿里反复斟酌存储字段的夜晚在编译Wasm时等得发慌的几分钟以及终于看到自己写的pallet在浏览器前端被调用成功那一刻都是这条技术路线上很真实的记忆。如果你也在评估Substrate我的建议是别只看文档直接拿一个最小的业务场景从模板开始跑通一遍。跑通之后你自己就会知道它到底适不适合你的业务。
返回列表