ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostGraphile 原始插件 API 实战:用 Graphile Engine 钩子深度扩展你的 GraphQL Schema

PostGraphile 原始插件 API 实战:用 Graphile Engine 钩子深度扩展你的 GraphQL Schema PostGraphile 原始插件 API 实战用 Graphile Engine 钩子深度扩展你的 GraphQL Schema【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal本篇技术指南以 PostGraphile v4 文档《Schema Plugins — Graphile Engine》为核心系统讲解如何绕过graphile-utils高级辅助函数直接使用 Graphile Engine 底层插件钩子hook为 PostGraphile 自动生成的 GraphQL Schema 添加根级字段、包装既有 resolver、以及精准移除指定类型与字段。读完本文你将掌握builder.hook()的核心调用约定、GraphQLObjectType:fields与GraphQLObjectType:fields:field两个关键钩子的用法、addArgDataGenerator的 look-ahead 数据请求机制以及通过--append-plugins将自定义插件加载进 PostGraphile 的完整链路并能据此在你的项目中编写可直接运行的定制插件。背景PostGraphile 的 Schema 就是 Graphile Engine 插件的组合PostGraphile 的 GraphQL Schema 并非单一程序一次性生成的产物而是由大量 Graphile Engine 插件逐层构建而成。每个插件只引入一小块功能并彼此叠加——例如为表生成 OrderBy 枚举、为外键生成关联字段、为计算列生成字段都分别由不同的插件负责。在 PostGraphile v4 时代核心 PG 相关插件的源码位于graphile-engine仓库的packages/graphile-build-pg/src/plugins目录下插件之间的加载顺序具有语义该顺序由graphile-build-pg模块src/index.ts中的defaultPlugins导出定义。顺序之所以重要是因为后加载的插件可以依赖先前插件在build对象上注册的类型、inflector 与工具函数。你可以通过两种方式干预默认插件列表追加插件推荐在默认插件之前或之后追加自己的插件使用 CLI 的--append-plugins或库模式的appendPlugins选项整体替换完全替换用于构建 Schema 的插件列表skipPlugins/appendPlugins的组合可达到等效目的。Graphile Engine 插件体系构建在 GraphQL 参考实现graphql-js之上因此编写自定义插件前建议先熟悉 GraphQL 的字段、类型、resolver 与GraphQLObjectType等基础概念。当前仓库的 v4 兼容层还保留了对应的 V5 实现线索在 postgraphile/postgraphile/src/presets/v4.ts 中可以看到appendPlugins?: GraphileConfig.Plugin[]与skipPlugins?: GraphileConfig.Plugin[]选项被声明底层由PgV4BuildPlugin、PgV4BehaviorPlugin等插件承接近乎相同的职责说明 v4 插件 API 的设计思想在 v5 中得到了延续只是从过程式函数演进为声明式对象。加载自定义插件CLI 与库模式在进入具体钩子编写之前先明确插件的装载方式。文档 extending.mdx 给出了标准做法。通过 CLI 加载# 加载本地文件注意使用 pwd 展开绝对路径 postgraphile \ --append-plugins pwd/add-http-bin-plugin.js \ -c postgres:///mydb # 或加载 npm 包插件 postgraphile \ --append-plugins postgraphile-plugin-connection-filter \ -c postgres:///mydb--append-plugins接受逗号分隔的模块规格列表每条规格是JS 文件的绝对路径或 npm 模块名可选地跟一个冒号和导出名my-npm-module要求module.exports function NpmPlugin(...)或/path/to/module.js:MyPlugin要求exports.MyPlugin function MyPlugin(...)。通过库模式加载const ConnectionFilterPlugin require(postgraphile-plugin-connection-filter); app.use( postgraphile(process.env.DATABASE_URL, app_public, { appendPlugins: [ConnectionFilterPlugin /* 在此继续追加更多插件 */], graphiql: true, }), );⚠️多版本graphql冲突多个版本的graphql同时存在于node_modules会导致类型不匹配等诡异问题。官方强烈建议使用Build对象钩子的第二个参数上的build.graphql命名空间而不是自行require独立版本。核心概念builder.hook钩子调用约定所有原始插件本质是一个接收builder的工厂函数function MyPlugin(builder, options) { builder.hook(HookName, (input, build, context) { // ...过滤与变换... return input; }); }钩子回调的三个参数约定如下参数含义input当前钩子的输入对象例如GraphQLObjectType:fields钩子的输入就是该对象类型的所有字段映射{ fieldName: FieldConfig }buildBuild 对象内含extend、getTypeByName、pgSql、inflection等大量工具context上下文对象最常用的是context.scope用于过滤也包含addArgDataGenerator、Self等属性铁律钩子回调必须返回输入对象或经过变换后的新对象否则下游插件将失去数据源。添加根级 Query / Mutation 字段最常见的需求是为 Schema 增加根级字段例如集成外部服务REST API、第三方 SDK。推荐路线makeExtendSchemaPlugin如果只是简单添加字段官方推荐使用 make-extend-schema-plugin.md 中讲解的makeExtendSchemaPlugin它可以用类似graphql-tools的语法合并类型与 resolver可用于 Schema 任意位置而不仅是根级// add-http-bin-plugin.js const { makeExtendSchemaPlugin, gql } require(graphile-utils); const fetch require(node-fetch); module.exports makeExtendSchemaPlugin({ typeDefs: gql extend type Query { httpBinHeaders: JSON } , resolvers: { Query: { async httpBinHeaders() { const response await fetch(https://httpbin.org/headers); return response.json(); }, }, }, });底层路线GraphQLObjectType:fields钩子如果你需要 look-ahead 特性或者要以更自动化的方式批量定义字段可以降级到原始插件 API。核心思路是挂接GraphQLObjectType:fields钩子借助context.scope.isRootQuery过滤出根 Query 类型然后用extend向字段映射追加新字段// add-http-bin-plugin-raw.js const fetch require(node-fetch); function AddHttpBinPlugin(builder, { pgExtendedTypes }) { builder.hook( GraphQLObjectType:fields, ( fields, // 输入对象——该 GraphQLObjectType 的字段映射 { extend, getTypeByName }, // Build 对象——实用工具 { scope: { isRootQuery } }, // Context 对象——用于过滤 ) { if (!isRootQuery) { // 不是我们想修改的对象类型原样返回输入 return fields; } // 直接复用 Schema 中已有的 JSON 类型避免重复定义引发类型冲突 const JSONType getTypeByName(JSON); return extend(fields, { httpBinHeaders: { type: JSONType, async resolve() { const response await fetch(https://httpbin.org/headers); if (pgExtendedTypes) { // 当通过 postgraphile 的 --dynamic-json 选项启用时返回 JSON 对象 return response.json(); } else { // 未启用 Dynamic JSON 时返回 JSON 字符串 return response.text(); } }, }, }); }, ); } module.exports AddHttpBinPlugin;要点解读钩子对每个对象类型都会执行所以isRootQuery过滤必不可少getTypeByName(JSON)用于获取既有类型。新增字段的返回类型不必通过 Graphile Engine 的newWithHooks创建——标准 GraphQL 对象同样可用如上面的JSONType但要注意未通过newWithHooks创建的对象无法被后续插件扩展若要添加 Mutation 根级字段把isRootQuery换成isRootMutation即可pgExtendedTypes来自插件的选项对应 PostGraphile 的--dynamic-json演示了插件选项的消费方式。进一步深化pgQuery与selectGraphQLResultFromTable上述示例的 resolver 只访问外部 HTTP 服务。当新增字段需要返回数据库记录表、视图、函数返回值时直接使用context.pgClient.query无法利用 PostGraphile 的 look-ahead 机制也就无法正确加载嵌套关联。此时应当使用resolveInfo.graphile.selectGraphQLResultFromTable返回记录/连接/列表或在非根级字段上使用pgQuery指令v4.4.0直接声明数据源例如在User类型上挂一个pets: PetsConnection pgQuery(source: ..., withQueryBuilder: ...)。详细用法参见同目录文档 make-extend-schema-plugin.md。包装既有 resolver在 SQL 执行前后插入自定义逻辑有时需要覆盖既有字段的默认行为。由于 PostGraphile 的工作方式——只有根级 Query 字段的 resolver 负责执行 SQL 查询——resolver 包装在最顶层根级字段最有价值。快速路线makeWrapResolversPluginPostGraphile v4.1 提供了 make-wrap-resolvers-plugin.md 中的makeWrapResolversPlugin按类型名 → 字段名 → 包装函数/规则声明即可module.exports makeWrapResolversPlugin({ User: { async email(resolve, source, args, context, resolveInfo) { const result await resolve(); // 调用被包装的原 resolver return result.toLowerCase(); }, }, });⚠️ 由于 look-ahead 的存在包装 resolver 一般不会改变生成的 SQL。若你想影响系统如何执行如过滤、排序只应在根级 resolver 上包装但用于修饰返回值掩码私密数据、归一化等则任意字段都安全。底层路线GraphQLObjectType:fields:field钩子 addArgDataGenerator当需要更强的控制力时降级到原始 API。与前文GraphQLObjectType:fields操作字段列表不同GraphQLObjectType:fields:field操作单个字段它还能拿到addArgDataGenerator——这是 Graphile Engine look-ahead 系统的入口之一用于提前声明本次查询需要额外请求的数据。下面的例子包装createLinkMutation先校验title长度插入后执行附加任务全程借助addArgDataGenerator保证link.id无论如何都被请求回来function performAnotherTask(linkId) { console.log(We created link ${linkId}!); } module.exports function CreateLinkWrapPlugin(builder) { builder.hook( GraphQLObjectType:fields:field, ( field, { pgSql: sql }, { scope: { isRootMutation, fieldName }, addArgDataGenerator }, ) { if (!isRootMutation || fieldName ! createLink) { // 该钩子对 Schema 中每个对象类型的每个字段都会执行 // 只有根 Mutation 上的 createLink 才需要处理其余原样返回。 return field; } // 利用 addArgDataGenerator 保证 link.id 总是被 SELECT // 即使客户端没有请求它。别名以 __ 开头——GraphQL 规范禁止 // 用户字段以此开头因此绝不会与用户字段冲突。 addArgDataGenerator(() ({ pgQuery: (queryBuilder) { queryBuilder.select( // 从 INSERT 的返回结果中选取 id sql.query${queryBuilder.getTableAlias()}.id, // 在结果数据中的名字 __createdRecordId, ); }, })); // 字段可能没有显式 resolve此时回退到默认 resolver const defaultResolver (obj) obj[fieldName]; // 从 field 中摘出旧 resolver可能不存在 const { resolve: oldResolve defaultResolver, ...rest } field; return { // 除 resolve 外全部保留 ...rest, // 新增包装 resolver async resolve(...resolveParams) { // 1) 在调用旧 resolver 之前做校验或其他前置动作 const RESOLVE_ARGS_INDEX 1; const { input: { link: { title }, }, } resolveParams[RESOLVE_ARGS_INDEX]; if (title.length 3) { throw new Error(Title is too short!); } // 2) 调用旧 resolver。注意除非同时修改传给它的 // 第 4 个参数AST否则不应改动其入参代价很高。 const oldResolveResult await oldResolve(...resolveParams); // 3) 记录创建成功后的附加任务 await performAnotherTask(oldResolveResult.data.__createdRecordId); // 4) 返回原结果 return oldResolveResult; }, }; }, ); };这个模式的用途非常广用户注册后发邮件、记录失败的认证尝试、审计日志……几乎任何在数据库操作前后附加副作用的场景都可以套用。从 Schema 中移除元素删插件 vs 删字段⚠️ 危险警告以移除后再添加同名类型/字段的方式操作 Schema 可能引发不可预期的后果类型/字段冲突、钩子行为异常。官方建议与其事后移除不如从源头避免其生成。 想简单阻止某些表、字段、函数或关联进入 Schema请优先使用 smart-comments.mdx 介绍的 smart comments如omit而不是编写移除插件。移除一类事物直接移除对应插件如果想去掉一整类功能最干净的做法是不加载生成它们的插件。例如不再允许按表的所有列排序只允许主键排序→ 省略PgOrderAllColumnsPlugin不需要计算列 → 省略PgComputedColumnsPlugin。这些插件源码位于graphile-build-pg的src/plugins/目录可从其defaultPlugins导出中定位并剔除。移除某个具体事物挂接到所有者对象当需要外科手术式的精准移除如从Foo类型删掉bar字段应挂接拥有该事物的对象的钩子返回删减后的字段集。下面是一个可复用的插件生成器用于按对象名字段名移除单个字段const omit require(lodash/omit); function removeFieldPluginGenerator(objectName, fieldName) { const fn function (builder) { builder.hook(GraphQLObjectType:fields, (fields, _, { Self }) { if (Self.name ! objectName) return fields; return omit(fields, [fieldName]); }); }; // 便于调试 fn.displayName RemoveFieldPlugin:${objectName}.${fieldName}; return fn; } const RemoveFooDotBarPlugin removeFieldPluginGenerator(Foo, bar); module.exports RemoveFooDotBarPlugin;要点context.Self.name用于识别当前正在构建的对象类型仅在目标类型上执行删除fn.displayName不是必须的但能显著改善插件调试与报错信息可读性该示例用于演示移除插件的写法实际项目中 smart comments 通常是更优解。从 v4 原始插件到 v5钩子体系的演进速览如果你正在把 v4 原始插件迁移到 PostGraphile v5了解以下对应关系详见 migrating-custom-plugins.md有助于理解本文 API 的设计动机v4 的过程式插件(builder) { builder.hook(...) }演进为 v5 的声明式对象{ name, version, schema: { hooks: { ... } } }钩子名中的:被替换为_GraphQLObjectType:fields→GraphQLObjectType_fieldsv5 的 Grafast 计划系统取代了 v4 的 look-ahead 引擎addArgDataGenerator、QueryBuilder、selectGraphQLResultFromTable等 API 不复存在QueryBuilder.getTableAlias()大致对应$pgSelect.aliasqueryBuilder.where(...)对应$pgSelect.where((sql) ...)v4 的newWithHooks被build.registerObjectType/registerInterfaceType等注册方法取代v4 的appendPlugins/skipPlugins选项在 v4.ts 中仍被声明作为兼容层将 v4 配置转换为 v5 的 Graphile Config 插件体系。总结与最佳实践清单场景推荐方案底层 API添加字段/类型makeExtendSchemaPlugingraphile-utilsGraphQLObjectType:fields钩子 extend包装 resolvermakeWrapResolversPluginv4.1GraphQLObjectType:fields:field钩子 addArgDataGenerator需要 look-ahead 的数据库字段pgQuery指令 /selectGraphQLResultFromTable数据生成器v4 特有移除整类功能省略对应插件如PgOrderAllColumnsPlugin—精准移除单个字段优先 smart commentsomitGraphQLObjectType:fields钩子 omit编写原始插件的三条纪律每个钩子必须返回变换后的输入对象否则会破坏后续插件链用context.scope过滤目标isRootQuery、isRootMutation、Self.name等因为钩子对所有匹配对象都会触发优先使用build.graphql命名空间避免多版本graphql冲突并尽量复用 Schema 既有类型如getTypeByName(JSON)而不是重新创建。【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进