Substrate 官方教程增强版

Substrate 开发没那么神秘。

经过前两篇([第一篇](https://learnblockchain.cn/article/998)、[第二篇](https://learnblockchain.cn/article/1163)漫长的铺垫,按照剧情发展,作为第三篇怎么滴也得开始写写代码了。这也确实是本篇的目的。 按照原定计划,你将在文中看到一个端到端的示例。但查过官方教程之后,我打算略微调整一下写作计划:在官方的教程上进行增强,不再凭空写一个。 官方教程的第一个编程示例:[Build a PoE Decentralized Application](https://substrate.dev/docs/en/tutorials/build-a-dapp/) 提供了一个非常好的示范,一个完整的端到端例子。透过这篇教程,你应该很快能够了解 Substrate 的开发,以及如何开发一个前端应用。不过它依旧还有改进的空间,而本文则针对这些地方给出补充: 1. 缺少单元测试的示例。 1. 虽然有前端示例代码,但跟 UI 混杂在一起反而没有办法突出重点。 现在,请先去查看并练习官方教程,之后再来阅读本文。 ## 单元测试 首先,让我们先看看单元测试。这里假设你已经了解 rust 的测试编写过程,若不清楚,请先去查阅相关资料。或者,先跳过此节,回头再看不迟。 Substrate 的模板工程已经为测试提供了一个很好的基础:有 mock 也有 test。就官方教程而言,写测试基本上就是把我们的方法调用一下,然后检验结果即可,与平时的测试开发没有什么不同。而且,很大程度上还省掉了 mock 的时间。 那么,让我们先完成一个测试,它用来测试官方教程的完整业务逻辑:既可以创建 claim ,也能移除 claim 。在这一步,只需要更改 tests.rs 文件: ```rust #[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 。此时,当然要报错啦。验证这种情况很简单: ```rust #[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`。 - 为测试运行时添加事件支持。 ```rust 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>;` - 其中的 system 为:`use frame_system as system;` 对于 tests.rs,需要引入:`use super::RawEvent;` 这样,你的工程就为事件的测试做好了准备。让我们将上面第一个测试完善一下,增加对于事件的测试: ```rust #[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](https://github.com/polkadot-java/api/blob/master/README.md) 中有以下这句话: > 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。 1. 对于非 react 团队(比如我们团队一直用 angular),官方教程的代码不具备参考价值,还得直接去使用 api。 关于 API,[官方文档](https://polkadot.js.org/api/start/)非常详细且具体,非常值得一读。这里只给出值得注意之处: - 整个调用采用的是 promise 风格,熟悉前端开发的同学应该不陌生。 - api 基于元数据自动生成,整个模式:`api.<type>.<module>.<section>`。 - 有过以太坊开发经验的同学会知道,任何发往后端的交易基本上都需要经过签名,这里也不例外。 那么,我们看一下完整的调用官方教程的前端 api 例子: ```typescript 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); }); }; ``` 其中: - 这里是一个 cli 示例,用的是 commander。 - 创建 api 实例时请注意里面给出了类型映射(在官方教程的前端示例工程中的 development.json 文件中可看到类似的内容),这一点很关键,因为官方文档的 getting started 中没有明确给出(后面有提到,但不明显)。缺少这步,你很可能在发起请求时得到下面的错误: > Verification Error: Execution(ApiError("Could not convert parameter 'tx' between node and runtime - 之所以放在 isReady 中,这是为了保证 ws 链接已经建立成功。 - keyring 可简单理解为账户,用它来完成交易的签名。 除此之外,没有什么特别的了。 ## 关于 Substrate 应用的设计 最后,简单聊一下 Substrate 应用的设计: - 采用 Substrate 并不意味着你就要舍弃其他后端存储,如数据库等。它们之间的关系应该是互补而非排它关系。 - 不要把 Substrate 当垃圾场,它保存的内容应该尽可能的少。这或许有点反直觉,但细细品味一下确实是这样。这里有几个原因: - Substrate 的存储不是免费的,存储越多意味着成本越高。 - 区块链上保存的内容应该是大家达成共识的内容,这种形式有很多,最典型的就是教程中的 claim,它也没有保存实际的源文件。这样可以鱼和熊掌兼得: - claim 是哈希值,本身就是抗修改; - 保存于区块链上可利用区块链存储本身的特质,不允许修改; - 不保存源文件,存储成本低。 - 关于链上存储结构的设计,本质上跟一般 nosql 数据库设计没有差别。由于不像 sql 数据库天然提供了类似外键和 count 等聚合函数的支持,如果你有类似查询需求,你就得自行去用相应的辅助结构去完成。 ## 写在最后 总的来讲,只要习惯了 rust 语法,熟悉了 Substrate 的概念和 API,它的开发其实并没有什么难度。 至于其他,没什么秘诀,一个字:练。再就是,熟读文档,善用搜索,尤其是英文原文搜索。 [原文链接](https://blog.dteam.top/posts/2020-08/substrate-step3.html)

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

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

官方教程的第一个编程示例: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::&lt;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&lt;T>,
        template&lt;T>,
    }
}
  • 将原来代码中的:type Event = () 改为 type Event = TestEvent
  • 同时添加:pub type System = system::Module&lt;Test>;
    • 其中的 system 为: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 的信息,只会给出一个单调的失败报错,让你郁闷无比。

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,官方文档非常详细且具体,非常值得一读。这里只给出值得注意之处:

  • 整个调用采用的是 promise 风格,熟悉前端开发的同学应该不陌生。
  • api 基于元数据自动生成,整个模式:api.&lt;type>.&lt;module>.&lt;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("&lt;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);
  });
};

其中:

  • 这里是一个 cli 示例,用的是 commander。
  • 创建 api 实例时请注意里面给出了类型映射(在官方教程的前端示例工程中的 development.json 文件中可看到类似的内容),这一点很关键,因为官方文档的 getting started 中没有明确给出(后面有提到,但不明显)。缺少这步,你很可能在发起请求时得到下面的错误:

    Verification Error: Execution(ApiError("Could not convert parameter 'tx' between node and runtime

  • 之所以放在 isReady 中,这是为了保证 ws 链接已经建立成功。
  • keyring 可简单理解为账户,用它来完成交易的签名。

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

关于 Substrate 应用的设计

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

  • 采用 Substrate 并不意味着你就要舍弃其他后端存储,如数据库等。它们之间的关系应该是互补而非排它关系。
  • 不要把 Substrate 当垃圾场,它保存的内容应该尽可能的少。这或许有点反直觉,但细细品味一下确实是这样。这里有几个原因:
    • Substrate 的存储不是免费的,存储越多意味着成本越高。
    • 区块链上保存的内容应该是大家达成共识的内容,这种形式有很多,最典型的就是教程中的 claim,它也没有保存实际的源文件。这样可以鱼和熊掌兼得:
    • claim 是哈希值,本身就是抗修改;
    • 保存于区块链上可利用区块链存储本身的特质,不允许修改;
    • 不保存源文件,存储成本低。
  • 关于链上存储结构的设计,本质上跟一般 nosql 数据库设计没有差别。由于不像 sql 数据库天然提供了类似外键和 count 等聚合函数的支持,如果你有类似查询需求,你就得自行去用相应的辅助结构去完成。

写在最后

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

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

原文链接

区块链技术网。

  • 发表于 2020-08-23 12:15
  • 阅读 ( 1656 )
  • 学分 ( 51 )
  • 分类:Polkadot

评论