Substrate 官方教程增强版

  • 胡键
  • 更新于 2020-08-23 12:15
  • 阅读 1465

Substrate 开发没那么神秘。

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

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

官方教程的第一个编程示例: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 需要进行哪些修改:

  • 引入: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>;
    • 其中的 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.<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);
  });
};

其中:

  • 这里是一个 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,它的开发其实并没有什么难度。

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

原文链接

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
胡键
胡键
CSM / 架构师 / 创业者,先后就职于中兴和 SAP,现专注于工业物联网、机器学习和区块链。同时,作为机器学习和区块链技术活动的组织者和分享者活跃于本地社区。