拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Substrate开发框架核心拆解:从Runtime到pallet的应用链实战

Substrate开发框架核心拆解:从Runtime到pallet的应用链实战 1. Substrate到底是什么——先把它放回区块链开发的历史坐标里看提到substrate这个词外行可能想到生物实验里的酶底物或者材料学里的基材。但在区块链开发圈尤其是波卡生态里Substrate指的是Parity Technologies开源的区块链开发框架——它就是波卡Polkadot网络底层的构建工具也是无数独立公链、联盟链、应用链的起点。我第一次接触Substrate是在2019年底当时市面上主流的做法还是复制一份比特币代码改改参数或者用以太坊Solidity写合约然后部署上去。这两条路各有各的痛点前者改了共识改不了出块逻辑改了出块逻辑又动了挖矿激励牵一发而动全身后者虽然省事但业务逻辑被锁死在 EVM 的框架里想做一个自定义的Gas机制、自定义的账本模型、甚至自定义的节点间通信协议几乎等于要跟整个以太坊生态的默认设置对抗。Substrate的定位很直接它不是一个链也不只是一个合约平台而是一整套用来造链的框架。它把一条区块链最底层的那些组件——共识引擎、网络层、存储层、Runtime执行环境、账户系统、质押系统、治理机制——全部模块化开发者可以像搭积木一样选择需要的部分替换掉不需要的部分然后编译出一条拥有独立生命周期的链。所以这篇文章写给谁看如果你是一个想从零做一条链的开发者或者你在研究波卡生态、想搞懂为什么那么多项目团队选择用Substrate而不是从零开发又或者你已经看过Substrate文档但被Runtime、FRAME、pallet这些概念绕晕了那这篇文章应该能帮你把整个框架的脉络捋清楚。2. 核心抽象层拆解Runtime、FRAME、pallet到底在解决什么问题想理解Substrate不能一头扎进代码里得先理解它的分层逻辑。很多人第一次看Substrate文档会懵就是因为它的抽象层太多了Client、Runtime、FRAME、pallet、Node、Primitive……每个名词单独看都懂合在一起就不知道谁负责什么了。2.1 Client和Runtime的边界是整个设计的灵魂简单说Substrate把一条链分成两半Client客户端和Runtime运行时。Client是链的骨架负责网络通信、交易池、共识算法、数据库存储这些不会频繁变动的底层能力。这套代码是编译成原生机器码直接跑在节点上的跟链的具体业务逻辑无关。你可以把它理解成操作系统的内核——它管理着硬件资源和进程调度但它不知道你的应用程序具体在干什么。Runtime则是链的大脑它定义了状态转换函数给定一个当前状态和一个交易链应该如何更新状态。账户余额怎么变、存储怎么读写、交易费怎么算、有什么治理投票规则全部在Runtime里定义。最关键的一点是Runtime被编译成WasmWebAssembly字节码存在链上。这一下子把整条链变成了可自我升级的形态。因为Runtime在链上所以链可以通过一次普通的交易或治理投票把新的Wasm代码写入链上存储节点会自动加载新版本。这就是Substrate常说的forkless upgrade无分叉升级。传统链比如比特币要升级共识规则基本等于硬分叉而Substrate链的升级就像手机系统在线更新不需要强制所有节点换客户端。我当时第一次跑通链上升级时确实被这个机制惊艳到了——你在链上提交一个包含新Wasm的runtime升级交易等待执行完成后链就换了新的业务逻辑而节点进程连重启都不用。2.2 FRAME是积木盒pallet是积木块Runtime里具体的业务逻辑Substrate又包了一层叫FRAMEFramework for Runtime Aggregation of Modular Entities的开发框架。FRAME把Runtime的功能切分成一个个叫pallet的模块每个pallet是一块独立业务能力的集合。举个例子FRAME里默认提供了一堆官方积木pallet_balances处理账户余额和转账。pallet_stakingPOS质押挖矿逻辑。pallet_scheduler定时任务调度的原语治理投票结束后延迟执行就是用这个实现的。pallet_elections_phragmen基于Phragmén方法的理事会选举。pallet_democracy公投、提案投票的治理模块。pallet_contracts在链上运行Wasm智能合约。pallet_treasury国库资金池管理。这些pallet像乐高积木一样你可以按需拼装。如果官方积木不够用就写自己的pallet。Substrate的开发体验从某种程度来说就是写pallet的体验。我当时做的一个教训是不要一开始就追求自定义pallet先用标准pallet把链跑通再去替换。因为标准pallet之间的兼容性已经被官方测试得足够充分了而自定义pallet需要自己处理与存储、事件、错误的交互细节调试成本完全不一样。2.3 一组关键概念掌握了它们你就能读懂大半的Substrate代码Externalities外部环境Runtime本身是个纯逻辑状态机它不能直接访问数据库或节点环境。所有读写链上状态的请求都通过Externalities trait接口转发给Client层实现。这段设计让Runtime保持纯净、可测试因为你在测试时可以把真实的数据库替换成一个内存Map。Dispatch调度一笔交易进入Runtime后会被分发给对应的pallet里的dispatch方法。每个pallet的调用入口都遵循统一的Call枚举类型。这笔分发的过程就是状态转换的核心路径。Storage存储Substrate的链上状态存储在一个基于Trie的结构里键值对可以通过pallet的存储声明宏生成读写接口。所有存储项的key都带pallet的前缀比如Balances Account对应的存储key会包括pallet名和存储项名的哈希。Event事件pallet执行完成后可以发事件相当于链上的日志。客户端和前端可以通过订阅事件来感知链状态变化。Error错误pallet可以定义错误类型执行失败时返回错误信息同时保证状态回滚到交易执行前的状态不会产生脏数据。理解了这几样东西你再去看Substrate的文档和开源代码基本就不会迷路了。3. 从零跑通一条自定义链环境准备到第一个pallet落地的完整过程理论讲再多不如亲手跑一遍。我带你把用Substrate做一条带自定义业务逻辑的链从头到尾走一遍过程中会标注哪些坑是我实际踩过的。3.1 环境准备Rust工具链与编译耗时Substrate的开发语言是Rust所以你首先需要一个正常的Rust环境。安装rustup之后要确认一下toolchain版本。Substrate目前需要stable版本的Rust工具链某些epoch版本甚至需要nightly但建议直接用官方模板指定的版本别自己折腾。rustup update stable rustup target add wasm32-unknown-unknown --toolchain stable这里有个关键点Runtime要被编译成Wasm所以必须安装wasm32-unknown-unknown目标。如果不装这个目标编译时会报一个找不到目标的错误这是新手最常见的问题之一。当时我还在一个老教程指引下装了nightly toolchain结果编译到一半发现代码不兼容白白等了半小时编译。然后用官方模板初始化项目cargo install --force cargo-substrate cargo substrate new my-chain cd my-chain如果你不想装cargo-substrate也可以直接git clonesubstrate-node-template仓库。注意模板的仓库名不同但结构基本一致。cargo substrate new其实是官方推荐的快速创建方式它可以让你选择是创建节点模板、pallet模板还是runtime模板。第一次编译会非常久——真的非常久取决于你的机器性能可能要十几分钟到半小时。因为要编译几百个crate包括rocksdb、libp2p网络栈、webassembly运行时等。所以第一件事建议是cargo build --release然后把编译时间用来做别的事。不要用debug模式跑节点测试因为Wasm执行在debug模式下的性能会拖慢很多跑着跑着你可能误以为代码有bug。3.2 目录结构先搞清楚哪是头哪是脚模板编译好之后看一下项目结构重点关注这些目录pallets/ template/ src/ lib.rs runtime/ src/ lib.rs node/ src/ chain_spec.rs command.rs rpc.rs service.rspallets/template是官方自带的一个示例pallet大多数自定义逻辑从这里起步。runtime/src/lib.rs是Runtime的聚合地所有pallet都要在这里注册construct_runtime!宏里列出所有pallet。node/src/chain_spec.rs是链的创世配置可以指定初始账户、初始余额、共识参数等。node/src/service.rs是节点服务装配层通常不需要动但如果你想自定义RPC接口就要在这里改。3.3 核心操作一写一个自定义pallet我举一个最简单的例子做一个记录用户签到天数的pallet。这个场景短小、直观能够覆盖存储、事件、错误、调用四要素非常适合练手。在pallets/template/src/lib.rs里用#[frame_support::pallet]声明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: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::pallet] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn checkin_days)] pub type CheckinDaysT: Config StorageMap_, Blake2_128Concat, T::AccountId, u32, ValueQuery; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { CheckedIn { who: T::AccountId, days: u32 }, } #[pallet::error] pub enum ErrorT { AlreadyCheckedInToday, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn check_in(origin: OriginForT) - DispatchResult { let who ensure_signed(origin)?; let current frame_system::pallet::Pallet::T::block_number(); // 这里简化处理用上次签到的天数存储做校验实际生产应记录最近签到区块 let prev_block LastCheckinBlock::T::get(who); if current prev_block { return Err(Error::T::AlreadyCheckedInToday.into()); } LastCheckinBlock::T::insert(who, current); let old_days CheckinDays::T::get(who); let new_days old_days.saturating_add(1); CheckinDays::T::insert(who, new_days); Self::deposit_event(Event::CheckedIn { who, days: new_days }); Ok(()) } } }同时需要在pallet里再声明一个存储项LastCheckinBlockT用来记录上次签到的区块号。这里我用saturating_add而不是就是为了防止天数溢出导致panic。在Substrate的Runtime代码里能用saturating操作就不要用普通算术这是写pallet的基本素养之一。Runtime代码panic会导致整个区块执行失败而saturating操作只会在溢出时顶格返回安全得多。3.4 核心操作二把pallet注册进Runtime写完后不是马上就生效了还得去runtime/src/lib.rs里做三件事引入pallet的crate依赖。模板的Cargo.toml已经默认引入了pallet-template但如果你新增了别的pallet需要手动加依赖。实现pallet_template::Configtraitimpl pallet_template::Config for Runtime { type RuntimeEvent RuntimeEvent; }在construct_runtime!宏里注册palletconstruct_runtime!( pub struct Runtime { System: frame_system, Timestamp: pallet_timestamp, Balances: pallet_balances, TemplatePallet: pallet_template, } );这里每个pallet的命名格式是名称: crate路径名称会作为存储前缀的一部分所以在改pallet名字时要考虑储存key的变化。我建议pallet名保持和crate路径一致避免在升级后出现存储不匹配的问题。改完之后重新编译cargo build --release如果没报错把你自己的链跑起来./target/release/node-template --dev--dev模式会使用开发配置自动给你预置一些账户和余额跳过挖矿等待。浏览器打开https://polkadot.js.org/apps/切换到你本地链的WebSocket地址默认ws://localhost:9944就能在开发者-交易页面里调用templatePallet.checkIn了。调用后可以在事件里看到CheckedIn事件并可以在链状态里查询对应的签到天数。这个跑通的过程看起来很简单吧但真正第一次操作的时候编译错误是必然的。接下来我专门写一节踩坑记录把可能让你卡半天的几个问题提前暴露出来。4. 实际开发中逃不掉的坑从卡编译到链上存储迁移的排查链路这一段是我最想分享的内容因为文档和教程很少会告诉你这些。我在做Substrate开发时遇到的坑基本可以归结为四类编译期问题、运行时逻辑问题、存储升级问题、节点工程化问题。4.1 编译期的老朋友版本锁定与Rust工具链冲突Substrate生态的更新非常频繁几乎每周都有依赖版本的推进。如果你把一个几个月前的项目在新环境上编译大概率会遇到cargo update后API变了或者Rust toolchain版本变了导致frame_support宏展开报错。这里的排查链要a从三个角度走第一步确认rust toolchain版本。去rust-toolchain.toml文件里看项目要求的具体版本或channel。官方模板会锁定一个stable版本但很多第三方项目用的是nightly。可以用rustup show查看当前生效的toolchain。第二步锁定Cargo.lock。无论是新项目还是旧项目不要把Cargo.lock放进.gitignore。Substrate的依赖树极其庞大锁定文件一旦丢失cargo build会重新解析所有依赖版本轻则编译时间暴涨重则直接编译失败。我在一台新机器上clone了自家仓库因为CI里没保留Cargo.lock结果解析出来的依赖版本和开发机不一致折腾了一天才把编译跑通。第三步用cargo tree检查重复依赖。如果你自己的pallet和runtime用了不同版本的同名依赖编译会报duplicate crate或者类型不匹配。最常见的场景是你在pallet里引入了某个库的0.7版本而runtime的其他pallet用的是0.8版本两边定义的同一个类型在Rust看来是两种完全不同的类型相互转换就会报错。4.2 运行时的经典逻辑坑Runtime升级后的存储兼容问题Substrate允许无分叉升级这非常强大但也带来了一个没人告诉你的风险旧存储数据和新代码逻辑不兼容。很多人升级Runtime后以为万事大吉结果链上的数据和新的pallet逻辑对不上轻则读取异常重则链直接无法出块。举个例子你在v1版本里用StorageValueu32存储一个版本号后来想着要保存多个版本的信息就把存储结构改成StorageMapu32, Vecu8。如果你直接发布v2链上v1版本的存储值还在旧key下新key下一片空白读取结果和预期完全不一致。这不是Substrate的bug而是升级时存储迁移没有处理好。Substrate为此提供了OnRuntimeUpgradetrait你可以在代码里实现存储迁移逻辑在Runtime升级执行完成后、业务逻辑正式使用新存储之前把旧数据转换成新结构。我的建议是每一次Runtime升级先写存储迁移测试再发升级提案。千万别图省事。一个没有迁移的升级就像把一栋房子的承重墙拆了但没换新的表面看不出问题住久了会塌。4.3 另一个隐藏陷阱pallet的存储前缀和hasher选择Substrate的每个存储项有独立key前缀一部分来源于pallet名字一部分来源于存储名本身还有一部分来源于你的hasher选择。StorageMap需要一个hasher参数可选的是Blake2_128Concat、Twox64Concat等。选型取决于key是否敏感如果key是账户地址、区块号、余额这类数据建议用Blake2_128Concat因为Twox64Concat的哈希较容易被构造碰撞被攻击者构造出能覆盖他人存储项的key就麻烦了。如果key是自增序号、有界枚举这类内部安全值用Twox64Concat可以节省一些计算量性能更好。但是请注意在已经上线运行后再换hasher存储key会全部变化等于清空链上数据。所以hasher选择必须在第一版发布前定好。这个问题我见过不止一个项目在testnet阶段踩坑他们改了hasher之后发现所有旧状态都读不到了。4.4 节点工程化磁盘空间与RPC性能的实际观察链跑起来容易跑稳了难。我在跑一个长期运行的Substrate测试网节点时发现存储增长的速率比想象中快得多。一开始我以为只是普通交易数据后来用du -sh一看rocksdb目录占了几个GB。这跟区块中存放的所有交易、事件、历史存储变更都有关系。如果你运行节点时不需要历史状态回溯可以把--pruning参数设为archive或transaction。默认的配置会保留一定量的历史状态但不会保留完整的历史状态快照。需要做链上数据分析、或者跑一些需要历史状态的工具时记得全节点模式要选对./target/release/node-template --dev --pruning archive另外RPC层的性能也要注意。Substrate默认的RPC大多走的是state_getStorage、state_getMetadata这类标准接口。如果你做一个前端应用频繁轮询某个pallet的存储会给节点造成不小的压力。更优雅的做法是使用链下工作机Off-chain Workers把数据聚合后写到一个外部数据库前端直接查询外部服务而不是每个请求都砸给RPC。这一段的经验总结起来就是Substrate的上手门槛不在写代码而在对链是分布式状态机这件事的敬畏程度。每个状态变更都是终局的出错了只能在下一版本修复而不能像传统后端那样直接改数据库。5. 关于是否选Substrate的冷静思考什么时候它是对的答案很多开发者一听说Substrate能做应用链就产生了万能论的错觉。我在这里说几句得罪人的话。5.1 选Substrate的典型场景如果你要做的业务需要下面任意一条Substrate会是一个非常顺手的选择需要一条独立的链有自定义的共识规则、费用模型或存储逻辑而现有公链无法满足。业务需要与其他Substrate链或波卡中继链交互通过XCMP跨链消息传递实现跨链资产转移或共享安全性。希望保留无分叉升级能力不想每次改业务逻辑都要硬分叉。需要在链上实现复杂的调度、治理、质押等原语——FRAME里已经有一堆经过审计的成熟pallet直接拿来用比自己从零搓要稳得多。如果你只是做一个简单的ERC20代币项目或者绝大多数逻辑都可以用智能合约实现我会劝你谨慎考虑Substrate。因为运营一条链的含义远大于部署几个合约你要处理节点运维、区块浏览器、RPC基础设施、社区运行的验证人节点、安全审计这些成本是合约项目的数倍。5.2 和其他方案做一个朴素对比我整理了一张表方便你在画架构图的时候参考方案业务逻辑自由度开发门槛链上资源成本升级方式适合场景Substrate应用链高高中自己承担链的成本无分叉升级需要定制共识、存储、治理逻辑的项目智能合约EVM低低低支付手续费即可部署新合约逻辑不可变标准代币、交易所、DeFi类项目传统中心化后端多签极高低低中心化随意改不需要去信任化的应用这个表不是绝对正确但能帮你快速判断。Substrate不是更好的区块链开发方式它只是另一种方式一种让你拥有更多控制权、同时背负更多运维责任的方式。选择它之前先问问自己是否有精力持续维护节点、处理共识层面的异常、以及跟随上游版本的节奏。5.3 生态现状与学习资源的一句话总结目前Substrate生态的核心引擎依然是波卡及其平行链但也有一批独立的联盟链和私有链基于它构建。常见的学习资源包括Substrate官方文档docs.substrate.io内容更新频率很高但概念密集适合查证不适合通读。Parity的substrate-node-template和substrate-front-end-template是起步最快的组合。Polkadot的polkadot-sdk仓库原substrate仓库已更名合并代码里有很多pallet的参考实现。社区里不少团队把pallet仓库开源了比如Acala、Moonbeam、Bifrost的代码都很值得读。我个人建议的学习路径是先跑通node-template然后照着pallet-templete改一个自己的pallet接着尝试把两个pallet之间的交互打通最后再读polkadot-sdk里最核心的frame_support源码理解宏展开背后的设计逻辑。6. 这一段是实操后的醒悟Substrate真正考验人的不是技术最后聊一点感想。写Substrate代码这件事技术本身只是第一步真正的考验在于你能不能适应链的不可变性和可升级性之间的张力。一方面链上的数据是公开的、不可篡改的用户一旦把你的合约地址或业务逻辑当真你就对它的安全性负全部责任。另一方面Substrate又给了你无分叉升级的能力这意味着你总是有一个修复错误的逃生口。但这个逃生口是有代价的——每升级一次你就需要给用户、给节点运营者、给生态里的所有参与者一个交代。我见过一些团队把Substrate当作可以随意改链上逻辑的工具频繁升级、频繁改存储结构结果社区信任度急剧下降。也见过一些团队因为过于保守明明可以安全地升级改进却因为害怕出现问题而固守着旧版本功能迭代极其缓慢。要在两者之间找到平衡那才是真正的工程能力。在我个人的实操体验里最稳妥的做法是把每次Runtime升级当成一次产品发布来对待。写迁移脚本、写测试、先在dev链上验证、再在testnet上验证、最后才提交到主网的治理流程。这个过程虽然繁琐但它能让你在真正出问题的时候有兜底方案而不是靠运气出块。这些年用Substrate的经历让我学会了一件事让链上的每一个状态变更都足够简单、足够可解释。复杂逻辑堆到链上只会让调试变成灾难让用户变成小白鼠。如果你准备开始自己的第一条Substrate链我希望你从最小可用的pallet开始一点一点扩展而不是一上来就构思一个充斥几十个pallet的宏大架构。链这种东西活得久远比走得快更重要。
返回列表