- 后端
- API网关
【免费下载链接】crystal
🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!
导读:PostGraphile V5 用全新的 Grafast规划与执行引擎取代了 V4 的 "lookahead" 系统,这使得
makeExtendSchemaPlugin时代的一系列变通手段(@requires、@pgField、@pgQuery、selectGraphQLResultFromTable、Savepoints、QueryBuilder 等)全部失去存在意义。本篇基于 PostGraphile V5 官方迁移文档,逐项对照 V4 旧写法与 V5 新写法,手把手教你把扩展 Schema 的插件从 "resolver + 指令" 范式重写为 "plan + 标准步骤" 范式,并补充仓库源码级实现依据,帮助你一次性完成迁移。
迁移背景:从 Lookahead 到 Grafast计划引擎
PostGraphile V4 的扩展机制建立在 "look-ahead"(前瞻)系统之上——系统在解析阶段提前探测查询需要哪些数据,再以各种 hack 手段把这些信息塞给 resolver。这套体系虽然可用,但既脆弱又难以理解。V5 将执行内核替换为 Grafast规划与执行引擎后,绝大多数过去的变通手段都不再需要,其中包括:
- 指令:
@requires、@pgField、@pgQuery - 辅助函数:
selectGraphQLResultFromTable、embed - Savepoints(保存点)
context.pgClient.query- QueryBuilder 的 "named children"
- QueryBuilder 本身
build.getTypeAndIdentifiersFromNodeId- 为确保引用类型之前先加载类型而编写的各种 hack
(官方文档标注的 TODO:仍需为@scope指令寻找替代方案。)
这一切变化的核心原因只有一个:V5 用计划(plans)取代了 resolver。从技术上说,与外部系统交互时你仍然可以继续使用 resolver,但上述指令行为必须用计划来复刻——既然都要学,不如直接全面拥抱 plans。
这一范式变化在仓库源码中也有直接体现。例如 PgV4SimpleSubscriptionsPlugin.ts 就是 V5 官方插件中一个完整的extendSchema用例,它不再注册任何 resolver,而是通过typeDefs+objects+plans的组合声明订阅字段:
// postgraphile/postgraphile/src/plugins/PgV4SimpleSubscriptionsPlugin.ts(节选) export const PgV4SimpleSubscriptionsPlugin = extendSchema((build) => { return { typeDefs: [ gql` extend type Subscription { listen(topic: String!): ListenPayload } type ListenPayload { event: String } `, ], objects: { Subscription: { plans: { listen: { subscribePlan(_, { $topic }) { const $pgSubscriber = context().get("pgSubscriber"); const $derivedTopic = lambda( $topic, (topic) => `postgraphile:${topic}`, ); return listen($pgSubscriber, $derivedTopic, jsonParse); }, plan($event) { return $event; }, }, }, }, }, }; });在继续之前,先明确 V5 插件工厂函数的入口变化:
-const { makeExtendSchemaPlugin, gql } = require("graphile-utils"); +const { extendSchema, gql } = require("postgraphile/utils");makeExtendSchemaPlugin在 V5 中由extendSchema取代,回调接收build对象,返回的配置从{ typeDefs, resolvers }变为{ typeDefs, plans }(更推荐{ typeDefs, objects },见下文selectGraphQLResultFromTable一节)。
@requires:改为从父计划中.get()字段
V4 中@requires(columns: [...])用来确保传入 resolver 的父对象携带指定列(尽管这些列可能被转换为驼峰命名,导致大小写不一致的困扰)。
在 V5 的计划中,你只需对父计划调用.get(...)即可取到对应列。下面是官方文档中一个 V4 示例的完整迁移对照,功能是把price_in_us_cents通过convertUsdToAud函数转换为澳元:
-const { makeExtendSchemaPlugin, gql } = require("graphile-utils"); +const { extendSchema, gql } = require("postgraphile/utils"); const { convertUsdToAud } = require("ficticious-npm-library"); +const { lambda } = require('postgraphile/grafast'); -const MyForeignExchangePlugin = makeExtendSchemaPlugin((build, options) => { +const MyForeignExchangePlugin = extendSchema((build) => { + const { options } = build; return { typeDefs: gql` extend type Product { - priceInAuCents: Int! @requires(columns: ["price_in_us_cents"]) + priceInAuCents: Int! } `, - resolvers: { + plans: { Product: { - priceInAuCents: async (product) => { - // Note that the columns are converted to fields, so the case changes - // from `price_in_us_cents` to `priceInUsCents` - const { priceInUsCents } = product; - return await convertUsdToAud(priceInUsCents); - }, + priceInAuCents($product) { + const $cents = $product.get('price_in_us_cents'); + return lambda($cents, cents => convertUsdToAud(cents)); + }, }, }, }; });这里有两个值得注意的设计细节:
$product.get(...)接收数据库原始列名(snake_case),不再有 V4 中大小写换算的麻烦;lambda是逐值转换。如果convertUsdToAud能一次批量转换多个币值,更高效的做法是用loadOne只调用一次,而不是用lambda每个值调用一次。
@pgField:指令消失,计划自然接管
@pgField从诞生起就是一个 workaround,在 V5 中它已无意义——只要你把正确的计划挂到正确的字段上,一切都会按预期工作,而且比 V4 的许多模式(尤其是 mutation payload 相关)更高效、更直白。
官方文档在此给出了一条实用建议:不要总想着在一个字段里做完所有事。更好的做法是给子字段各自分配计划,这样相关逻辑只在字段确实被请求时才执行,代码也会更简洁。这正是计划系统 "按需执行" 的核心价值——未被请求的子计划根本不会执行。
@pgQuery:内联 SQL 改为计划
V4 中@pgQuery用于把 SQL 内联进 GraphQL 操作,通常是作为性能优化,绕过 PostgreSQL 未能内联的计算列函数等问题。
V5 中这一需求交给计划处理。根据目标不同,你有多种计划可选。
叶子字段场景——需要在数据库而非 JS 中完成计算时,可以使用 SQL 表达式:
-module.exports = makeExtendSchemaPlugin(build => { +module.exports = extendSchema(build => { const { pgSql: sql } = build; return { typeDefs: gql` extend type User { - nameWithSuffix(suffix: String!): String! @pgQuery( - fragment: ${embed( - (queryBuilder, args) => - sql.fragment`(${queryBuilder.getTableAlias()}.name || ' ' || ${sql.value( - args.suffix - )}::text)` - )} - ) + nameWithSuffix(suffix: String!): String! } `, + objects: { + User: { + plans: { + nameWithSuffix($user, { $suffix }) { + return $user.select( + sql`${$user.getClassStep().alias}.name || ' ' || ${$user.placeholder($suffix, TYPES.text)}`, + TYPES.text, + ); + } + } + } + } }; });关于 SQL 注入:上面的代码不是SQL 注入示例。它使用
sql标签模板字符串函数(来自 pg-sql2 模块)确保所有参数都被正确处理为绑定参数,而不是字符串拼接。这正是pg-sql2设计的核心价值——所有值都必须通过sql.value(...)或placeholder(...)包装。
更优的 JS 方案——官方文档指出,这个问题在 JS 中处理更简单也更高性能:
+ plans: { + User: { + nameWithSuffix($user, { $suffix }) { + return lambda( + [$user.get("name"), $suffix], + ([name, suffix]) => `${name} ${suffix}`, + ); + }, + }, + },SQL 表达式计划的更多细节,可参考 @dataplan/pg 的文档与源码,例如 steps 目录 下的pgClassExpression、pgSelect等步骤实现。
@pgSubscription:订阅逻辑迁入subscribePlan
V4 中,@pgSubscription(来自@graphile/pg-pubsub)让你在 SDL 中嵌入一个 topic 生成器。V5 中应移除该指令,改为在 Grafast的subscribePlan中使用listen(...)承载逻辑。
V4 写法:
import { makeExtendSchemaPlugin, gql, embed } from "graphile-utils"; const currentUserTopicFromContext = async (_args, context) => { if (!context.jwtClaims?.user_id) throw new Error("You're not logged in"); return `graphql:user:${context.jwtClaims.user_id}`; }; export default makeExtendSchemaPlugin(() => ({ typeDefs: gql` extend type Subscription { currentUserUpdated: UserSubscriptionPayload @pgSubscription(topic: ${embed(currentUserTopicFromContext)}) } type UserSubscriptionPayload { user: User event: String } `, resolvers: { UserSubscriptionPayload: { user(event) { /* ... */ }, }, }, }));V5 写法:
import { extendSchema } from "postgraphile/utils"; export default extendSchema((build) => { const { grafast: { context, get, listen, lambda }, dataplanJson: { jsonParse }, pgResources: { users }, } = build; return { typeDefs: /* GraphQL */ ` extend type Subscription { currentUserUpdated: UserSubscriptionPayload } type UserSubscriptionPayload { user: User event: String } `, objects: { Subscription: { plans: { currentUserUpdated: { subscribePlan(_$root, _args) { const $pgSubscriber = context().get("pgSubscriber"); const $userId = get(context().get("jwtClaims"), "user_id"); const $topic = lambda($id, (id) => `graphql:user:${id}`); return listen($pgSubscriber, $topic, jsonParse); }, plan($event) { return $event; }, }, }, }, UserSubscriptionPayload: { plans: { user($payload) { const $id = get($payload, "subject"); return users.get({ id: $id }); }, }, }, }, }; });迁移的关键点在于:topic 选择现在是subscribePlan中的普通代码,而不是指令元数据。context()拿到 GraphQL 上下文,get从上下文中提取字段,lambda把user_id转换为 topic 字符串,listen负责订阅 pgSubscriber 并解析事件,jsonParse把原始消息解析为结构化数据。
如果 V4 的 topic 来自字段参数,则在subscribePlan中用fieldArgs.getRaw(...)取原始参数:
const $forumId = fieldArgs.getRaw("forumId");然后把这个 step 用于构造$topic即可。订阅相关的更多指导,参见 Realtime 与 Subscriptions。
selectGraphQLResultFromTable:被pgResource.execute取代
V4 中这个方法用于从 GraphQL resolver 内发起 "look-ahead" 增强数据获取,但始终有引入 N+1 问题的风险。许多用户觉得它令人困惑,经常拿它来给自己取数据在 resolver 里用——这完全偏离了它的设计意图。
V5 中不再需要这个辅助函数:每个计划步骤都被自动纳入规划系统,N+1 问题由 Grafast自动解决。获取数据的入口与填充数据的入口合二为一,不再有歧义。
官方文档演示了如何把 V4 文档中的示例移植到 V5:先找到代表match_user函数的pgResource,再为Query.matchingUser字段添加计划,把searchText参数传入函数执行:
-module.exports = makeExtendSchemaPlugin((build) => { +module.exports = extendSchema((build) => { + const matchUser = build.pgResources.match_user; return { typeDefs: /* GraphQL */ ` type Query { matchingUser(searchText: String!): User } `, - resolvers: { + plans: { Query: { - matchingUser: async (parent, args, context, resolveInfo) => { - const [row] = await resolveInfo.graphile.selectGraphQLResultFromTable( - sql.fragment`(select * from match_user(${sql.value( - args.searchText, - )}))`, - () => {}, // no-op - ); - return row; - }, + matchingUser($parent, { $searchText }) { + return matchUser.execute({ step: $searchText }); + }, }, }, }; });注意:
typeDefs/plans模式已弃用。上面展示的是最省事的迁移路径,但由于难以做到类型安全,官方更推荐新的typeDefs/objects模式——把计划放进对象类型内部:
-module.exports = makeExtendSchemaPlugin((build) => { +module.exports = extendSchema((build) => { + const matchUser = build.pgResources.match_user; return { typeDefs: /* GraphQL */ ` type Query { matchingUser(searchText: String!): User } `, - resolvers: { + objects: { Query: { - matchingUser: async (parent, args, context, resolveInfo) => { - const [row] = await resolveInfo.graphile.selectGraphQLResultFromTable( - sql.fragment`(select * from match_user(${sql.value( - args.searchText, - )}))`, - () => {}, // no-op - ); - return row; - }, + plans: { + matchingUser($parent, { $searchText }) { + return matchUser.execute({ step: $searchText }); + }, + }, }, }, }; });你可以选择一次性迁移到新模式,也可以分两阶段过渡(先plans再objects)。
embed:没有替代品
目前没有embed的替代方案。理论上你不再需要它——如果确实遇到了非用它不可的场景,建议去 Graphile 官方社区(Discord)询问。在仓库中你仍能看到embed的残留形态,例如 PgV4SimpleSubscriptionsPlugin.ts 使用了EXPORTABLE(来自 graphile-utils)来把闭包序列化为可导出的代码,这是比embed更规范的同类场景解法。
Savepoints:按需事务取代保存点
PostGraphile V4 中,每个 GraphQL 请求都被包裹在一个事务里。为了符合 GraphQL 规范,每个 mutation 又必须包在SAVEPOINT中,确保单个 mutation 失败时其他 mutation 不会被回滚(即所谓的 "partial success" 部分成功)。
V5 中事务改为按需创建,savepoint 不再必要。这对关注SAVEPOINT子事务性能开销的应用(PostgreSQL 子事务在大量使用时确有性能影响)是个好消息。
context.pgClient.query:按需客户端 + 专用步骤
V4 在每个 GraphQL 请求开始时就准备一个 Postgres 客户端并放入事务(即使不需要),把它塞进 GraphQL context 的pgClient字段供 mutation 使用。
V5 的 Postgres 客户端改为按需供应:
- 自定义读取:使用
loadOneWithPgClient()/loadManyWithPgClient(),这样仍能受益于 Grafast的批量(batching)能力; - mutation:根据是否需要显式事务,选择
sideEffectWithPgClient()或sideEffectWithPgClientTransaction()(仓库中还有直接暴露的withPgClientTransaction)。
需要说明的是,这里的 pgClient 是一个通用适配器(见 executor.ts 中的PgClient接口),你可以把喜欢的 Postgres 客户端(pg、postgres、pg-promise等)接到上面使用。这些辅助函数均在 @dataplan/pg 的导出 中公开,其底层实现(如SideEffectWithPgClientStep类)位于 withPgClient.ts,核心思想是把回调执行建模为一个标准的 Grafaststep,从而纳入整个计划系统。
官方文档给出的完整自定义 mutation 示例:
import { object } from "postgraphile/grafast"; import { withPgClientTransaction } from "postgraphile/@dataplan/pg"; import { extendSchema } from "postgraphile/utils"; export default extendSchema((build) => { const { sql } = build; /** * 这里的 'executor' 告诉我们正在与哪个数据库通信,这是默认 executor。 * * 如果你要连接多个数据库,可以从 registry 中获取;默认 executor 名为 * `main`,但你可以通过 `pgServices` 配置项覆盖它并添加额外的 * sources/executors: * * const executor = build.input.pgRegistry.pgExecutors.main; */ const executor = build.pgExecutor; return { typeDefs: /* GraphQL */ ` input MyCustomMutationInput { count: Int } type MyCustomMutationPayload { numbers: [Int!] } extend type Mutation { """ 一个示例 mutation:本身不做有意义的事,用 Postgres 的 generate_series() 返回一组数字。 """ myCustomMutation(input: MyCustomMutationInput!): MyCustomMutationPayload } `, objects: { Mutation: { plans: { myCustomMutation(_$root, { $input: { $count } }) { /** * 这个 step 决定作为第二个参数传给 `withPgClientTransaction` * 回调的数据。通常是字段参数、GraphQL 上下文细节, * 或之前已执行 step 的数据。 */ const $data = object({ count: $count, }); // 回调会收到一个处于事务中的 client;它返回的普通数据 // 就是该 step 的结果;如果回调抛错,事务回滚且错误成为 // 该 step 的结果。 const $transactionResult = withPgClientTransaction( executor, $data, async (client, data) => { // 来自上面 $data step 的数据 const { count } = data; // 执行一些 SQL const { rows } = await client.query( sql.compile( sql`select i from generate_series(1, ${sql.value( count ?? 1, )}) as i;`, ), ); // 做一点异步工作(比如调用 Stripe 等) await sleep(2); // 在事务内再执行一些 SQL await client.query(sql.compile(sql`select 1;`)); // 返回稍后需要的任何数据 return rows.map((row) => row.i); }, ); return $transactionResult; }, }, }, MyCustomMutationPayload: { plans: { numbers($transactionResult) { return $transactionResult; }, }, }, }, }; });这个示例展示了 V5 自定义 mutation 的完整骨架:extendSchema定义输入/载荷类型,objects.Mutation.plans里用withPgClientTransaction(executor, $data, callback)拿到事务内客户端,执行任意多条 SQL 与异步工作,返回值自动成为 payload 计划的数据来源。
QueryBuilder "named children":直接使用 Grafast步骤
这个概念在 V5 中已无用处,可以移植为更直接的 Grafast步骤。如果迁移中遇到困难,可在 Graphile 官方社区(Discord)寻求帮助。
QueryBuilder 本身:由pgSelect等步骤取代
QueryBuilder 在 V5 中已不存在,取而代之的是pgSelect及类似步骤上的辅助方法。你不再需要手动操作getTableAlias()、拼接 fragment 等底层细节——计划的表达方式更接近 "想要什么数据" 而非 "如何拼 SQL"。
build.getTypeAndIdentifiersFromNodeId:由specFromNodeId取代
这个辅助函数被specFromNodeId取代。每个实现 Node 接口的 GraphQL 类型都会注册一个 "node ID handler";如果你明确知道typeName,可以通过build.getNodeIdHandler(typeName)拿到它。由此可以确定编码 NodeID 所用的 codec,再把这两者连同 node ID 一起交给specFromNodeId,它会返回节点的规格(specification),典型形如{ id: $id }(其中$id是一个可执行 step),但不同节点类型可能有很大差异。
源码中的实现印证了这一点(见 node.ts):specFromNodeId接收 handler 与$idstep,内部先用lambda+decodeNodeIdWithHandler完成解码与handler.match(decoded)校验,再通过handler.getSpec($decoded)生成规格。当预期对象类型已知时(例如updateUser(id: ID!, ...)mutation),应优先使用specFromNodeId,它避免了NodeStep的额外多态开销。
示例:
const typeName = "User"; const handler = build.getNodeIdHandler(typeName); const objects = { Mutation: { plans: { updateUser(parent, fieldArgs) { const spec = specFromNodeId(handler, fieldArgs.$id); const plan = object({ result: pgUpdateSingle(userSource, spec) }); fieldArgs.apply(plan); return plan; }, }, }, };pgUpdateSingle在仓库中的实现位于 pgUpdateSingle.ts,它与specFromNodeId产出的 spec 配合,即可在不触碰多态机制的情况下完成按 NodeID 的更新。
迁移速查:一张表看完所有对应关系
| V4 旧机制 | V5 新机制 | 说明 |
|---|---|---|
makeExtendSchemaPlugin | extendSchema(来自postgraphile/utils) | 回调签名从(build, options)变为(build),options 在build.options中 |
| resolver | plan(plans或objects.*.plans) | 计划按需执行,未被请求的字段不执行 |
@requires(columns: [...]) | $parent.get('col_name') | 直接按数据库列名取值 |
@pgField | 直接为字段写计划 | 指令本身不再有意义 |
@pgQuery | SQL 表达式计划 或lambda | 数据库内计算用$user.select(...),JS 内计算用lambda |
@pgSubscription | subscribePlan+listen(...) | topic 选择从指令元数据变为普通代码 |
selectGraphQLResultFromTable | pgResource.execute({ step }) | N+1 由 Grafast自动解决 |
embed | 无替代 | 不再需要 |
| Savepoints | 按需事务 | 不再为每个请求预建事务 |
context.pgClient.query | loadOneWithPgClient/withPgClientTransaction等 | 客户端按需供应,仍可批量 |
| QueryBuilder named children | 直接 Grafast步骤 | 概念移除 |
| QueryBuilder | pgSelect等步骤的辅助方法 | 底层查询构建被计划系统接管 |
build.getTypeAndIdentifiersFromNodeId | specFromNodeId+build.getNodeIdHandler(typeName) | 已知类型时避免多态开销 |
迁移策略建议
最后给出三条实战建议:
- 先按原样平移:优先把
typeDefs/resolvers平移为typeDefs/plans,这是最省事的迁移路径,官方文档也承认这一点; - 再演进到类型安全模式:有余力时把
plans收进objects内部,享受类型安全的收益; - 逐字段而非逐插件迁移:
@pgQuery、@pgSubscription等机制相互独立,可以按字段逐个替换,不必一次性推倒重来。
PostGraphile V5 的迁移本质上是一次思维转换:从 "告诉系统取哪些数据"(resolver + lookahead)转向 "声明数据如何从数据库流向客户端"(plan)。理解了这个转换,makeExtendSchemaPlugin时代的每一项 hack 都能在 Grafast的计划世界里找到更干净、更高效的归宿。
- 后端
- API网关
【免费下载链接】crystal
🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!
相关推荐
PostGraphile V5 演进全解析:从 Grafast 重写到 5.1.4 的关键变更指南
PostGraphile V5 演进全解析:从 Grafast 重写到 5.1.4 的关键变更指南 本篇技术指南以 postgraphile/postgraph
后端API网关PostGraphile V5 迁移指南:用 wrapPlans 取代 makeWrapResolversPlugin
PostGraphile V5 迁移指南:用 wrapPlans 取代 makeWrapResolversPlugin PostGraphile V5 全面转向
后端API网关PostGraphile V5 迁移指南:从 makeAddPgTableOrderByPlugin 到 addPgTableOrderBy
PostGraphile V5 迁移指南:从 makeAddPgTableOrderByPlugin 到 addPgTableOrderBy PostGraph
后端API网关
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考