跳到主要内容

开发自定义的功能模块

通过本节,你会学到:

  • 如何初始化一个区块链节点
  • 如何与节点交互
  • 功能模块的组成
  • 如何添加新的功能模块

预备

  1. 快速安装Substrate依赖(如已安装,请跳过),详细内容参考开发者中心文档Installing Substrate
curl https://getsubstrate.io -sSf | bash -s -- --fast
  1. 更新substrate-up脚本,它提供了初始化节点、创建新模块等功能:
git clone https://github.com/paritytech/substrate-up
cd substrate-up
cp -a substrate-* ~/.cargo/bin
cp -a polkadot-* ~/.cargo/bin

创建区块链节点

作为一个通用的区块链开发框架,Substrate提供了用于构建区块链的所有组件,开发者要做的只是将需要的组件组装起来。为了帮助开发者从繁杂的组装工作中解放出来,Substrate提供了两类的节点程序来快速实现组装工作:

  • Template Node: 包含了所需用到的最少组件,但是依然具备完善的区块链功能。可以在其上快速开发应用,添加新的功能模块。
  • Node: 基本上包含了Substrate提供的所有组件,让你能够测试内置的各种功能。

这里所说的节点通常也被称为点对点节点或者全节点,承载了区块链的所有功能,你可以把它想象成传统互联网开发中的后端,但是没有放在中心化的服务器上,而是散落在世界的各个角落里。

本文我们将会用Template Node作为我们的节点程序,承载我们的抛硬币游戏。

初始化节点

substrate-up脚本提供的初始化节点命令是substrate-node-new,通过下载和编译Template Node来生成我们的节点程序。运行下面的命令来生成节点,替换demo-node为你自己的节点名,替换yourname为你的团队或个人名字:

substrate-node-new demo-node yourname

启动刚刚生成的节点:

cd demo-node
./target/release/demo-node --dev

如果在控制台看到这些内容,证明你的节点创建成功:

2019-07-27 18:03:45 Substrate Node
2019-07-27 18:03:45 version 1.0.0-2857a44-x86_64-macos
2019-07-27 18:03:45 by demo-author, 2017, 2018
2019-07-27 18:03:45 Chain specification: Development
2019-07-27 18:03:45 Node name: safe-tin-6167
2019-07-27 18:03:45 Roles: AUTHORITY
2019-07-27 18:03:45 Initializing Genesis block/state (state: 0x79b0…3c01, header-hash: 0xacb5…bb17)
2019-07-27 18:03:45 Loaded block-time = 10 seconds from genesis on first-launch
2019-07-27 18:03:45 Best block: #0
2019-07-27 18:03:45 Using default protocol ID "sup" because none is configured in the chain specs
2019-07-27 18:03:45 Local node identity is: QmZH4oHKH4nwaP4apeYCM7EJXkxAjv4AqnJt29MrMNhWBV
2019-07-27 18:03:45 Libp2p => Random Kademlia query has yielded empty results
2019-07-27 18:03:46 Listening for new connections on 127.0.0.1:9944.
2019-07-27 18:03:46 Using authority key 5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu
2019-07-27 18:03:48 Libp2p => Random Kademlia query has yielded empty results
2019-07-27 18:03:49 Accepted a new tcp connection from 127.0.0.1:62636.
2019-07-27 18:03:50 Starting consensus session on top of parent 0xacb55b52944dff23e2aa99326cc20b1f9c091556516d15db9ffcffd7d159bb17
2019-07-27 18:03:50 Prepared block for proposing at 1 [hash: 0x2d84be81477309b475af22c457f850174c498d1b0d19032f18fe7f7656233dad; parent_ha
sh: 0xacb5…bb17; extrinsics: [0xb1d4…9362]]
2019-07-27 18:03:50 Pre-sealed block for proposal at 1. Hash now 0x1d70dc9d4299519880cc5824cee49ffa0c5a74ec5a9bb238012ae5ff65055302, previ
ously 0x2d84be81477309b475af22c457f850174c498d1b0d19032f18fe7f7656233dad.
2019-07-27 18:03:50 Imported #1 (0x1d70…5302)
2019-07-27 18:03:50 Idle (0 peers), best: #1 (0x1d70…5302), finalized #0 (0xacb5…bb17), ⬇ 0 ⬆ 0

以上输出的内容包含了一些有价值的信息如:

  • Chain specification: Development ,表明我们使用的是内置开发模式的chain spec。
  • Node identity: QmZH4oHKH4nwaP4apeYCM7EJXkxAjv4AqnJt29MrMNhWBV, 节点ID。
  • Authority key: 5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu, 验证人的公钥。
  • WebSocket RPC 的 IP 和端口: 127.0.0.1:9944
  • Current block: best: #1 (0x1d70…5302).
  • Current finalized block: finalized #0 (0xacb5…bb17)。一直显示 0 是由于 Template Node 并没有引入最终性模块 GRANDPA finality gadget

Substrate 默认的共识机制是基于BABE和GRANDPA的混合共识,详细信息参考Polkadot Consensus

启动之后,你就拥有了一个由单个节点维护的"区块链"网络。下面我们通过UI与刚创建的节点进行交互。

节点交互

Substrate生态里提供了一个UI工具 Polkadot/Substrate UI 来帮助开发者与Substrate编写的区块链进行交互。你可以根据项目README的指示在本地运行,或者访问官方host的网页应用,链接为:https://polkadot.js.org/apps。

Settings页面,配置remote node为之前所说的WebSocket端口127.0.0.1:9944。保存配置后,会有更多的功能在侧边栏出现,供大家使用。

转到Extrinsics页:

  • 使用内置的ALICE用户,
  • 配置 submit the following extrinsictemplate doSomething(something),
  • 配置 something 为任意整数,
  • 点击提交 Submit Transaction. 几秒钟之后,你将会看到交易成功的提示信息。

接着,转到 Chain state 页面:

  • 配置 selected state querytemplate something(): Option<u32>
  • 点击➕按钮,你会看到刚刚输进入的数字。

以上就是我们与节点程序的基本交互操作。如果你还不熟悉UI的其它功能,可以多多练习,有助于后面的操作和理解。

添加功能模块

使用Substrate编写区块链应用,数据存储、可调用函数和事件都被封装在自定义的Runtime模块中。以刚刚创建的节点程序为例,预先定义的template模块,代码位于runtime/src/template.rs, 内容包含:

  • 数据存储(Storage): Something get(something): Option<u32>
  • 可调用函数(Callable Function):
    pub fn do_something(origin, something: u32) -> Result {
    // --snip--
    }
  • 事件(Event): SomethingStored(u32, AccountId)

下面我们在编写自定义的功能模块时会逐一对上面的内容进行介绍。

创建新模块

substrate-up提供了命令substrate-module-new来帮助我们创建一个template模块,里面包含了一些基本的依赖引入,以及上面提到的数据存储项、可调用函数、事件等示例代码,其中的一些注释可以很好地帮助初学者理解Substrate runtime模块的构成。在节点程序目录下执行如下命令(替换mymodule为你自己的模块名):

cd runtime/src
substrate-module-new mymodule

执行完成后,你会看到一个新生成的mymodule.rs文件,这就是你的模块程序文件。为了使用这一模块,我们还需要修改当前目录下的lib.rs

  • 引入我们新定义的模块:
mod mymodule;
  • 实现模块的配置接口:
impl mymodule::Trait for Runtime {
type Event = Event;
}
  • 添加模块到construct_runtime!宏:
construct_runtime!(
pub enum Runtime with Log(InternalLog: DigestItem<Hash, AuthorityId, AuthoritySignature>) where
Block = Block,
NodeBlock = opaque::Block,
UncheckedExtrinsic = UncheckedExtrinsic
{
// --snip--
MyModule: mymodule::{Module, Call, Storage, Event<T>},
}
);

通常被称为元编程,根据提供的代码可以生成新的代码,实现代码复用。Substrate使用了大量的宏来减轻开发人员的工作,让人"又爱又恨",更多细节参考 construct_runtime!

接下来,重新编译我们的节点程序:

# 编译runtime的wasm版本
./scripts/build.sh

# 编译runtime的本地二进制版本,并构建可执行的客户端
cargo build --release

# 删除链上的历史数据
./target/release/demo-node purge-chain --dev

# 启动本地测试网络
./target/release/demo-node --dev

请通过 Polkadot/Substrate UI 简单测试一下新创建模块的功能。

添加业务功能

本文,我们实现的业务是"抛硬币"游戏,用户可以付费玩游戏,如果抛出的结果为"正面朝上",则用户胜利,获取奖池里的奖金;如果用户失败,则什么都拿不到。无论胜负,用户支付的游戏费用都要存进奖池,以备后面的用户使用。

添加Storage Item

Runtime开发的第一步是设计你的存储数据结构,比如这里需要的游戏花费和奖池,在模块的decl_storage!宏中添加如下存储项:

decl_storage! {
trait Store for Module<T: Trait> as mymodule {
// --snip--
Payment get(payment): Option<T::Balance>;
Pot get(pot): T::Balance;
Nonce get(nonce): u64;
}
}

这里我们使用的decl_storage!宏使代码变得简单易懂,由Substrate负责生成更多和数据库进行交互的辅助代码,开发者只需设计存储的数据模型。

这里有三个存储项:

  • Payment 类型为 Option<T::Balance> ,保存着游戏的花费。使用Option表明该费用是否已经被初始化。

  • Pot 类型为 T::Balance ,保存了上次获胜者之后累积的所有奖励。

  • Nonceu64类型的整数,我们将会在生成随机数的时候用到。

    Balance 类型是由 SRML balances 模块提供的,用来表示账户的余额。要使用它,需要将我们模块的配置接口修改为依赖 balances Trait:

pub trait Trait: balances::Trait {
// --snip--
}

代码中get(payment)是用来定义Payment存储项的另一种getter函数,下面的章节我们再介绍如何使用这些getter函数。

定义Callable Function

本节我们将会定义Runtime开发所需的可调用函数。这里所说的可调用函数,是那些可以被用户调用,并且与区块链系统进行交互的函数。函数本身是不可以被代码之外进行调用的,但是由于Substrate的封装开放了对应的RPC接口,更多细节这里我们不过多的讨论。我们为"抛硬币"游戏定义了两个函数:一个用来初始化游戏花费;另一个用来开始游戏并生成游戏结果。

decl_module! {
pub struct Module<T: Trait> for enum Call where origin: T::Origin {
fn set_payment(_origin, value: T::Balance) -> Result {
// Logic for setting the game payment
}

play(origin) -> Result {
// Logic for playing the game
}
}
}

上面的代码显示了我们的可调用函数位于Module结构体中,下面我们将会为函数添加真正的逻辑。对于 set_payment 函数:

// This function initializes the `payment` storage item
// It also populates the pot with an initial value
fn set_payment(origin, value: T::Balance) -> Result {
// Ensure that the function call is a signed message (i.e. a transaction)
let _ = ensure_signed(origin)?;

// If `payment` is not initialized with some value
if Self::payment().is_none() {
// Set the value of `payment`
<Payment<T>>::put(value);

// Initialize the `pot` with the same value
<Pot<T>>::put(value);
}

// Return Ok(()) when everything happens successfully
Ok(())
}

我们的 set_payment 函数需要两个参数,

  • origin, 类型为 SRML system 模块定义的T::Origin,包含了函数调用的发出方。这个参数总是作为可调用函数的第一个参数。Substrate允许我们为这个参数缺省类型签名来简化工作。参考这里Origin的定义
  • value ,类型为 T::Balance,用来初始化 PaymentPot

接下来,我们来实现 play 函数:

// This function is allows a user to play our coin-flip game
fn play(origin) -> Result {
// Ensure that the function call is a signed message (i.e. a transaction)
// Additionally, derive the sender address from the signed message
let sender = ensure_signed(origin)?;

// Ensure that `payment` storage item has been set
let payment = Self::payment().ok_or("Must have payment amount set")?;

// Read our storage values, and place them in memory variables
let mut nonce = Self::nonce();
let mut pot = Self::pot();

// Try to withdraw the payment from the account, making sure that it will not kill the account
let _ = <balances::Module<T> as Currency<_>>::withdraw(&sender, payment, WithdrawReason::Reserve, ExistenceRequirement::KeepAlive)?;

// Generate a random hash between 0-255 using a csRNG algorithm
if (<system::Module<T>>::random_seed(), &sender, nonce)
.using_encoded(<T as system::Trait>::Hashing::hash)
.using_encoded(|e| e[0] < 128)
{
// If the user won the coin flip, deposit the pot winnings; cannot fail
let _ = <balances::Module<T> as Currency<_>>::deposit_into_existing(&sender, pot)
.expect("`sender` must exist since a transaction is being made and withdraw will keep alive; qed.");

// Reduce the pot to zero
pot = Zero::zero();
}

// No matter the outcome, increase the pot by the payment amount
pot = pot.saturating_add(payment);

// Increment the nonce
nonce = nonce.wrapping_add(1);

// Store the updated values for our module
<Pot<T>>::put(pot);
<Nonce<T>>::put(nonce);

// Return Ok(()) when everything happens successfully
Ok(())
}

上面的 play 函数只接收 orgin 这一个参数。然后做一些预置条件检查如,交易应当被签名,并且payment存储项不能为空。这里我们使用了 Self::payment() 来获取存储项中的具体值,这就是我们上面说到的getter函数的具体使用方法,另一种获取存储项的方法为 <Payment<T>>::get()

在真正"抛硬币"之前,我们需要将游戏所需的花费从用户账户取出,当游戏结束之后将这些费用放入奖池中。代码中使用的 withdraw 函数还需要引入下面的依赖:

use support::traits::{Currency, WithdrawReason, ExistenceRequirement};

当硬币被抛出之后,用户有百分之五十的几率获胜。为了模拟这样的情况,首先生成一个0到255的随机数,再拿这个随机数跟128进行比较,如果小于128,那么用户获胜并获得奖池中的奖金;反之失败,用户什么都没有得到。最后更新存储项,为下一次游戏做准备。关于更多的随机数生成,请参考 Generating Random Data 页面

最后还需要引入的依赖有:

use runtime_primitives::traits::{Zero, Hash, Saturating};
use parity_codec::Encode;

生成Event

客户端通过监听区块中的Event来更新链下的存储状态或与用户交互。

当Payment被更新之后我们希望产生一个包含Payment信息的Event,由于用到了Balance类型,我们需要修改Event enum,添加泛型约束Balance = <T as balances::Trait>::Balance

decl_event!(
pub enum Event<T> where
AccountId = <T as system::Trait>::AccountId,
Balance = <T as balances::Trait>::Balance {
// --snip--
}
);

之后,在Event enum中定义我们的Event,并修改set_payment函数来生成这一事件:

PaymentSet(Balance),
fn set_payment(origin, value: T::Balance) -> Result {
// --snip--
if Self::payment().is_none() {
// --snip--
Self::deposit_event(RawEvent::PaymentSet(value));
}
// --snip--
}

当用户完成游戏之后,我们希望产生一个包含用户信息以及获胜信息的事件,同样我们需要添加我们的Event到Event enum中,并在合适的时机触发事件:

PlayResult(AccountId, Balance),
// This function is allows a user to play our coin-flip game
fn play(origin) -> Result {
let sender = ensure_signed(origin)?;
// --snip--
let mut winnings = Zero::zero();

if (<system::Module<T>>::random_seed(), &sender, nonce)
.using_encoded(<T as system::Trait>::Hashing::hash)
.using_encoded(|e| e[0] < 128)
{
// --snip--

// Set the winnings
winnings = pot;

// Reduce the pot to zero
pot = Zero::zero();
}

// --snip--

// Raise event for the play result
Self::deposit_event(RawEvent::PlayResult(sender, winnings));

// Return Ok(()) when everything happens successfully
Ok(())
}

这里我们定义了新的变量winnings保存获胜信息,初始值为0,如果获胜则更新为pot即奖池中的值。在函数返回Ok(())之前触发该事件。

总结

现在已经完成了所有的代码,可以进行简单的测试。同样地,访问 Polkadot/Substrate UI 在Extrinsics页面中调用上面定义的函数;之后在Chain state页面查询对应的存储项。遇到问题可以参考这里的 “抛硬币”完整代码

Reference