老胡茶室
老胡茶室

Ethers.js 非权威开发指南(续)

胡键

原本以为两篇文章的内容应该足以覆盖 Ethers.js 的关键内容,但目前看来似乎还有些遗漏,于是乎就有了这个续篇。与之前的风格一样,本篇的内容同样采用问题驱动的风格。

在本篇中,你可以了解到:

  • 不要忽视地址
  • 如何读取合约中的 public 变量
  • 合约地址不定,仅能确定事件名,如何监听。
  • 优化 infura 调用次数
  • 留意日志查询的跨度
  • 对接其他网络
  • 值得了解的 ethers 周边工具

相比前两篇包含的都是实战代码,本篇则更偏向于开发策略和一些优化方法的总结。

看似简单的地址

从接触 dapp 开发的第一天,你就要跟地址打交道,不论是 EOA 还是 contract address,离了任何一个都很难开发出有意义的应用。地址本身,其实没有什么太多的复杂性,即便你不了解以下的几个事实,也并不影响应用的开发:

然而,一个容易忽略的细小地方却可能对于你的 dapp 体验带来比较大的影响,虽然这个影响在数据量小的时候并不会暴露出来:地址同时包含了大小写

这种地址有个专有名称:Checksum 地址,并且 ethers 也提供了方法来得到它:ethers.utils.getAddress。对于一般 dapp 来说,如何生成和计算 checksum 地址并不重要,真正值得关注的地方在于:你打算如何保存地址?

我在这篇状态同步的文章里曾经解释过为何要做状态同步的同步的原因,也提到过一般会利用数据库来作为状态同步数据的存储。看到这里,有经验的朋友应该很快就会明白我的潜台词:地址的大小写会影响数据查询的速度!

没错,这正是我的意思。看看典型的一个开发场景:findSomethingByAddress。它的几个版本的演进可能是这样的:

  • version 1: 后端直接将前端传入的参数传入查询:
select * from some_table where address = $1
  • 很快就会遇到经典场景:前端说这个地址明明应该有数据,但后端却没有返回。后端一检查,恍然大悟:地址大小写的问题。于是查询变成 version 2:
select * from some_table where address ilike $1

当然,也可以利用字符串函数把大小写统一转换再用相等比较来实现,但是 ilike 最省事嘛,😄。

  • 随着数据的积累,开始有人抱怨查询慢了,于是有人说:给 address 加上索引。

然而,问题似乎并没有解决。

原因很简单:ilike 不会命中索引。并且,如果即使前面用了函数加相等判断来解决,也会有同样的问题,或许熟悉数据库(如 PG)的同学会说:利用表达式索引就行了。

可是,为什么要把简单问题复杂化呢?采用以下方式要比上面的折腾简单得多,而且可维护性也大大提高。

  • 保存时,统一大小写:统一大写 or 统一小写 or 统一 checksum。
  • 查询时,转成数据库使用的大小写规范,直接使用等于操作。

这样 index 也可以派上用场,并且也不需要太高深的数据库背景知识,降低了开发的理解成本和减少了系统的隐性知识。

读取合约 public 变量

上篇给的 abi 的例子主要集中在三个方面:只读、只写和 struct。这里再补充一个场景:合约 public 常量。

例如,在 BAYC 的合约中定义了 MAX_APES

uint256 public MAX_APES;

使用方式并不复杂,将其视为只读函数并按照只读函数构建 abi 字符串即可:

'function MAX_APES() public view returns (uint256)',

如此,你就可以用 baycContract.MAX_APES() 来访问这个变量了。

如何监听不确定地址的合约事件

这个问题一个典型场景:监听任意合约的 transfer 事件。

由于合约地址不确定,故无法在合约实例上实行监听,只能在 provider 实例上完成。在下篇中虽给出了示例,但并不详细,这里给出一个完整的示例:

const filter = {
  topics: [id("Transfer(address,address,uint256)")],
};

this.params.provider().on(filter, async (log: any, event: any) => {
  if (log.topics.length === 4) {
    this.transferEventHandler(
      `0x${log.topics[1].slice(26)}`,
      `0x${log.topics[2].slice(26)}`,
      log.address,
      Number(log.topics[3]).toString(),
      log.transactionHash,
      (await this.params.provider().getBlock(log.blockNumber)).timestamp,
      (await this.params.provider().getTransaction(log.transactionHash)).data
    );
  }
});

其中注意一下 log.topics 与方法参数的对应关系,第一个参数由 1 开始;此外, log.address 代表合约地址。

对于这类大范围且高频事件的监听需要注意,因为它会极大影响你所使用的 provider 服务的调用次数,除非明确的需求使然,不要采用。关于它的细节,请看下节。

优化 infura 调用次数

虽然本节以 infura 为题,但其内容适合任何其他类似的 provider,如 alchemy。

可能会有人好奇,不就是一个合约调用吗,怎么跟 infura 调用次数扯上关系了?这是因为:所有的合约调用(不论是否通过 ethers 完成)最终都会转到所用 sdk 的底层 rpc 服务,进而都会体现在你所用服务的订单之上。说到底还是跟成本有关,很自然优化调用次数就值得关注了。

影响 infura 调用次数的因素主要有两个:手工调用和事件监听。

优化手工调用

对于手工调用,很容易理解:每调用一次合约方法(不论读还是写),肯定会触发一次 rpc,当然也就导致了 infura 次数的增加。它的优化策略很简单:尽可能少的调用合约。这听起来似乎有些矛盾,dapp 怎么可能不去调用合约就实现功能呢?如果我换一种说法,应该就更容易理解了:不到万不得已,不去调用合约方法。

通常来说,读方法的调用次数远高于写方法,并且写方法一般都伴随有明显的成本(提交 tx 需要消耗用户的 eth)大家会比较关注,因此这里把重点放在读方法的优化策略上。常见的优化方法:

  • 缓存,对于不易变属性可以采用这个策略,将其结果缓存而非每次都去调用合约。
    • 扩展:若可能,复用前次调用结果。比如下面例子中完全可以用一个变量先保存最近的区块链,而非每个循环都去再调用一次。虽然 ethers 可能也有缓存策略,但简单的赋值语句可以让我们更确定而非寄希望于工具来帮我们优化。
while (start < (await this.params.provider().getBlock('latest')).number) {
  ...
}
  • 延迟调用,即不是一开始就把所有需要使用的数据全都拿到,而是在处理过程中用到时才去调用。
  • 必要的检查,这本质上是上一条策略的扩展:环境不允许时,就不去调用。
  • 缩小日志查询的范围,不要总是从 0 开始查询,而是通过指定 block 号开始,参见下篇的例子。

优化事件监听

手工调用对于 infura 次数的影响非常直观:调一次就记一次。相比而言,事件监听就没有那么明显了,这也是它容易被忽视的原因。ethers 通过轮询来实现事件机制,因此创建事件监听并非没有成本。对于这部分的优化策略:

  • 只创建必要的事件监听
  • 尽量缩小事件监听的范围,即能确定合约的就在合约上监听,避免在 provider 上监听。
  • 修改 provider.pollingInterval,缺省是 4s,对于一般情况而言太快了,设成每个 block 的时间已经足够。

其他优化策略

除此之外,还有的其他策略:

  1. 转移调用方。

前面策略的假设是:调用方是自己,这个假设通常适合后端程序。对于前端并非如此:调用方往往是 metamask 或其他 wallet app。而这些插件和应用一般也都有自己的服务和相应的配额。

因此,后端不要大包大揽,把该前端做的事情,交给前端。

  1. 优化合约。

对于这个策略,可以问自己两个问题:

  • 是否可通过实现必要的功能避免不必要的事件监听?
    • 如使用 ERC721Enumerable 可方便掌握当前合约内所有 token 状态,而不必跟踪 transfer 事件。如果不打算跟踪 owner 的话,它是一个非常好的候选。
  • 是否可以通过实现必要的功能缩小要监听的合约范围?
    • 如对于主从合约(一主多从),可以先将子合约的事件传给主合约,再由主合约转发出来。这样一来不必监听多个子合约,只需要监听一个主合约即可。在某些情况下,这是不得不采用的策略:子合约是主合约动态部署的情况下。

留意日志查询的跨度

某些 evm 兼容链对于日志查询的范围有限制,即 contract.queryFilter(filter, fromBlock) 有可能失败,并且提示:查询范围太大。对此,没有其他办法,只能限制查询跨度。代码示例如下:

try {
  const latest = (await this.params.provider().getBlock("latest")).number;

  while (start < latest) {
    const end = start + MAX_BLOCK_COUNT - 1;
    await process(await fetchLogs(start, end));
    start = end + 1;
  }
} catch (err) {
  this.logger.error(err as any);
}

其中的 MAX_BLOCK_COUNT 为该链支持的最大跨度。

对接其他网络

随着兼容 evm 的链越来越多,对他们对接再说难免。以 infura 为例,对于 ethers 支持的,直接传入网络参数就可以了,如连接 mumbai:

new ethers.providers.InfuraProvider("maticmum", process.env.INFURA_PROJECT_ID);

若底层服务不支持,则可以使用 JsonRpcProvider,如连接 mumbai 还可以写成这样:

new ethers.providers.JsonRpcProvider(
  `https://polygon-mumbai.infura.io/v3/${process.env.INFURA_PROJECT_ID}`
);

这个 url 需要查阅服务商的文档获得。

值得了解的 ethers 周边工具

ethers 有两个比较出彩的周边:eth-sdkTypeChain,它们出自一个作者,前者是后者的轻量级版本。

这两个工具的作用大同小异,都是通过合约自动生成代码。但我个人对此类工具兴趣不大,故没有过多关注和使用,有兴趣的同学可以自行查阅他们的文档。

总结

相信从本文的这些例子中你也看出,做好 dapp 开发绝非仅仅熟悉一两个工具那么简单。除了对于工具的 api 的熟悉和掌握,开发这还必须时刻关注底层细节和对应基础设施服务商的技术文档。只有这样,才能有效的发挥工具的作用,同时也避免各种浪费。

付费内容

本文包含付费内容,需要会员权限才能查看完整内容。