9

Substrate 官方教程增强版

 3 years ago
source link: https://blog.dteam.top/posts/2020-08/substrate-step3.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Substrate 官方教程增强版

胡键 Posted at — Aug 23, 2020 阅读 242

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

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

官方教程的第一个编程示例: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,它的开发其实并没有什么难度。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK