TheGraph 二 :subgraph 四大关键定义数据
拿 Uniswap V2 的 subgraph 当样本,把 manifest、数据源、mapping、schema 四份定义逐一拆开:有地址的进 dataSources、没地址的进 templates,事件签名为什么必须逐字对上,以及 schema 一处改动会同时动到数据库表、mapping 的输出和查询接口。
上一篇讲的是索引器那一侧:区块怎么进库、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 里最关键的一处分工,也是最容易写错的地方。
specVersion: 0.0.8schema: 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、TransferFactory 只有一个,地址在部署 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):
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) 注册一个动态数据源,从这个区块往后,该地址上的 Swap、Mint、Sync 才会触发 core.ts 里的 handler。它是不可逆的,也没有对应的删除操作 —— 数据源只能加不能减。
被省略的那两段里有一个值得单独说的动作:新建 Token 时 symbol、name、decimals 都不在 PairCreated 事件里,只能对代币合约发 eth_call 现读。上一篇说过这是 mapping 里唯一会打网络的动作。这里读一次是划算的,因为一个代币只在第一次出现时读;要是把同样的调用放进 handleSwap,每笔交易读一次,同步速度会直接塌掉。
代码里还留着一处对现实的妥协:decimals 读回来可能是 null(有些代币压根没实现这个方法,或者返回了非标准的类型),这时 handler 直接 return 放弃这个交易对。索引器的世界里没有「报错让人来看一眼」这个选项,遇到不合规范的合约只能选一种放弃方式。
schema 一处定死三样东西
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 要重新生成、查询接口跟着变,而已经索引过的历史数据里那个字段是空的,除非重新同步。
字段类型里 BigInt 和 BigDecimal 的区别是这套 schema 里最实际的一个选择。链上的数量都是整数(wei、最小单位的代币),存 BigInt 是无损的;一旦要算价格、算美元计价的成交额,就得除以 10^decimals,结果不再是整数,只能用 BigDecimal。上面这个 type 里 txCount 是 BigInt、totalVolumeUSD 是 BigDecimal,分界线就在这儿。Int! 那个 pairCount 是 32 位整数,交易对数量还远没到需要担心的量级。
关系字段是另一个容易绊倒人的地方:schema 里把字段类型写成另一个实体(比如 token0: Token!),mapping 里赋给它的却是那个实体的 id 字符串,不是实体对象。写成 pair.token0 = token0.id,看起来像类型对不上,实际上生成的类就是这么定义的。
顺带说一句版本:很多教程里的 Factory、handlePairCreated、mostLiquidTokens、mostLiquidPairs 都能在 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:解析以太坊区块链数据。