TheGraph 二 :subgraph 四大关键定义数据

拿 Uniswap V2 的 subgraph 当样本,把 manifest、数据源、mapping、schema 四份定义逐一拆开:有地址的进 dataSources、没地址的进 templates,事件签名为什么必须逐字对上,以及 schema 一处改动会同时动到数据库表、mapping 的输出和查询接口。

预计
7 分钟

上一篇讲的是索引器那一侧:区块怎么进库、mapping 在 wasm 里跑、写入按区块提交。这一篇看开发者要交出去的那份声明 —— manifest、数据源、mapping 函数、schema,样本是 Uniswap V2 的 subgraph。

先说一件容易卡住的事:仓库里没有 subgraph.yaml。真实的文件是 v2-subgraph.template.yaml,加上 config/<chain>/config.json,用 Mustache 渲染出最终的 manifest。同一份 subgraph 要部署到好几条链,合约地址和起始区块每条链都不一样,模板化是绕不开的。下面贴的是渲染之后的样子。

manifest 开头那两个版本号是两回事,混起来会得到很难懂的报错:specVersion 说的是这份 manifest 本身的格式版本(Uniswap V2 用 0.0.8,目前最新是 1.3.0),mapping.apiVersion 说的是 mapping 代码调用的宿主 API 的版本(这里是 0.0.7,最新 0.0.9)。前者管解析,后者管运行。

有地址的进 dataSources,没地址的进 templates

这是整份 manifest 里最关键的一处分工,也是最容易写错的地方。

v2-subgraph.template.yaml · 渲染后的样子(节选)
specVersion: 0.0.8
schema:
file: ./schema.graphql
dataSources:
- kind: ethereum/contract
name: Factory
network: mainnet
source:
address: '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f'
abi: Factory
startBlock: 10000834
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/v2/mappings/factory.ts
entities:
- Pair
- Token
abis:
- name: Factory
file: ./abis/factory.json
eventHandlers:
- event: PairCreated(indexed address,indexed address,address,uint256)
handler: handleNewPair
templates:
- kind: ethereum/contract
name: Pair
network: mainnet
source:
abi: Pair # 没有 address,也没有 startBlock
mapping:
kind: ethereum/events
apiVersion: 0.0.7
language: wasm/assemblyscript
file: ./src/v2/mappings/core.ts
# entities / abis 同上;eventHandlers 是 Mint、Burn、Swap、Sync、Transfer

Factory 只有一个,地址在部署 subgraph 之前就知道(第 10 行),所以它是 dataSources 里的一项。Pair 不一样:Uniswap 上每建一个交易对就多一个合约,地址要到 PairCreated 事件发出来的那一刻才存在。这种合约必须声明在顶层的 templates: 下面,它和普通数据源唯一的区别就是 source 里没有 address(第 33 行)。

Pair 写进 dataSources 是个常见错误。写在那儿它需要一个地址,而你没有;就算随便填一个,PairTemplate.create() 也找不到对应的模板 —— 那个 API 找的是 templates 里同名的项。

第 12 行的 startBlock 只在有地址的数据源上有意义,填的是 Factory 合约的部署区块。填早了白扫几百万个空区块,填晚了直接丢掉那一段历史,而且不会有任何报错 —— 你只会发现早期的交易对不在数据里。模板不需要这个字段,动态数据源从注册它的那个区块开始索引。

eventHandlers 里的事件签名要逐字对上 ABI,包括 indexed 修饰符和参数类型的精确写法。uint256 写成 uint 匹配不上,漏掉一个 indexed 也匹配不上。对不上的后果同样是安静的:graph build 能过,部署能成功,handler 一次也不会被调用,你得盯着实体表一直是空的才反应过来。

mapping 里没有初始化钩子,单例实体只能先 load 再判空

PairCreated 的处理函数叫 handleNewPair(不是 handlePairCreated):

uniswap-v2-subgraph · src/v2/mappings/factory.ts · handleNewPair(简化)
import { PairCreated } from '../../../generated/Factory/Factory'
import { Bundle, Pair, Token, UniswapFactory } from '../../../generated/schema'
import { Pair as PairTemplate } from '../../../generated/templates'
import { FACTORY_ADDRESS } from '../../common/chain'
import { ZERO_BD, ZERO_BI } from '../../common/constants'
export function handleNewPair(event: PairCreated): void {
// load factory (create if first exchange)
let factory = UniswapFactory.load(FACTORY_ADDRESS)
if (factory === null) {
factory = new UniswapFactory(FACTORY_ADDRESS)
factory.pairCount = 0
// 省略:其余计数和累计额字段一律初始化为零
let bundle = new Bundle('1')
bundle.ethPrice = ZERO_BD
bundle.save()
}
factory.pairCount = factory.pairCount + 1
factory.save()
let token0 = Token.load(event.params.token0.toHexString())
let token1 = Token.load(event.params.token1.toHexString())
// 省略:两个 token 为 null 时新建,symbol / name / decimals 都靠 eth_call 现读
// 省略:新建 Pair 实体,储备量、成交额、价格全部置零
// 从这一刻起,这个地址上的事件才开始被索引
PairTemplate.create(event.params.pair)
}

第 9、10 行那个「load 完判空、空了就 new」的写法会在几乎每个 handler 开头出现一次,因为 mapping 没有全局初始化钩子。没有一个 onInit 能让你在同步开始前把 UniswapFactory 这种全站单例先建好,唯一的时机就是第一次用到它的时候。FACTORY_ADDRESS 拿来当这个单例的 id,是因为它是个天然唯一又稳定的字符串。

第 28 行是这个 handler 真正的产出。PairTemplate.create(address) 注册一个动态数据源,从这个区块往后,该地址上的 SwapMintSync 才会触发 core.ts 里的 handler。它是不可逆的,也没有对应的删除操作 —— 数据源只能加不能减。

被省略的那两段里有一个值得单独说的动作:新建 Tokensymbolnamedecimals 都不在 PairCreated 事件里,只能对代币合约发 eth_call 现读。上一篇说过这是 mapping 里唯一会打网络的动作。这里读一次是划算的,因为一个代币只在第一次出现时读;要是把同样的调用放进 handleSwap,每笔交易读一次,同步速度会直接塌掉。

代码里还留着一处对现实的妥协:decimals 读回来可能是 null(有些代币压根没实现这个方法,或者返回了非标准的类型),这时 handler 直接 return 放弃这个交易对。索引器的世界里没有「报错让人来看一眼」这个选项,遇到不合规范的合约只能选一种放弃方式。

schema 一处定死三样东西

uniswap-v2-subgraph · schema.graphql · UniswapFactory
type UniswapFactory @entity {
id: ID!
pairCount: Int!
totalVolumeUSD: BigDecimal!
totalVolumeETH: BigDecimal!
untrackedVolumeUSD: BigDecimal!
totalLiquidityUSD: BigDecimal!
totalLiquidityETH: BigDecimal!
txCount: BigInt!
}

@entity 是给 graph codegen 看的标记:带这个指令的 type 会变成一张 Postgres 表、一个 AssemblyScript 类、以及 GraphQL 里一组可查询的字段。这三样是同一处定义的三个投影,所以改 schema 的成本比看上去高 —— 加一个字段意味着数据库要迁移、mapping 要重新生成、查询接口跟着变,而已经索引过的历史数据里那个字段是空的,除非重新同步。

字段类型里 BigIntBigDecimal 的区别是这套 schema 里最实际的一个选择。链上的数量都是整数(wei、最小单位的代币),存 BigInt 是无损的;一旦要算价格、算美元计价的成交额,就得除以 10^decimals,结果不再是整数,只能用 BigDecimal。上面这个 type 里 txCountBigInttotalVolumeUSDBigDecimal,分界线就在这儿。Int! 那个 pairCount 是 32 位整数,交易对数量还远没到需要担心的量级。

关系字段是另一个容易绊倒人的地方:schema 里把字段类型写成另一个实体(比如 token0: Token!),mapping 里赋给它的却是那个实体的 id 字符串,不是实体对象。写成 pair.token0 = token0.id,看起来像类型对不上,实际上生成的类就是这么定义的。

顺带说一句版本:很多教程里的 FactoryhandlePairCreatedmostLiquidTokensmostLiquidPairs 都能在 2020 年那版 Uniswap V2 subgraph 里找到,它们当时是真的,现在的仓库里已经没有了。抄这类示例之前值得去仓库对一眼当前的名字。

这四份定义留下的代价

声明式的东西改起来都要重来一遍。 manifest 决定索引器看什么,schema 决定数据长什么样,两者任何一处变动都可能意味着从 startBlock 重新同步。开发期这一点最难受 —— 改一个字段名,等几个小时。本地起一个 graph-node 配一小段区块区间,比直接改线上的 subgraph 省事得多。

错误大多是安静的。 事件签名对不上、startBlock 填晚了、模板名写错,这三样都不会让构建失败,只会让数据少一块。真正的验证手段只有一个:同步一段之后去查一下,看条数对不对。

多链靠模板渲染,不是靠 manifest 自己的能力。 一份 manifest 只对应一条链上的一组地址。Uniswap 那套 Mustache 加 config 的做法是自己搭的脚手架,The Graph 本身没有提供跨链的抽象。链多了之后,subgraph 的数量和部署流水线的复杂度是乘起来的。

动态数据源只增不减。 Template.create() 没有反向操作。Uniswap V2 至今建过的每一个交易对都是一个数据源,索引器要为它们全部维护过滤条件。一个会无限创建子合约的协议,subgraph 的成本会随时间单调上涨。

这些事件本身是从哪儿发出来的、Swap 的六个参数各是什么,在 Uniswap v2 学习里能对上另一半。

这篇归在链上基础设施下。同一主题里最近的另外两篇是基于 MetaMask 理解钱包工作原理Node.js,Ethers.js:解析以太坊区块链数据