- 后端
- API网关
【免费下载链接】crystal
🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!
本文以 Grafast 官方测试套件(
dcc,Dungeon Crawler Carl 主题)中的consumable-items场景为切入点,完整解析 Grafast 在解析多态接口查询时如何通过 Combine(合并)步骤将来自多个具体类型数据源的步骤归并为一个执行单元。读完本文,你将理解 Item 接口四种实现类型的差异设计、planType/planForType的职责划分、Combine 节点在计划图(plan graph)中的位置与作用,以及该行为如何被 queries-test.ts 的快照测试所固化。
场景背景:dcc 测试套件中的 Item 多态体系
dcc(取自小说《Dungeon Crawler Carl》)是 Grafast 仓库中一个自成体系的 GraphQL 测试工程,其 Schema 与数据均定义于 dcc-schema.ts 与 dcc-data.ts。consumable-items系列文件位于 queries 目录,专门用来验证"一个接口拥有多个实现类型,且部分实现类型共享额外接口"时,Grafast 的计划(plan)应当如何组织。
该场景对应的关联文档 consumable-items.md 用三句话点明了核心设计意图:
- Item 有四种类型:
Equipment(装备)、Consumable(消耗品)、MiscItem(杂物)与UtilityItem(实用物品); - 只有
Equipment与Consumable额外实现了Created与HasContents两个接口(即只有它们有"创造者"和"内容物"两个子字段); - 期望
Equipment与Consumable的节点在计划图中发生 Combine(合并)。
这三点在 dcc-schema.ts 的 SDL 定义中得到完全印证:
interface Item { id: Int! name: String canBeFoundIn: [LootBox] } interface HasContents { contents(first: Int): [Item] } interface Created { creator: Crawler } type Equipment implements Item & Created & HasContents { id: Int! name: String canBeFoundIn: [LootBox] contents(first: Int): [Item] creator: Crawler currentDurability: Int maxDurability: Int } type Consumable implements Item & Created & HasContents { id: Int! name: String canBeFoundIn: [LootBox] contents(first: Int): [Item] creator: Crawler effect: String } type MiscItem implements Item { id: Int! name: String canBeFoundIn: [LootBox] } type UtilityItem implements Item { id: Int! name: String canBeFoundIn: [LootBox] }可见:MiscItem与UtilityItem只实现Item,没有contents与creator字段;而Equipment与Consumable各自带有maxDurability/currentDurability或effect等专属字段。正是这种"接口子集不同"的设计,制造出了 Combine 步骤存在的必要场景。
核心图解:consumable-items 的期望计划结构
consumable-items.md 用一段 Mermaid 图精确刻画了该查询应当生成的计划图拓扑,这也是整个场景的验收标准:
逐层解读这张计划图:
- 顶层
id:是Item接口的"说明符"(specifier)步骤,即查询中最上游的步骤。对每个具体类型Equipment/Consumable/MiscItem/UtilityItem,Grafast 都会从它派生出各自的取值步骤(图中的四个叶子节点); GetContents_1/GetCreator_1与GetContents_2/GetCreator_2:由于只有Equipment与Consumable拥有HasContents.contents与Created.creator字段,这两个类型各自会派生独立的"取内容物"与"取创造者"步骤;Combine_2(Creator 子图):把来自Equipment与Consumable两条路径的"创造者 id"合并成一个步骤,再交由crawlerToTypeName计算出 Crawler 的具体类型名;Combine(Contents 子图):把来自两条路径的"内容物 id 列表"合并,再交给decodeItemSpec统一解码为{__typename, id},最后按类型分发到GetEquipmentById、GetConsumableById、GetMiscItemById、GetUtilityItemById四个批量加载步骤。
换言之:Combine 的作用是把"同一个逻辑字段、但来自多个具体实现类型"的重复步骤归并为一个共享步骤,从而避免重复执行、保证批量加载(batching)能被合并到同一次调用中。从 LayerPlan.ts 的源码注释可以看到,Grafast 中存在一种 "combined" 类型的 LayerPlan,其职责正是"将来自多个 layer 的值重新合并,以便在分支之后重新组合"——这与文档图中 Combine 节点的语义完全一致。
查询形态:ConsumableItems 及其变体
文档对应的可执行查询保存在 consumable-items.test.graphql,共包含五个操作,从不同维度覆盖同一场景:
query ConsumableItems { crawler(id: 101) { id name ... on HasInventory { items(first: 3) { __typename id name ... on Created { __typename creator { id name } ... on Equipment { maxDurability } ... on Consumable { effect } ... on HasContents { contents(first: 3) { __typename id name ... on Equipment { maxDurability } ... on Consumable { effect } } } } } } } }五个操作的设计意图分别是:
| 操作名 | 特性 | 验证目标 |
|---|---|---|
ConsumableItems | 基础查询 | 无变量、无增量场景下的数据与计划快照 |
ConsumableItemsWithVariables | 携带@variables(values: { first: 3 }) | 将first作为变量传入后行为保持一致 |
ConsumableItemsDefer1 | 顶层@defer+@incremental | 增量执行时{crawler}字段以 patch 形式输出 |
ConsumableItemsDefer3 | 内层@defer(字段级) | 在items与contents层级分段增量 |
ConsumableItemsDefer4 | 双重@defer+@incremental | 更深层级的增量拆分 |
对应的数据快照 consumable-items.json5 给出了crawler(id: 101)(Carl)的期望结果:三个物品分别是Consumable("Dolores Doesn't Splat Potion")、Equipment("Enchanted Anarchist's Battle Rattle")与Consumable("Carl's Jug O' Boom"),其中前两个各自携带creator与contents,与文档"只有 Equipment 与 Consumable 具备 Created/HasContents"的断言一一对应。
源码佐证:ItemResolver 与 decodeItemSpec
Combine 之后的类型分发逻辑集中在 dcc-schema.ts 的ItemResolver中,它是Item接口(以及两个 unionSafeRoomStock/ClubStock)共用的planType实现:
const ItemResolver = { planType($itemSpec) { const $decoded = lambda($itemSpec, decodeItemSpec); const $__typename = get($decoded, "__typename"); return { $__typename, planForType(t) { const $id = get($decoded, "id"); const $db = context().get("dccDb"); if (t.name === "Equipment") { return loadOne($id, { load: batchGetEquipmentById, shared: $db }); } if (t.name === "Consumable") { return loadOne($id, { load: batchGetConsumableById, shared: $db }); } if (t.name === "UtilityItem") { return loadOne($id, { load: batchGetUtilityItemById, shared: $db }); } if (t.name === "MiscItem") { return loadOne($id, { load: batchGetMiscItemById, shared: $db }); } return null; }, }; }, } as InterfacePlan<ItemSpec>;这里的关键在于数据层的"规格"设计:dcc-data.ts中定义了模板字符串类型
export type ItemType = "Equipment" | "Consumable" | "UtilityItem" | "MiscItem"; export type ItemSpec = `${ItemType}:${number}`;即物品在数据库中一律以"类型:ID"字符串(如"Consumable:205")表示,见 dcc-data.ts。而decodeItemSpec负责把它拆回{__typename, id}(dcc-schema.ts):
function decodeItemSpec(itemSpec: ItemSpec): { __typename: string; id: number } { const [__typename, rawID] = itemSpec.split(":"); const id = parseInt(rawID, 10); return { __typename, id }; }于是整个数据流可以概括为:
GetContents_1/2与GetCreator_1/2分别取出contents(ItemSpec[])与creator(number);- 相同逻辑路径的步骤被Combine归并;
decodeItemSpec把每个ItemSpec解码为类型与 id;planForType(t)依据具体类型,把 id 路由到batchGetEquipmentById、batchGetConsumableById等对应的批量加载器。
这种"单一规格字符串 + 接口 plan 分发"的模式,正是 Grafast 多态查询的典型写法:上层统一以规格(specifier)描述数据,下层按具体类型精细加载。
为什么只有 Equipment 与 Consumable 需要 Combine
对照文档第二句断言可作如下推理:MiscItem与UtilityItem的items字段只派生"自身数据"步骤,不存在需要跨类型归并的creator/contents路径;而Equipment与Consumable都实现Created与HasContents,在查询中会对同一逻辑字段(creator、contents)分别生成步骤。若不 Combine,同一批 id 会被重复加载;Combine 之后,两个类型的"取创造者/取内容物"步骤共享同一执行路径,既保证了 N+1 问题的最小化,也让下游decodeItemSpec只出现一次。
这一点也可以从getCreator的实现得到旁证——dcc-schema.ts 中Equipment.creator与Consumable.creator的 plan 完全指向同一个辅助函数:
function getCreator($source: Step<{ creator?: number }>) { const $db = context().get("dccDb"); const $id = inhibitOnNull(get($source, "creator")); return loadOne($id, { load: batchGetCrawlerById, shared: $db }); }两个类型共用同一段 plan 逻辑,正是它们在计划图中能够(也应该)被 Combine 的结构性前提。
测试机制:计划图如何被固化为快照
consumable-items的验收不止停留在文档断言,queries-test.ts 将其落成了可自动执行的测试。该测试文件对queries/下所有*.test.graphql逐操作执行:
- 通过
grafast(...)执行查询,并把结果经streamToArray/resolveStreamDefer归一化(queries-test.ts); - 对非增量操作校验
result.data与consumable-items.json5快照完全一致;对带@incremental的操作校验与同一快照顺序无关地一致(增量结果允许乱序到达); - 通过
@incremental指令标记增量操作,并将增量签名写入consumable-items.ConsumableItemsDefer1.incsig.json5等文件——例如 Defer1 的签名表明增量仅在顶层{crawler}字段发生; - 利用
makeBaseArgs()中grafast: { explain: true }的配置(dcc-schema.ts),从result.extensions.explain.operations中取出计划,经planToMermaid渲染为 Mermaid 快照并落盘(queries-test.ts)。
也就是说,consumable-items.md 中的 Mermaid 图既是设计文档,也是最终快照所固化的期望形态——文档与测试共同构成"计划即契约"的闭环。
从本案例出发的 Grafast 实践要点
- 接口多态设计要显式规划 Combine:当多个实现类型共享同一组接口字段时,预期它们在计划图中合并,而不是各自为战;合并不是巧合,而是 plan 结构优化(减少重复加载、合并批量调用)的必然结果;
- 用"规格字符串"统一数据入口:
"Type:id"式的ItemSpec让所有类型的数据访问先汇聚到decodeItemSpec一次解码,再按类型分发,避免为每种类型各写一套解码逻辑; planType与planForType分工明确:planType负责判定具体类型(产出$__typename),planForType(t)负责按类型返回对应数据步骤;两者共同支撑Equipment/Consumable等类型在运行时被正确解析;- 以快照测试守护计划结构:借助
explain输出 + Mermaid 快照,任何导致 Combine 消失或拓扑变化的改动都会在 CI 中被立即发现。
结语
consumable-items虽是一个很小的测试用例,却浓缩了 Grafast 多态接口计划编排的三个核心机制:接口子集差异驱动的步骤派生、Combine 对重复逻辑路径的归并、以及planType/planForType + 规格解码的分发链路。理解这三点,再对照 consumable-items.md 的计划图与 queries-test.ts 的测试机制,即可在自己的 Grafast Schema 中复现并验证类似的多态查询优化行为。
- 后端
- API网关
【免费下载链接】crystal
🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!
相关推荐
RustDesk 远程桌面完全指南:从首次连接到团队部署的自托管实践
RustDesk 远程桌面完全指南:从首次连接到团队部署的自托管实践 RustDesk 是一款用 Rust 编写的开源远程桌面软件,定位为 TeamViewer
音视频通信网络精通Video Combine节点:7个高效视频合并策略深度解析
精通Video Combine节点:7个高效视频合并策略深度解析 在ComfyUI VideoHelperSuite中,Video Combine节点作为视频工
音视频AI 应用Apache Doris查询计划深度解析:如何看懂EXPLAIN输出并优化SQL性能
Apache Doris查询计划深度解析:如何看懂EXPLAIN输出并优化SQL性能 Apache Doris作为一款高性能的统一分析数据库,其查询优化能力备受
OLAP数据库大数据实时分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考