Substrate 开发没那么神秘。
经过前两篇(第一篇、第二篇漫长的铺垫,按照剧情发展,作为第三篇怎么滴也得开始写写代码了。这也确实是本篇的目的。
按照原定计划,你将在文中看到一个端到端的示例。但查过官方教程之后,我打算略微调整一下写作计划:在官方的教程上进行增强,不再凭空写一个。
官方教程的第一个编程示例:Build a PoE Decentralized Application 提供了一个非常好的示范,一个完整的端到端例子。透过这篇教程,你应该很快能够了解 Substrate 的开发,以及如何开发一个前端应用。不过它依旧还有改进的空间,而本文则针对这些地方给出补充:
现在,请先去查看并练习官方教程,之后再来阅读本文。
首先,让我们先看看单元测试。这里假设你已经了解 rust 的测试编写过程,若不清楚,请先去查阅相关资料。或者,先跳过此节,回头再看不迟。
Substrate 的模板工程已经为测试提供了一个很好的基础:有 mock 也有 test。就官方教程而言,写测试基本上就是把我们的方法调用一下,然后检验结果即可,与平时的测试开发没有什么不同。而且,很大程度上还省掉了 mock 的时间。
那么,让我们先完成一个测试,它用来测试官方教程的完整业务逻辑:既可以创建 claim ,也能移除 claim 。在这一步,只需要更改 tests.rs 文件:
#[test]
fn should_work() {
new_test_ext().execute_with(|| {
assert_ok!(TemplateModule::create_claim(
Origin::signed(1),
vec![1, 2, 3, 4]
));
assert_ok!(TemplateModule::revoke_claim(
Origin::signed(1),
vec![1, 2, 3, 4]
));
});
}
就这么简单,传入合适的参数,验证是否调用成功即可,这里用到了 assert_ok
这个宏。其中的 Origin 等都是在 mock 中定义的。同时,也注意这些 assert
方法其实运行在一个闭包之中。不妨简单理解成,这个闭包其实提供了一个净室环境,准备了这些方法运行所需要的上下文。
程序显然不是只有理想情况,还有异常,比如典型的:移除一个不存在的 claim 。此时,当然要报错啦。验证这种情况很简单:
#[test]
fn should_not_revoke_calim_with_non_existing_proof() {
new_test_ext().execute_with(|| {
assert_noop!(
TemplateModule::revoke_claim(Origin::signed(1), vec![1, 2, 3, 5]),
Error::<Test>::NoSuchProof
);
});
}
请注意这里用了另一个宏:assert_noop
,验证方法失败同时验证跑出的错误符合我们的预期。
看到以上两个示例,相信聪明的你应该已经知道其他测试该如何书写了。这里就将此作为练习,供大家自行解决。
但是,在看下一节之前,我们还需要解决一件事情,这也是 mock 中并没有完成,需要我们花点时间去准备的:关于事件的测试。
细心的同学应该会发现第一个示例是不完整的:它虽然测试了完整的流程,但却没有验证正确的事件被触发。这个安排是有意的,因为 mock 中并没有为 event 测试做好模拟,如果一上来就摆出来,可能会显得过程太复杂。
现在,到了讲解验证事件的时候了。让我们先看看要测试事件,mock.rs 需要进行哪些修改:
impl_outer_event
。mod template {
pub use crate::Event;
}
impl_outer_event! {
pub enum TestEvent for Test {
system<T>,
template<T>,
}
}
type Event = ()
改为 type Event = TestEvent
pub type System = system::Module<Test>;
use frame_system as system;
对于 tests.rs,需要引入:use super::RawEvent;
这样,你的工程就为事件的测试做好了准备。让我们将上面第一个测试完善一下,增加对于事件的测试:
#[test]
fn should_work() {
new_test_ext().execute_with(|| {
System::set_block_number(1);
let sender = ensure_signed(Origin::signed(1)).unwrap();
assert_ok!(TemplateModule::create_claim(
Origin::signed(1),
vec![1, 2, 3, 4]
));
assert!(System::events().iter().any(|a| {
a.event == TestEvent::template(RawEvent::ClaimCreated(sender, vec![1, 2, 3, 4]))
}));
assert_ok!(TemplateModule::revoke_claim(
Origin::signed(1),
vec![1, 2, 3, 4]
));
assert!(System::events().iter().any(|a| {
a.event == TestEvent::template(RawEvent::ClaimRevoked(sender, vec![1, 2, 3, 4]))
}));
});
}
请注意:这里有一个小 trick 。注意上面的第一行,它显式的设定了区块号。这一点对于事件测试很关键,缺少这一行,整个测试会失败。检查之后,你会发现: System::events()
的长度为 0,即没有任何事件被激发。这是因为,对于区块 0,不会发出事件!
运行测试很简单:在 pallet/template
运行 cargo test
。
最后,再补充一个技巧:适当的使用 assert_eq
宏,因为我发现单单用 assert 宏并不利于调试:它在失败时不会给出类似:expect xxx but got yyy
的信息,只会给出一个单调的失败报错,让你郁闷无比。
本来,我打算给出 js 和 java 两种示例,但在检查 java git 仓库时发现太久没有更新,且其 README 中有以下这句话:
The working substrate version is 1.0.0-41ccb19c-x86_64-macos. Newer substrate may be not supported.
再加上本质上,作为 client 调用机制和套路应该都差不多,因此也就打消了这个念头,只给出 js 的示例。
可能有同学会疑惑:官方教程上已经有前端示例了,这里再给出一个有何意义?这里我来解释一下:
关于 API,官方文档非常详细且具体,非常值得一读。这里只给出值得注意之处:
api.<type>.<module>.<section>
。那么,我们看一下完整的调用官方教程的前端 api 例子:
import program from "commander";
import * as fs from "fs";
import { ApiPromise, WsProvider, Keyring } from "@polkadot/api";
import { blake2AsHex } from "@polkadot/util-crypto";
const wsProvider = new WsProvider("ws://127.0.0.1:9944");
module.exports = async (argv: string[]) => {
program.version("1.0.0").usage("<command> [options]");
const api = await ApiPromise.create({
provider: wsProvider,
types: {
Address: "AccountId",
LookupSource: "AccountId",
},
});
api.isReady.then((api) => {
program
.command("server-info")
.description("Show the information about a local chain.")
.action(async () => {
const [chain, nodeName, nodeVersion] = await Promise.all([
api.rpc.system.chain(),
api.rpc.system.name(),
api.rpc.system.version(),
]);
console.log(`You are connected to chain ${chain} using ${nodeName} v${nodeVersion}`);
api.disconnect();
});
program
.command("create-claim [name]")
.description("Create a claim from a file.")
.action(async (name) => {
const content = Array.from(new Uint8Array(fs.readFileSync(name)))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
const hash = blake2AsHex(content, 256);
console.log(hash);
const keyring = new Keyring({ type: "sr25519" });
const alice = keyring.addFromUri("//Alice", { name: "Alice default" });
await api.tx["templateModule"]
["createClaim"](hash)
.signAndSend(alice)
.catch((e) => {
console.log(e.toString());
api.disconnect();
});
api.disconnect();
});
program
.command("revoke-claim [hash]")
.description("Revoke a claim by a hash code.")
.action(async (hash) => {
const keyring = new Keyring({ type: "sr25519" });
const alice = keyring.addFromUri("//Alice", { name: "Alice default" });
await api.tx["templateModule"]
["revokeClaim"](hash)
.signAndSend(alice)
.catch((e) => {
console.log(e.toString());
api.disconnect();
});
api.disconnect();
});
program.parse(argv);
});
};
其中:
Verification Error: Execution(ApiError("Could not convert parameter 'tx' between node and runtime
除此之外,没有什么特别的了。
最后,简单聊一下 Substrate 应用的设计:
总的来讲,只要习惯了 rust 语法,熟悉了 Substrate 的概念和 API,它的开发其实并没有什么难度。
至于其他,没什么秘诀,一个字:练。再就是,熟读文档,善用搜索,尤其是英文原文搜索。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!