Grafast Plan Resolver 最佳实践声明式步骤图的构建、去重与错误处理【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystalGrafastGraphile 家族的核心执行引擎将 plan resolver 定义为声明式函数它在 plan-time 阶段构建一张由 step 组成的执行图随后由引擎在 execution-time 分批执行这张图。本文以 grafast/website/grafast/plan-resolvers/best-practices.md 为骨架结合 Grafast 源码 深入讲解四条最重要的实战建议——深度提取参数、选对 step 类型、在文件作用域定义回调、以及用流控 step 取代try/catch。读完本文你将能够写出更利于批量优化、可去重、可调试的高质量 plan resolver。心智模型plan-time 与 execution-time 的分野理解所有最佳实践的前提是先牢牢把握 Grafast 的**声明式declarative**本质。正如官方文档 Thinking in plans 所述plan-timeplan resolver 只负责描述值从哪来、如何被变换即创建 step 节点与依赖边DAG此时输入值尚未可知、数据尚未被读取execution-time引擎按依赖关系分批执行值沿依赖边流动最终由 output plan 还原成 GraphQL 规定的响应形状。由于 plan 与 execute 分离step 在最终执行前可以被重排、合并、替换和优化——这正是 Grafast 相对传统逐字段 resolve 方式效率更高的根源也是本文所有建议成立的逻辑基础一切以让引擎看到更多信息、少创建多余节点为准则。深度提取参数Extract arguments deeply当需要访问嵌套的参数值时应直接提取叶子值而不是先提取中间对象、再用lambda从中间对象里取字段。后者会多出一个中间 lambda step削弱引擎的优化空间。假设有以下 Schemainput UserFilter { author: String publishedAfter: Int } type Query { bookCount(search: String, filter: UserFilter): Int! }反例浅提取后再变换function bookCount_plan($parent, fieldArgs) { const $filter fieldArgs.getRaw(filter); // ✘ 产生一个不必要的中间 lambda step const $author lambda($filter, (f) f?.author); // ... }正例直接深度提取function bookCount_plan($parent, fieldArgs) { // ✔ 单一 step可直接参与优化 const $author fieldArgs.getRaw([filter, author]); const $publishedAfter fieldArgs.getRaw([filter, publishedAfter]); // ... }也可以用$前缀快捷语法达到同样效果function bookCount_plan($parent, fieldArgs) { const { $search, $filter } fieldArgs; const { $author, $publishedAfter } $filter; // ... }源码佐证getRaw 的路径解析从源码看getRaw()接受字符串或路径数组并沿路径逐段下钻返回对应叶子 stepgrafast/grafast/src/operationPlan-input.ts#L114-L146路径为字符串时直接返回trackedArguments[path]路径为数组时先取trackedArguments[first]再对剩余段依次执行$entry.at(pathSegment)数字下标用于列表或$entry.get(pathSegment)对象字段若路径中混入非 input object 的字段会抛出明确的报错信息getRaw path must only relate to input objects right now...便于提前发现问题。换句话说getRaw([filter, author])在 plan-time 就能让引擎直接看见需要filter.author这个叶子从而避免为纯字段读取创建冗余的LambdaStep。当前优化器对这一信息的使用还比较有限但深度提取是表达意图的良好习惯并可能在未来的优化中发挥更大作用。选择合适的 step 类型Choose the right step type大多数数据获取场景应优先使用loadOne()或loadMany()——它们自动分批batching并支持去重。琐碎的同步变换字符串拼接、简单算术用lambda()即可。而需要完全掌控执行、去重与优化的场景则应该自建 step 类。三种方式的对比lambdaloadOneCustom stepBatching分批无——每个值调用一次有自动分批并去重batched and uniqued有完全可控Deduplication仅当回调是同一引用时仅当回调是同一引用时通过deduplicate()完全控制Optimization无大量自动优化通过optimize()/finalize()/execute()完全控制注意批处理对 Mutation / 副作用可能不适用按 GraphQL 规范副作用只能发生在Mutation字段中而 Mutation 字段是串行执行的因此没有批处理的机会。Mutation 字段的 plan resolver 通常用sideEffect()与lambda()签名相同但携带副作用标记即可一般不需要其他 step。副作用不应出现在非 Mutation 的 plan resolver 中任何 step 都可以通过标记marking声明自己具有副作用。何时使用 loadOne / loadMany满足以下任一条件时应使用loadOne()每个输入加载一条记录或loadMany()每个输入加载一组记录存在异步工作Mutation 除外存在 I/O 工作Mutation 除外代码能从批处理中受益。loadOne相对 DataLoader 还有额外能力参见 standard-steps/loadOne.md它通过.get(attrName)与.setParam(key, value)做属性/参数追踪把实际需要哪些字段传给回调让后端只取必要数据还可选传ioEquivalence声明输出与输入等价从而把链式拉取优化为并行拉取。何时用 lambdalambda()适合琐碎的同步变换字符串拼接lambda([$first, $last], ([f, l]) ${f} ${l}, true)简单算术lambda($n, (n) n 1, true)不需要批处理收益的普通数据映射从源码看LambdaStep继承自UnbatchedStep每个值调用一次回调grafast/grafast/src/steps/lambda.ts#L52-L57其去重逻辑是peers.filter((peer) peer.fn this.fn)lambda.ts#L48-L50——这正是后文回调必须定义在文件作用域的根本原因。此外若回调带hasSideEffects标记而误用lambda()引擎会打印console.trace提示改用sideEffect()lambda.ts#L77-L82。何时创建自定义 step需要自建 step 类的典型场景想对外暴露自己的辅助 API例如自定义方法需要完全控制执行例如loadOne/loadMany的优化不符合需求需要完全控制去重减少冗余工作需要完全控制 plan 优化尤其是通过与其他 step 通信来消除过度/不足获取只想对自己的 step 做一次性自定义工作自定义finalize()。示例把 Google Drive API 包装成自定义 step下面这个示例官方文档注明未经测试仅作演示把 Google Drivefiles.listAPI 包装成一个自定义 step将多个文件 ID 的查找合并进一次 HTTP 请求并利用fields参数只拉取 GraphQL 查询实际需要的字段。import { Step, ExecutionDetails, access } from grafast; /** 加载 Google Drive 文件元数据把多个 ID 合并成一次 API 调用。 */ export class GoogleDriveFileStep extends StepGoogleDriveFile { /** * 为 graphile-export 提供元数据使其在导出 Schema 时生成正确的 * import 语句moduleName 必须是包名exportName 是类标识符。 */ static $$export { moduleName: my-app, exportName: GoogleDriveFileStep, }; // 记录 plan 实际需要的字段 private fieldPaths: Setstring new Set([id]); constructor($fileId: ExecutableStepstring) { super(); this.addDependency($fileId); } /** * 辅助方法声明下游 step 需要某个字段返回读取该字段的 access step。 */ get(name: string): Step { this.fieldPaths.add(name); return access(this, name); } /** * 辅助方法声明下游 step 需要嵌套字段。 */ getNestedField(parent: string, child: string): Step { this.fieldPaths.add(${parent}(${child})); return access(this, [parent, child]); } // 去重请求同一文件 ID 的 step 合并 deduplicate(peers: GoogleDriveFileStep[]): GoogleDriveFileStep[] { return peers; } // 合并来自去重伙伴的字段需求 deduplicatedWith(peers: GoogleDriveFileStep[]): void { for (const peer of peers) { for (const field of this.fieldPaths) { peer.fieldPaths.add(field); } } } // 对整批执行一次 async execute(details: ExecutionDetails) { const { values: [fileIdEv], indexMap, } details; const uniqueIds [...new Set(indexMap((i) fileIdEV.at(i)))]; // 整批一次 HTTP 请求 const url new URL(https://www.googleapis.com/drive/v3/files); url.searchParams.set(q, uniqueIds.map((id) ${id}).join( or )); url.searchParams.set(fields, fields); const response await fetch(url); const { files } await response.json(); // 建立查找表并按原始顺序返回结果 const byId new Map(files.map((f: GoogleDriveFile) [f.id, f])); return indexMap.map((i) { const fileId fileIdEV.at(i); return byId.get(fileId) ?? null; }); } } export function googleDriveFile($fileId: Stepstring) { return new GoogleDriveFileStep($fileId); }在 plan resolver 中的用法function file_plan($parent) { const $fileId $parent.get(driveFileId); const $file googleDriveFile($fileId); // 只拉取 GraphQL 查询实际请求的字段 const $name $file.get(name); const $ownerEmail $file.getNestedField(owners, emailAddress); // ... }这个示例完整展示了自定义 step 的四个关键生命周期钩子constructor声明依赖、deduplicate/deduplicatedWith合并相同请求与字段需求、execute整批执行一次以及get辅助方法把需要哪个字段反馈给执行器。在文件作用域定义回调Define callbacks at file scope许多 step 函数接受回调参数。务必在文件/模块作用域定义这些回调或从其他文件导入不要内联定义。原因在源码中一目了然Grafast 通过比较回调的引用来决定是否去重——内联函数每次调用都会生成新引用直接摧毁去重能力参见 lambda.ts#L48-L50 的peer.fn this.fn判断。此外具名函数还能让 debug 输出与explain计划更可读toStringMeta()使用fn.displayName || fn.name见 lambda.ts#L44-L46。需要遵守此原则的函数分两档最重要失去去重代价高昂lambda()loadOne()loadMany()applyInput()同样推荐影响较小但原则相同each()filter()groupBy()partitionByIndex()sideEffect()反例内联回调const objects { User: { plans: { fullName($user) { const $firstName $user.get(firstName); const $lastName $user.get(lastName); // ✘ 每次调用都是新函数引用——无法去重 return lambda([$firstName, $lastName], ([f, l]) ${f} ${l}, true); }, }, }, };正例文件作用域回调// ✔ 在模块作用域定义一次——每次都是同一引用 function fullname([firstName, lastName]: [string, string]): string { return ${firstName} ${lastName}; } const objects { User: { plans: { fullName($user) { const $firstName $user.get(firstName); const $lastName $user.get(lastName); return lambda([$firstName, $lastName], fullname, true); }, }, }, };不要在 plan resolver 里使用 try/catchplan resolver 运行在plan-time——输入值未知、数据尚未拉取它只负责构建声明式步骤图。因此try/catch只能捕获规划期错误本就不该发生永远捕获不到执行期错误即真正拉取/加工数据时产生的错误。为什么行不通plan resolver 不执行数据获取逻辑只描述它try块包裹 step 创建捕获不到运行时数据错误因为那些错误发生在稍后的执行阶段用try/catch包裹 step 创建会掩盖本应修复的 plan-time 编程错误它暗示了对 plan/execute 分离这一核心模型的误解。反例包裹 step 的 try/catch// ✘ 这个 try/catch 毫无意义——运行时错误发生在执行期而非规划期 function post_author_plan($post) { try { const $authorId $post.get(authorId); return loadOne($authorId, batchGetAuthorById); } catch (e) { return constant(null); } }正解用 maskError 处理错误呈现GraphQL 被设计为在错误面前继续执行支持部分成功partial success。若希望向用户重贴错误标签应使用 Grafserv 的maskError能力或所用服务器自带的类似机制而不是在 plan resolver 中捕获。必要时的正解使用流控 stepGrafast 提供了声明式流控来在执行期处理错误与空值import { loadOne, trap, inhibitOnNull, TRAP_ERROR } from grafast; function post_author_plan($post) { const $authorId $post.get(authorId); // 让 Grafast 在 authorId 为 null 时跳过加载 const $guardedId inhibitOnNull($authorId); // 加载作者若出错转为 null const $author loadOne($guardedId, batchGetAuthorById); return trap($author, TRAP_ERROR, { valueForError: NULL }); }关键流控 step 一览inhibitOnNull()—— 值或列表中某项为null时抑制下游工作assertNotNull()—— 把null变成对客户端可见的SafeErrortrap()—— 把被抑制或出错的值恢复为普通数据如null、空列表或把错误当作普通数据值而非异常。从源码可以印证这些 step 的语义inhibitOnNull、assertNotNull、trap都是对__FlagStep的封装grafast/grafast/src/steps/__flag.ts#L324-L364inhibitOnNull在acceptFlags中排除FLAG_NULL即遇到null即抑制后续执行assertNotNull在拒绝reject路径上挂一个SafeError(message)把null转换为用户可见错误trap接受TRAPPABLE_FLAGS并把valueForInhibited/valueForError等选项透传给__FlagStep从而把抑制/错误翻译成普通返回值。更多使用时机与细节参见 Thinking in plans: Flow control。总结四条最佳实践对应 Grafast 声明式执行模型的不同侧面深度提取参数用getRaw([a,b])或$前缀解构直达叶子值减少中间 step、保留优化空间选对 step 类型I/O 用loadOne/loadMany以享受批处理、去重与自动优化lambda()只用于琐碎同步变换需要完全控制时自建 step 类并实现deduplicate()/deduplicatedWith()/execute()文件作用域回调回调定义在模块作用域保证引用稳定、step 可去重也让explain输出更可读不要 try/catchplan resolver 是声明式的运行时错误请交给maskError或inhibitOnNull/assertNotNull/trap等流控 step 处理。把这四条原则内化为习惯你的 plan resolver 会天然地更干净、更可批处理、更可优化——这也正是 Grafast 引擎设计意图的最佳实践形态。【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考