DTeam 技术日志

Doer、Delivery、Dream

Substrate 官方教程增强版

胡键 Posted at — Aug 23, 2020 阅读

经过前两篇(第一篇第二篇)漫长的铺垫,按照剧情发展,作为第三篇怎么滴也得开始写写代码了。这也确实是本篇的目的。

按照原定计划,你将在文中看到一个端到端的示例。但查过官方教程之后,我打算略微调整一下写作计划:在官方的教程上进行增强,不再凭空写一个。

官方教程的第一个编程示例:Build a PoE Decentralized Application 提供了一个非常好的示范,一个完整的端到端例子。透过这篇教程,你应该很快能够了解 Substrate 的开发,以及如何开发一个前端应用。不过它依旧还有改进的空间,而本文则针对这些地方给出补充:

  1. 缺少单元测试的示例。
  2. 虽然有前端示例代码,但跟 UI 混杂在一起反而没有办法突出重点。

现在,请先去查看并练习官方教程,之后再来阅读本文。

单元测试

首先,让我们先看看单元测试。这里假设你已经了解 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 需要进行哪些修改:

mod template {
    pub use crate::Event;
}

impl_outer_event! {
    pub enum TestEvent for Test {
        system<T>,
        template<T>,
    }
}

对于 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 的信息,只会给出一个单调的失败报错,让你郁闷无比。

Polkadot API

本来,我打算给出 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 的示例。

可能有同学会疑惑:官方教程上已经有前端示例了,这里再给出一个有何意义?这里我来解释一下:

  1. 官方教程的例子是基于 react ui 的范例,很多细节都隐藏了(不信就去对比一下 api 文档里的代码和官方教程中的前端代码),并不利于理解 API。
  2. 对于非 react 团队(比如我们团队一直用 angular),官方教程的代码不具备参考价值,还得直接去使用 api。

关于 API,官方文档非常详细且具体,非常值得一读。这里只给出值得注意之处:

那么,我们看一下完整的调用官方教程的前端 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);
  });
};

其中:

除此之外,没有什么特别的了。

关于 Substrate 应用的设计

最后,简单聊一下 Substrate 应用的设计:

写在最后

总的来讲,只要习惯了 rust 语法,熟悉了 Substrate 的概念和 API,它的开发其实并没有什么难度。

至于其他,没什么秘诀,一个字:练。再就是,熟读文档,善用搜索,尤其是英文原文搜索。


相关文章