☰
Substrate开发实战:15分钟写出并上链你的第一个Pallet
2026/10/7 12:31:50 网站建设 项目流程

如果你和我一样,第一次看到“链上模块”“runtime”“pallet”这些词的时候,第一反应多半是:这玩意的开发门槛到底有多高?我是不是得先把密码学、共识算法、P2P网络全部啃一遍才敢动手?说实话,我自己刚打开 Substrate 的 node-template 源码时也是一脸懵。但真正把第一个 pallet 跑通之后,我发现链上开发这件事被严重神化了。在 Polkadot / Substrate 这套体系里,写一个链上模块(pallet)本质上就是一套有规可循的框架式编程。只要抓住最小闭环,从新建文件到上链调用,15 分钟真的够用。

这篇内容不是泛泛讲概念,我会用一个完整的“点赞计数”模块为例,从环境准备、pallet 源码、runtime 接入,到本地节点上链验证,一步步带你走一遍。适合刚接触区块链开发、或者想在 Polkadot 生态里跑通第一个 demo 的开发者参考。你也可以把它当成一张地图:先整体跑通一遍,再回去补细节,效率会高很多。

1. 为什么说 pallet 开发是“搭积木”而不是“造轮子”

先搞清楚一个前提:Polkadot 本身不是一条传统意义上的单链,而是一个异构多链网络。在这个网络里,各条平行链的核心逻辑其实是用 Substrate 框架开发的。Substrate 提供了一套叫 FRAME 的模块化开发体系,pallet 就是 FRAME 下的一个独立业务模块。可以把它类比成后端开发里的一个“服务”或者“微服务”:它有自己的状态存储、自己的事件、自己的可调用函数,然后被组装进整个 runtime 里,成为链的一部分。

很多人会把链上开发想象成“从零写一条链”,这其实是一个思维误区。真正常用到的链上业务,绝大多数都可以拆成一个个 pallet。你写业务逻辑时,不需要关心区块是怎么打包的,不需要关心共识怎么跑,也不需要处理账本层的存储怎么落盘。Substrate 已经把链底层的东西全部抽象好了,你只需要按照 pallet 的标准接口,把业务状态和业务规则写进去。这个体验很像你在 Spring Boot 里写一个 Controller:路由、序列化、依赖注入框架全给你处理了,你只要写路由函数和业务代码。

FRAME 框架本身已经内置了大量官方 pallet,比如处理账户余额的pallet_balances、管理链上治理的pallet-democracy、配置共识参数的pallet-session等等。这些官方模块承担了链上最通用的基础能力。而我们自己写的业务 pallet,更像是往这套体系里挂一个新的“乐高积木块”,挂上去之后,它就能和系统里的其他模块协同工作。

为什么选 Polkadot / Substrate 来做这件事?原因很直接:Substrate 允许你用最少的代码,把一个真正能跑的业务模块接入到一条链上。你不需要先维护一套复杂的 P2P 网络,不需要设计创世区块,甚至在本地开发模式下连 token 经济模型都不用管。用官方提供的 node-template 起一条私有开发链,几分钟就能出块。这种“开箱即用”的开发体验,在区块链领域里真的算非常友好的了。

明白了这个背景,接下来就可以直接上手。我们的目标是:在现有 node-template 里新增一个pallet-likes模块,让每个账户可以执行“点赞”和“取消点赞”操作,链上保存每个账户的点赞总数。这个模块麻雀虽小,但包含了 pallet 的全部标准组成部分,弄懂它,就弄懂了 pallet 的基础骨架。

2. 环境准备:先把模板跑起来,这步最花时间

我先把话说在前头:标题里的“15 分钟”,指的是你已经装好 Rust 工具链、并且把官方模板完整编译过一次之后的增量时间。如果你是一台全新的电脑,从零装环境加首次全量编译,可能要 30 到 60 分钟,这取决于你的网速和机器性能。别指望第一次接触就能在 15 分钟内完成,这不现实。但等你熟悉了整个流程,第二次、第三次新建 pallet,15 分钟确实足够。

准备工作的第一步是安装 Rust。Substrate 开发对 Rust 的工具链版本有明确要求,官方一直推荐用rustup来管理,不要手动去装某个特定的 Rust 发行包。在终端执行:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

装完之后执行rustup show确认当前目录使用的工具链。node-template 仓库里通常会带一个rust-toolchain.toml文件,它锁定了 Substrate 所需的 Rust 版本和组件。Substrate 较新的版本已经默认使用 stable 工具链,所以一般直接rustup default stable就能满足要求。

接下来把官方模板克隆到本地:

git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template

这个模板就是一条完整的、能跑的开发链。里面包含了runtime目录(链上逻辑)、pallets目录(模板自带一个templatepallet)、node目录(节点程序)。如果你之前没接触过这个项目结构,建议先别急着到处乱翻,先执行编译:

cargo build --release

首次编译会拉取大量依赖,并且要构建 Wasm runtime,所以时间较长。我的建议是给这个步骤留出足够时间,中途不要随便 kill。如果你发现下载速度很慢,可以配置 Rust 的国内镜像源来加速 crates 拉取,或者适当增加并发下载的 onejob 数。编译成功之后,启动本地开发链验证一下:

./target/release/node-template --dev

看到终端滚动出区块生产日志,说明环境已经通了。--dev模式会使用一个临时存储目录,适合快速测试,重启之后数据默认会重置。所以如果后面你想验证“数据真的存在链上”,记得先确认启动参数,别在重启后误以为代码丢了数据。

环境这部分其实没什么技术含量,但它决定了后面所有步骤能否顺利推进。很多新手卡在这里并不是因为不会写代码,而是 Rust 工具链和 Substrate 版本不匹配导致各种莫名其妙的编译错误。所以我的经验是:不要自己去追最新版 Rust,以项目里的rust-toolchain.toml为准,这是最省心的做法。

3. 写一个真正能跑的点赞 pallet:代码逐段拆解

环境跑通之后,开始写我们自己的模块。先规划一下文件结构:在pallets/目录下新建likes/src目录,对应完整的 pallet 源码结构。

mkdir -p pallets/likes/src

然后建一个Cargo.toml文件:

[package] name = "pallet-likes" version = "0.1.0" edition = "2021" [dependencies] frame-support = { version = "4.0.0-dev", default-features = false, git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v1.0.0" } frame-system = { version = "4.0.0-dev", default-features = false, git = "https://github.com/paritytech/substrate.git", branch = "polkadot-v1.0.0" } parity-scale-codec = { version = "3.0.0", default-features = false, features = ["derive"] } scale-info = { version = "2.0.0", default-features = false, features = ["derive"] } [features] default = ["std"] std = [ "frame-support/std", "frame-system/std", "parity-scale-codec/std", "scale-info/std", ]

这里有几个点需要解释一下。frame-support和frame-system是写 pallet 最少需要依赖的两个 crate:前者提供#[pallet]这个核心宏,后者提供Origin、AccountId等系统级类型。parity-scale-codec负责存储数据的序列化,scale-info负责向链外暴露类型元数据。features里的std开关也很关键,因为 Substrate runtime 需要编译成两种形态:带标准库的用于原生执行,不带标准库的(no_std)用于 Wasm 环境。如果你漏配了stdfeature,编译时会遇到莫名其妙的链接错误。

接下来是核心文件src/lib.rs。为了保证代码可以完整编译,我用的是 Substrate 比较通用的 polkadot-v1.0.0 风格写法:

#![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 Pallet<T>(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>; } // 存储:每个账户对应的点赞总数 #[pallet::storage] #[pallet::getter(fn likes_count)] pub type Likes<T: Config> = StorageMap< _, Blake2_128Concat, T::AccountId, u64, ValueQuery, >; // 事件:链上执行成功后会触发的记录 #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { Liked { who: T::AccountId, count: u64 }, Unliked { who: T::AccountId, count: u64 }, } // 错误:业务校验失败时返回的错误类型 #[pallet::error] pub enum Error<T> { NoLikeToCancel, } // 可调用函数:extrinsics #[pallet::call] impl<T: Config> Pallet<T> { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn like(origin: OriginFor<T>) -> DispatchResult { let who = ensure_signed(origin)?; let current = Likes::<T>::get(&who); let next = current.saturating_add(1); Likes::<T>::insert(&who, next); Self::deposit_event(Event::Liked { who, count: next }); Ok(()) } #[pallet::call_index(1)] #[pallet::weight(10_000)] pub fn unlike(origin: OriginFor<T>) -> DispatchResult { let who = ensure_signed(origin)?; let current = Likes::<T>::get(&who); ensure!(current > 0, Error::<T>::NoLikeToCancel); let next = current - 1; Likes::<T>::insert(&who, next); Self::deposit_event(Event::Unliked { who, count: next }); Ok(()) } } }

这段代码虽然不长,但是 pallet 的五个核心组成部分全部覆盖到了。我们逐个拆开看。

Configtrait 是整个 pallet 和 runtime 之间的桥梁。每一个 pallet 都需要声明自己依赖哪些系统能力,最基础的就是frame_system::Config。这里还定义了一个关联类型RuntimeEvent,它的作用是让 pallet 在触发事件时,能把事件统一转换成 runtime 层面的RuntimeEvent。新手最容易忽略的就是这个关联类型,写自定义事件时如果不加这一行,大概率会报类型不匹配的错误。

Likes是链上的状态存储。这里的声明方式是声明一个StorageMap,键是账户地址T::AccountId,值是一个u64计数。ValueQuery表示当键不存在时,直接返回该类型的默认值 0,而不是返回None。这对点赞计数来说很合适:一个从未点过赞的账户,它的计数自然应该当成 0。如果想要“未赞过”和“赞过但计数为 0”有区分,那就得改用OptionQuery,两者的语义是有差别的,后面我会再提。

Event和Error是 pallet 对外暴露的结果反馈机制。事件会被记录进区块里,链下可以通过查询事件来感知链上发生了什么;错误则会在交易执行失败时被回滚掉,调用方会拿到对应的错误码。在unlike函数里,我们刻意用ensure!构造了一个错误路径:当计数已经是 0 时,再取消点赞就要拒绝执行。这样不仅演示了 Error 的用法,也给你留了点自己扩展逻辑的空间。

两个可调用函数like和unlike就是真正的链上交易入口,也就是 Substrate 里的 extrinsic。ensure_signed(origin)用于确认调用者是一个真实签名账户,然后拿到调用者身份who。之后就是常规的“读-改-写”流程:读取当前计数,加一或减一,写入存储,触发事件。#[pallet::weight(10_000)]表示这个函数消耗的权重,在 demo 里给个常量即可;真实项目需要通过 benchmark 来测定实际权重,不然会引发链上的资源计价问题。

这个 pallet 的完整骨架已经在手上了。你可以试着把它改造成其他业务,比如把“点赞计数”换成“每日签到次数”“投票记录”甚至是“链上任务进度”。核心开发模式都是一样的:定义存储、定义事件和错误、定义可调用函数,剩下的交给 FRAME。

4. 三步接入 runtime:Cargo 配置、Config 实现、construct_runtime

pallet 写完之后,它本身还只是一个孤立的库,不会出现在链上。要让这条链真正识别并运行pallet-likes,必须在 runtime 里把它“挂载”进去。这个过程一共有三步,每一步都有坑,但都不难。

第一步,在runtime/Cargo.toml里注册这个 pallet。打开文件,在[dependencies]区域添加:

pallet-likes = { path = "../pallets/likes", default-features = false, version = "0.1.0" }

同时在[features]的std列表里加上:

pallet-likes/std

这一步不能省。如果不加pallet-likes/std,当 runtime 以原生模式编译时,这个 pallet 仍然会以no_std的方式编译,最终会引发一堆“某 trait 未被实现”之类的奇怪错误。这算是外部 pallet 接入 runtime 时最经典的遗漏点。

第二步,在runtime/src/lib.rs中实现这个 pallet 的Configtrait。一般建议写在新出的模块声明区域后面,代码如下:

impl pallet_likes::Config for Runtime { type RuntimeEvent = RuntimeEvent; }

因为我们这个 pallet 只定义了一个关联类型,所以这里的实现非常简短。如果你的 pallet 里有type WeightInfo、type Currency之类的关联类型,也需要在这里一一指定。

第三步,在construct_runtime!宏里注册模块。在该宏的模块列表中,找到比如TemplatePallet那一段,在它下面加一行:

Likes: pallet_likes,

Likes是这条 runtime 内部的模块名,pallet_likes是 crate 名。这里要注意,construct_runtime!宏实际上是生成了一堆对应的类型和枚举,所以模块名必须是合法的 Rust 标识符,并且不能和已有模块重名。有些教程里会写成PalletLikes: pallet_likes,这样也是可以的,只是后续在链上看到的模块名会不一样。

完成这三步后,重新编译:

cargo build --release

因为只新增了一个模块,增量编译通常只需要几分钟。等编译结束后,再启动本地节点:

./target/release/node-template --dev

如果编译过程没报错,说明你的 pallet 已经被成功“焊”进 runtime 了。此时链的 metadata 里已经包含likes模块,这是后面上链交互的基础。特别提醒一点:如果修改了 pallet 代码,必须重新编译并重启节点,链上才会加载最新的逻辑;光刷新浏览器界面是没有用的。

5. 上链验证:启动本地节点,用 polkadot.js 提交第一次链上交易

模块接入 runtime 之后,最让人兴奋的部分来了:真正把一笔交易发到链上,亲眼看到自己的模块在跑。这里我不会选择用复杂的前端工程,直接使用 polkadot.js apps 的在线界面连接本地节点,最快也最直观。

先确保本地开发链还在运行。然后在浏览器里打开:

https://polkadot.js.org/apps/#/?rpc=ws://127.0.0.1:9944

这个地址的意思是让前端界面连接本地节点的 WebSocket RPC 端口9944。如果页面左上角显示已经连上了本地节点,并且区块高度在持续增长,就可以进行交互了。

第一步,进入“开发者 - 交易”页面(Developer -> Extrinsics)。在“提交外部交易”的下拉菜单里,你会看到刚才在construct_runtime!里注册的likes模块。选择它之后,下方会出现两个可调用函数:like和unlike。选择like,点击“提交交易”,然后签名并广播。这个过程会用到你的开发账户,node-template 的--dev模式默认预置了一批带余额的测试账户,选第一个即可,不需要自己额外配置。

交易打包进块之后,注意看“事件”列表。里面会出现我们自定义的事件,格式类似likes.Liked,并且带上了who和count字段。看到这个事件,说明like函数确实执行成功了,而且事件确实被记录进了区块。这是对你刚才写的代码最直接的反馈。

接下来到“链状态”页面(Developer -> Chain State)。在模块下拉菜单里选择likes,然后选择存储条目likesCount。此时会列出每个账户对应当前的点赞计数。按 F5 多刷新几次,你应该能看到刚才操作过的账户数量变成了 1。如果切换到unlike再提交一次,计数会减回到 0,这就完成了一个完整的“读-改-写-再读”闭环。

到这里,你已经完成了人生中第一次真正意义上的链上模块交互。它不是模拟,不是本地函数调用,而是经过签名、打包、执行、落盘、出块完整流程的链上交易。说实话,我第一次跑通这个流程的时候,特意去翻了浏览器里那个区块的事件列表,确认自己的事件真的写进去了,那种成就感是写普通后端接口给不了的。

把时间账算一下:翻开 pallet 模板、改代码大概 4 分钟,配置 runtime 大概 2 分钟,增量编译 3 到 5 分钟,UI 操作验证 2 分钟。如果已经准备好环境,15 分钟完成一个“从零到链上验证”的模块,这个说法是站得住脚的。

6. 新手最容易踩的三个坑:编译、Storage 和命名

流程走完了,我想把实际开发过程中踩过的坑也一并放出来。这些坑在文档里很难查得到,遇到了才会知道有多疼。

第一个坑是 Rust 工具链和 Substrate 版本不匹配。Substrate 对版本相当敏感,你用 stable 能编译的代码,切到 nightly 可能突然报出一堆陌生的错误。反过来也一样。最稳妥的做法是始终检查项目根目录的rust-toolchain.toml,让rustup自动切换到指定版本。不要手痒去更新全局工具链,特别是不要把项目目录里的rust-toolchain.toml随手删掉。我见过有人就是因为全局 nightly 版本太新,导致frame_support宏展开时报错,整整折腾了一个晚上才定位到是工具链的问题。

第二个坑是编译时内存不足和 Wasm target 缺失。首次构建 Substrate 项目时,需要编译一个 Wasm 版本的 runtime,这一步对机器内存有一定要求。如果编译过程中直接报内存不足的错误,可以适当减少并行任务数,用下面这个命令试试:

CARGO_BUILD_JOBS=4 cargo build --release

如果报的是找不到wasm32-unknown-unknowntarget,那就先手动装一下:

rustup target add wasm32-unknown-unknown

另外有些 Rust 组件比如rust-src也是必要的,开发环境装齐了之后,绝大多数“莫名其妙”的编译错误都会消失。

第三个坑是关于StorageMap的 Query 语义和 hasher 选择。我在前面代码里用的是ValueQuery,这意味着任何账户即使没有写入过数据,读取时也会拿到默认值 0。这在某些业务场景下会掩盖“到底插入过没有”这个信息。如果你希望区分“零值”和“无值”,就必须换成OptionQuery。这个决定会影响后面所有业务逻辑的写法,一开始就要想清楚,不要写到后来再改,因为牵一发而动全身。至于 hasher,示例里用的是Blake2_128Concat,这是 Substrate 里偏安全的默认选择。不要因为简单就换成Twox64Concat,那会导致存储键的可预测性增强,在部分敏感场景下有安全风险。

最后一个细节也许不算坑,但很多人会忽略:你修改了 pallet 代码之后,必须重新编译并重启节点,链上的 metadata 才会更新。如果你在 UI 里找不到新加的函数或存储,大概率不是前端的问题,而是节点没重启,或者编译时发生了静默失败。先cargo build --release确认成功,再重启节点,基本都能解决。

以我自己的开发习惯为例,现在每跑一个新人上手 Substrate,我都建议先照着“模板 pallet -> 改名字 -> 改一个存储 -> 改一个调用函数 -> 重新编译 -> 上链验证”这个循环走三遍。第一遍是熟悉,第二遍是理解,第三遍才能谈得上独立设计。这个流程看着简单,但它建立的不是“能跑”的幻觉,而是一条完整的、可以反复执行的开发链路。之后无论是接pallet_balances做积分转账,还是接pallet_timestamp做时间窗口任务,你会发现底层思路都是这套:定义存储、定义事件、写调用函数、接入 runtime、上链验证。

如果你第一次照着文章跑完,发现自己的模块没出块事件,不要急着怀疑人生,先对照一下看看是不是已经在编译期就把错误跳过了。比如确认unlike的ensure!是不是被误写成了反逻辑,确认Event里的事件是不是真的在函数末尾被deposit_event触发。调试这一类问题时,我的土办法是在函数里临时加一个不触发任何存储变更的log调用,配合节点日志看执行流走到哪一步了。等你处理完这些问题,再回头看这 15 分钟,你会发现自己已经掌握了一套完全可复现的链上开发骨架。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询