
欢迎阅读子图开发最佳实践系列的创刊版。本系列的目的是分享自Graph Node 于 2018 年开源以来出现的模式的见解和建议。自成立以来,大量的新功能被交付,导致在 web3 生态系统中构建了数以万计的子图。子图(去中心化开源 API)使开发人员能够为他们的 dapps 创建快速前端,并已成为web3 堆栈的一个组成部分。然而,随着使用的增加,对发现效率的需求也随之增加,新手和有经验的子图开发人员都经常寻求发现和实施最佳实践。
这个新的开发系列将提供提示和技巧,以提升子图开发技能,帮助创建更高效、更快速的 dapp!在这个首次亮相的版本中,让我们从一个经常被忽视的最佳实践开始,该实践非常易于实施并且具有巨大的优势。
第 1 部分:减少 eth_calls:简单的索引性能改进
当子图索引比预期的慢时,这可能会令人沮丧。有时,开发人员无意中结束了对索引性能极限的测试。The Graph 生态系统中的开发人员正在研究可大幅提高去中心化数据性能和功能的技术,例如Firehose和Substreams。尽管如此,子图开发人员通常可以通过简单地优化子图来在索引性能和查询速度方面看到相当显着的改进。最常提到的改进是减少或完全避免通过eth_calls访问智能合约状态。让我们深入研究一个解决方案。
为什么要减少 eth_calls?
常见的 dapp 设置类似于此图:

在这个模型中,我们的 dapp 的 UI 直接从子图中显示所有必要的数据,如左侧所示,因此无需通过 JSON RPC 直接从区块链节点获取任何数据。如果用户与 dapp 的智能合约交互,他们会发送一个交易,这将改变智能合约的状态,如上图右侧所示。示例包括铸币、转账、互换等。当智能合约状态发生变化时,它会发出子图订阅的事件。监听和索引事件中发出的数据是一种非常高效的模式。
不幸的是,许多智能合约不会直接在其事件中发出所有必要的数据。为了在完成交易后确定新的智能合约状态,通常需要将eth_call发送回智能合约以检索该数据。但是,JSON RPC 调用通常很慢。每个呼叫通常需要 100 毫秒到几秒钟才能解决。这就是为什么不鼓励开发人员在他们的 UI 中使用它们来显示信息的原因。如果这些 eth_calls 作为图形节点内映射的一部分运行,它们也会很慢。值得庆幸的是,有一些模式可以减少它们:
背景信息:为了正确处理重组和索引历史数据,Graph Node通过将块哈希作为参数添加到 eth_call 来利用EIP-1898 。这保证了调用的结果来自发出事件的块的最终状态。但在某些边缘情况下,合约的状态会在发出的事件和区块的最终确定之间在区块内发生变化。喜欢活动的另一个好理由。指定 block-hash 也会使 eth_call 更难被以太坊节点解析。

如果我们仍然可以更改合约,因为它仍在开发或升级中,我们应该尝试在事件中预先发出所有必要的数据。然而,有时我们不能再更改合约,所以我们需要回退到 eth_calls。在这种情况下,建议我们尽量减少 eth_calls 的数量。
如上所述,eth_calls 始终在进行调用的块末尾返回智能合约的状态。如果合约的状态没有随着链的进行而改变,那么进行这些调用只是查找我们已经检索到的数据的一种昂贵方式。因此,避免这种情况的一种常见模式是将 eth_call 的结果存储在子图中,并且仅在数据未知时才发送该调用。
示例模式
让我们看一个简单的 NFT 子图的例子。在这个练习中,我们想要跟踪这个假设的 NFT 项目的铸币总量,以及每个代币的元数据。我们的示例子图将具有以下模式:
type Token @entity {
id: Bytes!
owner: Bytes
uri: String
contract: Contract
}
type Contract @entity {
id: Bytes!
name: String
symbol: String
totalSupply: BigInt
tokens: [Token!]! @derivedFrom(field: "contract")
}
模式中的实体可以理解为数据库表定义:该模式描述了一个包含所有代币及其持有者的代币表,以及一个只有一个条目(单例)来保存合约信息的合约表。
天真的实现
一个天真的映射看起来像这样:
import { Bytes } from '@graphprotocol/graph-ts';
import { ERC721, Transfer as TransferEvent } from '../generated/ERC721/ERC721';
import { Token, Contract } from '../generated/schema';
export function handleTransfer(event: TransferEvent): void {
let instance = ERC721.bind(event.address);
let contract = new Contract(event.address);
contract.name = instance.name(); // eth_call
contract.symbol = instance.symbol(); // eth_call
contract.totalSupply = instance.totalSupply(); // eth_call
let token = new Token(Bytes.fromI32(event.params.tokenId.toI32()));
token.owner = instance.ownerOf(event.params.tokenId); // eth_call
token.uri = instance.tokenURI(event.params.tokenId); // eth_call
token.contract = contract.id;
contract.save();
token.save();
}
注意:为了简单起见,我们没有处理调用还原的可能性。在生产代码中,强烈建议根据Graph Docs 中的 Handling Reverted Calls 部分这样做。
让我们回顾一下该映射。如第 6 行所示,ERC-721 合约绑定到发出 Transfer-Event 的合约地址。通常,获得绑定合约实例表明 eth_calls 将随之而来。要在子图中找到 eth_calls,通常只需在代码库中搜索“.bind(”。
在第 8 行,创建了一个新实体(读取:表中的行)。这是子图开发人员应该开始质疑的地方:我们为什么要创建一个新实体?数据库中可能已经有一个实体。在接下来的几行中,实际的 eth_calls 被触发以获取合约的名称、符号和总供应量。请记住,这些调用是为每个 Transfer-event 触发的。包含许多转账的 NFT 合约会触发数以千计的 eth_calls。其中大部分实际上是不必要的,因为 NFT 合约的名称和符号通常不会改变。只要可以铸造这个 NFT,只有总供应量可能会改变。
转到第 13 行,其中包含关于令牌本身的块。此处可以看到类似的行为:所有者在每次传输时都会发生变化,因此此处的 eth_call 可能是检索新所有者的合理方法。但是,tokenURI 不应更改。
eth_calls 的优化实现
我们如何优化此映射以减少 eth_calls?这里有两个主要策略:
- 缓存 eth_calls 的结果
- 使用事件本身的数据并计算子图中的信息
这是优化映射的样子:
import { Address, BigInt, Bytes } from '@graphprotocol/graph-ts';
import { ERC721, Transfer as TransferEvent } from '../generated/ERC721/ERC721';
import { Token, Contract } from '../generated/schema';
const ZERO_ADDRESS = Address.fromString(
'0x0000000000000000000000000000000000000000',
);
function getOrCreateContract(address: Address): Contract {
let contract = Contract.load(address);
if (!contract) {
let instance = ERC721.bind(address);
contract = new Contract(address);
contract.name = instance.name();
contract.symbol = instance.symbol();
contract.totalSupply = BigInt.fromI32(0);
contract.save();
}
return contract;
}
function getOrCreateToken(tokenId: BigInt, address: Address): Token {
let id = Bytes.fromI32(tokenId.toI32());
let token = Token.load(id);
if (!token) {
let instance = ERC721.bind(address);
token = new Token(id);
token.uri = instance.tokenURI(tokenId);
token.contract = address;
token.save();
}
return token;
}
export function handleTransfer(event: TransferEvent): void {
let contract = getOrCreateContract(event.address);
let token = getOrCreateToken(event.params.tokenId, event.address);
token.owner = event.params.to;
token.save();
// A new token was minted if it comes from address
if (event.params.from == ZERO_ADDRESS) {
contract.totalSupply = contract.totalSupply.plus(BigInt.fromI32(1));
contract.save();
}
首先,我们为合约和代币引入辅助函数。辅助函数首先尝试从数据库加载实体。只有当它们还不存在时,它们才会发送 eth_calls 来加载必要的数据。如果需要,他们还会在第 17 行设置默认值,其中新合约的总供应量设置为零。
在实际的处理函数中,这些辅助函数会被利用。因此,如果代币和合约已经存在,它们根本不会发送单个 eth_call。在第 43 行,我们使用发出的事件附带的数据。通过查看事件的“to”参数,我们知道谁是该令牌的新所有者。
在第 47 和 48 行,我们可以在没有任何 eth_calls 的情况下计算映射中的 totalSupply:如果令牌是新铸造的,我们将观察到地址为零的“from”参数。如果是这种情况,我们可以简单地将 totalSupply 增加一个。
持续优化
通过引入辅助函数,我们能够最大限度地减少本例中的 eth_calls 数量。结果是子图的索引性能有了巨大的提高。类似的模式可以应用于其他挑战:
只做一次 eth_calls
将 eth_calls 的结果存储在数据库中
尝试计算子图中智能合约的状态
这里值得一提的一个更复杂的例子是Adam Fuller和David Lutterkort对 Uniswap 克隆的优化,记录在 Github 上。
参考链接:https://thegraph.com/blog/improve-subgraph-performance-reduce-eth-calls/