多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PostGraphile V5 迁移指南:用 Grafast 计划(Plans)重写 makeExtendSchemaPlugin

PostGraphile V5 迁移指南:用 Grafast 计划(Plans)重写 makeExtendSchemaPlugin 后端API网关【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址https://gitcode.com/gh_mirrors/cry/crystal点击查看免费下载导读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、embedSavepoints保存点context.pgClient.queryQueryBuilder 的 named childrenQueryBuilder 本身build.getTypeAndIdentifiersFromNodeId为确保引用类型之前先加载类型而编写的各种 hack官方文档标注的 TODO仍需为scope指令寻找替代方案。这一切变化的核心原因只有一个V5 用计划plans取代了 resolver。从技术上说与外部系统交互时你仍然可以继续使用 resolver但上述指令行为必须用计划来复刻——既然都要学不如直接全面拥抱 plans。这一范式变化在仓库源码中也有直接体现。例如 PgV4SimpleSubscriptionsPlugin.ts 就是 V5 官方插件中一个完整的extendSchema用例它不再注册任何 resolver而是通过typeDefsobjectsplans的组合声明订阅字段// 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订阅逻辑迁入subscribePlanV4 中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(Youre 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 增强数据获取但始终有引入 N1 问题的风险。许多用户觉得它令人困惑经常拿它来给自己取数据在 resolver 里用——这完全偏离了它的设计意图。V5 中不再需要这个辅助函数每个计划步骤都被自动纳入规划系统N1 问题由 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( sqlselect i from generate_series(1, ${sql.value( count ?? 1, )}) as i;, ), ); // 做一点异步工作比如调用 Stripe 等 await sleep(2); // 在事务内再执行一些 SQL await client.query(sql.compile(sqlselect 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.tsspecFromNodeId接收 handler 与$idstep内部先用lambdadecodeNodeIdWithHandler完成解码与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 新机制说明makeExtendSchemaPluginextendSchema来自postgraphile/utils回调签名从(build, options)变为(build)options 在build.options中resolverplanplans或objects.*.plans计划按需执行未被请求的字段不执行requires(columns: [...])$parent.get(col_name)直接按数据库列名取值pgField直接为字段写计划指令本身不再有意义pgQuerySQL 表达式计划 或lambda数据库内计算用$user.select(...)JS 内计算用lambdapgSubscriptionsubscribePlanlisten(...)topic 选择从指令元数据变为普通代码selectGraphQLResultFromTablepgResource.execute({ step })N1 由 Grafast自动解决embed无替代不再需要Savepoints按需事务不再为每个请求预建事务context.pgClient.queryloadOneWithPgClient/withPgClientTransaction等客户端按需供应仍可批量QueryBuilder named children直接 Grafast步骤概念移除QueryBuilderpgSelect等步骤的辅助方法底层查询构建被计划系统接管build.getTypeAndIdentifiersFromNodeIdspecFromNodeIdbuild.getNodeIdHandler(typeName)已知类型时避免多态开销迁移策略建议最后给出三条实战建议先按原样平移优先把typeDefs/resolvers平移为typeDefs/plans这是最省事的迁移路径官方文档也承认这一点再演进到类型安全模式有余力时把plans收进objects内部享受类型安全的收益逐字段而非逐插件迁移pgQuery、pgSubscription等机制相互独立可以按字段逐个替换不必一次性推倒重来。PostGraphile V5 的迁移本质上是一次思维转换从 告诉系统取哪些数据resolver lookahead转向 声明数据如何从数据库流向客户端plan。理解了这个转换makeExtendSchemaPlugin时代的每一项 hack 都能在 Grafast的计划世界里找到更干净、更高效的归宿。赞分享后端API网关【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址https://gitcode.com/gh_mirrors/cry/crystal点击查看免费下载相关推荐PostGraphile V5 演进全解析从 Grafast 重写到 5.1.4 的关键变更指南PostGraphile V5 演进全解析从 Grafast 重写到 5.1.4 的关键变更指南 本篇技术指南以 postgraphile/postgraph后端API网关PostGraphile V5 迁移指南用 wrapPlans 取代 makeWrapResolversPluginPostGraphile V5 迁移指南用 wrapPlans 取代 makeWrapResolversPlugin PostGraphile V5 全面转向后端API网关PostGraphile V5 迁移指南从 makeAddPgTableOrderByPlugin 到 addPgTableOrderByPostGraphile V5 迁移指南从 makeAddPgTableOrderByPlugin 到 addPgTableOrderBy PostGraph后端API网关上一篇实时音频导入神器为Unreal Engine注入动态音频处理能力下一篇sbt性能优化10个技巧让你的构建速度翻倍创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表