ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

@grafserv/persisted 解析:为 Grafserv 与 PostGraphile 构建持久化操作(Persisted Operations)支持

@grafserv/persisted 解析:为 Grafserv 与 PostGraphile 构建持久化操作(Persisted Operations)支持 grafserv/persisted 解析为 Grafserv 与 PostGraphile 构建持久化操作Persisted Operations支持【免费下载链接】crystal Graphiles Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!项目地址: https://gitcode.com/gh_mirrors/cry/crystal本文以 Graphile Crystal Monorepo 中 grafast/grafserv-persisted/CHANGELOG.md 的版本演进为线索结合同包 README.md 与 src/index.ts 源码完整剖析grafserv/persisted插件的能力、配置、实现原理与工程化演进。读完本文你将掌握如何在 Grafserv含 PostGraphile中启用持久化操作、理解其五个核心配置选项的取舍以及从 Relay / graphql-codegen / Apollo Client 端生成并提交持久化操作的完整实战方案。插件缘起从一条 Changeset 诞生的持久化操作支持在 CHANGELOG 最底部的0.0.0-1.1版本中记录了这个包的诞生New grafserv/persisted plugin to add persisted operations support to Grafserv 所谓持久化操作persisted operations在业界也被称为 persisted queries、query allowlist查询白名单或 persisted documents客户端不再随请求发送完整的 GraphQL 文档字符串而是只发送一个哈希或文档 ID由服务端依据这个标识符解析出预先批准的查询文档。该插件同时适用于标准的 GET 与 POST 请求以及 WebSocket 连接defer、订阅等场景覆盖 query、mutation、subscription 全部操作类型。从安全与运维角度看凡是只打算让第一方客户端使用 GraphQL Schema 的服务无论 PostGraphile 还是其他基于 Grafserv 的服务都建议启用持久化操作一方面可以显著缓解针对 GraphQL API 的恶意攻击面例如探测性查询、深度嵌套 DoS 查询另一方面便于精确追踪 Schema 中哪些字段真正被客户端使用。这正是 README.md 中明确给出的推荐理由。插件工作原理processGraphQLRequestBody中间件PersistedPlugin不是一个独立的 HTTP 服务而是一个 Graphile Config 插件通过挂载到 Grafserv 的processGraphQLRequestBody中间件上来工作。在 src/index.ts 中可以看到插件的完整定义export const PersistedPlugin: GraphileConfig.Plugin { name: PersistedPlugin, description: Enables persisted operations in Grafserv, version, grafserv: { middleware: { processGraphQLRequestBody(next, event) { const { body, resolvedPreset } event; const options resolvedPreset.grafserv; // ... const realQuery persistedOperationFromPayload( body, options, shouldAllowUnpersistedOperation(options, event), ); // 依据解析结果覆盖 body.query随后调用 next() 继续管线 return next(); }, }, }, };这个中间件名称在 Grafserv 侧的真实挂载点位于 grafast/grafserv/src/middleware/graphql.ts请求体解析parseGraphQLBody/parseGraphQLQueryParams完成后若存在processGraphQLRequestBody中间件则对解析出的 body 原地执行they will mutate the body in place随后才进入后续的 GraphQL 执行流程。中间件的注册与编排由 grafast/grafserv/src/hooks.ts 中的middleware.register(processGraphQLRequestBody, ...)完成。哈希提取默认兼容 Apollo 与 Relay插件需要从请求体中提取用于定位持久化操作的哈希。源码中的defaultHashFromPayloadsrc/index.ts按以下优先级依次尝试function defaultHashFromPayload(payload: RequestPayload) { return ( // Apollo Client 协议 payload?.extensions?.persistedQuery?.sha256Hash || // Relay 网络层约定 payload?.documentId || // 非标准兜底 payload?.id ); }即Apollo 客户端通过extensions.persistedQuery.sha256Hash提交哈希Relay 通过documentId提交文档 ID若你的客户端使用其他字段承载哈希则通过hashFromPayload选项自行实现提取逻辑。错误处理与放行机制插件配置缺失时resolvedPreset.grafserv为空抛出 500SafeErrorPersisted operations misconfigured解析不到对应文档时抛出 400SafeErrorPersisted operations are enabled on this server, please provide an approved document id.persistedOperationFromPayload本身被设计为绝不抛异常源码注释 It never throws内部捕获异常并记录日志后返回null由外层统一转换为 400 响应避免中间件崩溃当允许未持久化操作allowUnpersistedOperation返回真值且请求本身携带query字符串时直接放行原查询。核心配置选项五种方式定义持久化操作插件在GraphileConfig.GrafservOptions上新增了五个选项见 src/index.ts 的declare global声明与 README.md 的文档。它们全部为可选但必须且只能配置persistedOperationsDirectory、persistedOperations、persistedOperationsGetter三者之一插件才会有实际意义。hashFromPayloadhashFromPayload?(request: ParsedGraphQLBody): string | undefined—— 从请求对象中提取哈希的自定义函数。默认实现已兼容 Apolloextensions.persistedQuery.sha256Hash与 RelaydocumentId。请求体通常是{query: string, variables?, operationName?, extensions?}但在持久化操作场景下通常没有query属性。persistedOperationsDirectorypersistedOperationsDirectory?: string—— 指定存放持久化操作文件的目录文件必须命名为hash.graphql。该模式下首个请求某个哈希时读取对应文件随后结果被缓存文件系统读取只影响该哈希的第一次使用插件会周期性扫描该目录记录已知文件名对未出现在最近一次扫描结果中的哈希请求会被直接拒绝以缓解针对不存在哈希的拒绝服务攻击避免攻击者驱动大量无效文件系统访问。与之配套的还有persistedOperationsDirectoryScanInterval?: number | watch正整数两次扫描之间的毫秒间隔源码src/index.ts的实现不是简单的setInterval而是扫描完成后等待一个间隔再发起下一次扫描setTimeout(scanDirectory, scanInterval)并带有scanning/scanAgain标志防止重入watch使用fs.watch监听目录变化fsp.watch(directory, { signal, recursive: false })文件变更即触发重新扫描实验性且监听失败时只会打印错误并停止监听建议重启服务-1禁用扫描默认值CHANGELOG0.0.0-alpha.13中明确指出默认行为从每 5 秒轮询改为不轮询。persistedOperationspersistedOperations?: { [hash: string]: string }—— 一个字符串到字符串的映射对象键为哈希值为操作文档字符串。源码中通过persistedOperationGetterForCache(cache)将其包装为(key) cache[key]的 gettersrc/index.ts。适合在构建期静态生成、随代码发布的场景。persistedOperationsGetterpersistedOperationsGetter?: PersistedOperationGetter—— 若已知操作会随时间变化或希望按需惰性加载可提供此函数。其类型定义位于 src/interfaces.tsexport type PersistedOperationGetter (hash: string) PromiseOrDirectstring;注意该函数位于性能关键路径每个请求都会调用文档明确建议在其中使用缓存以提升后续同哈希请求的性能。allowUnpersistedOperationallowUnpersistedOperation?: boolean | ((event: ProcessGraphQLRequestBodyEvent) boolean)—— 在部分场景下允许绕过持久化操作约束例如开发环境使用 GraphiQL或允许管理员在生产环境发起任意请求。文档特别强调该函数绝不能抛出异常IMPORTANT: this function must not throw!。官方示例app.use(postgraphile(DATABASE_URL, SCHEMAS, { allowUnpersistedOperation(event) { return process.env.NODE_ENV development event.request?.getHeader(referer)?.endsWith(/graphiql); } });三者互斥约束与 LRU 缓存源码中的getterFromOptionsCoresrc/index.ts会检查persistedOperationsGetter/persistedOperationsDirectory/persistedOperations三个选项若同时指定超过一个会直接抛错at most one of these operations can be specified一个都没指定则抛出服务器配置错误。而getterFromOptions则通过getterFromOptionsCache——一个来自 utils/lru/src/index.ts 的graphile/lru实例maxLength: 100——按 options 对象缓存已构造的 getter。这正是 CHANGELOG0.0.0-alpha.14中那条变更的实现载体使用 LRU 缓存 getter防止在消费者代码编写不当例如每次请求都传入新对象时造成内存耗尽。客户端侧生成持久化操作持久化操作的哈希必须与服务端已知的操作一一对应因此需要构建期在客户端生成。以下为 README.md 给出的完整方案。RelayRelay 内置持久化操作支持在relay-compiler上追加--persist-output ./path/to/server.json即可输出持久化操作文件随后用下面的addToPersistedOperations.js脚本把该 JSON 拆分为每个查询一个文件供persistedOperationsDirectory使用同时在网络层将query: operation.text改为documentId: operation.id。GraphQL-Code-Generator非 Relay 场景推荐使用graphql-codegen配合graphql-codegen-persisted-query-ids插件测试于 v0.1.2配置示例schema: schema.graphql documents: src/**/*.graphql hooks: afterAllFileWrite: - node addToPersistedOperations.js generates: client.json: plugins: - graphql-codegen-persisted-query-ids: output: client algorithm: sha256 server.json: plugins: - graphql-codegen-persisted-query-ids: output: server algorithm: sha256同时建议在每次构建时把server.json内容写出为独立 GraphQL 文件便于版本控制// addToPersistedOperations.js const map require(./server.json); const { promises: fsp } require(fs); async function main() { await Promise.all( Object.entries(map).map(([hash, query]) fsp.writeFile( ${__dirname}/.persisted_operations/${hash}.graphql, query, ), ), ); } main().catch((e) { console.error(e); process.exit(1); });随后将.persisted_operations目录通过persistedOperationsDirectory选项传入如下方的配置示例所示。Apollo ClientApollo 侧可通过apollo-link-persisted-queries配合 codegen 产出的预生成哈希发送持久化请求import { createPersistedQueryLink } from apollo-link-persisted-queries; import { usePregeneratedHashes as withPregeneratedHashes } from graphql-codegen-persisted-query-ids/lib/apollo; import { hashes } from ./path/to/client.json; const persistedLink createPersistedQueryLink({ useGETForHashedQueries: false, generateHash: withPregeneratedHashes(hashes), disable: () false, }); // const client new ApolloClient({ link: ApolloLink.from([persistedLink, httpLink]), ... });安装与接入配置安装对应 README.mdyarn add grafserv/persisted # 或npm install --save grafserv/persisted在graphile.config.ts或等价配置文件中将PersistedPlugin加入plugins列表并配置目录import graphile-config; import PersistedPlugin from grafserv/persisted; const preset: GraphileConfig.Preset { plugins: [PersistedPlugin], grafserv: { /* 在此添加配置选项例如 */ persistedOperationsDirectory: ${process.cwd()}/.persisted_operations, }, }; export default preset;版本演进从 Alpha 到 1.0.0 的关键工程变更CHANGELOG 完整记录了该包从0.0.0-1.1到1.0.0的全部变更。除大量机械性的依赖版本同步Updated dependencies外值得关注的技术性变更如下它们共同构成了这个插件当前形态0.0.0-alpha.13watch 模式取代固定轮询 版本号改为源码写入默认行为变更不再每 5 秒轮询持久化操作目录除非显式配置No longer polls the persisted queries folder every 5 seconds unless you configure it to do so新增watch 模式当时标注untested。这正是上文persistedOperationsDirectoryScanInterval: number | watch选项的由来当前实现已落地于 src/index.ts。版本号机制version导出不再使用require(../package.json)的 hack而是在版本发布时把版本号写入源文件。对应文件即 src/version.ts其内容由/scripts/postversion.mjs自动生成This file is autogenerated当前为1.0.0。0.0.0-alpha.14LRU 缓存 getter 与依赖去重使用 LRU 缓存 getter from options防止消费者代码编写不当时内存耗尽——即上文getterFromOptionsCachemaxLength: 100的实现全面重构 peerDependencies / dependencies试图消除 duplicate modules 错误该问题在后续版本中被反复处理。0.0.0-beta.35 与 1.0.0-rc.3Node.js 最低版本提升至 22beta.35 将最低 Node.js 版本提升至 Node 22当时的最新 LTSrc.3 再次确认 Node v22 is required for this module并更新 TypeScript 配置以支持 Node 22 minimum。当前 package.json 中engines.node为22。1.0.0-rc.4TypeScript 现代化配置为启用 TypeScript 选项rewriteRelativeImportExtensions与erasableSyntaxOnly源码中开始使用.ts扩展名编写相对导入例如import type { PersistedOperationGetter } from ./interfaces.ts。这种可擦除语法 显式 .ts 扩展的写法让源码可直接被 Node 原生运行是 Monorepo 内各包逐步统一的现代化 TypeScript 约定。1.0.0-rc.5消除悬空 PromiseEliminate dangling promises, reducing chance of process exit due to unhandled promise rejection.——对应源码中void operation.then((operationText) { operationFromHash.set(hash, operationText); })src/index.ts这类显式void掉不再需要 await 的 Promise 的写法避免未处理的 Promise rejection 导致进程意外退出。1.0.0-rc.6具名导出以改善 Node ESM 互操作Export as named for better Node ESM interop——包同时提供export const PersistedPlugin具名导出与export default PersistedPlugin默认导出src/index.ts。1.0.0-rc.7 / 1.0.0发布流程收尾rc.7 无代码变更仅更新发布流程、清理 package.json、为 peerDependencies 使用固定标识符除非它们同时也是显式依赖并计划迁移至 trusted publishing1.0.0与1.0.0-rc.7内容完全一致Identical to 1.0.0-rc.7。从 package.json 可见最终形态运行时依赖仅graphile/lru与tslibpeerDependencies 为grafast、grafserv、graphile-config可选、graphql ^16.9.0。依赖关系与运行环境速览从 package.json 与 CHANGELOG 的 Updated dependencies 段可以归纳出该插件的依赖生态类别内容运行时依赖graphile/lruLRU 缓存、tslibpeerDependenciesgrafast、grafserv、graphile-config可选、graphql ^16.9.0环境要求Node.js 22engines.node导出CommonJS 主入口dist/index.js TypeScript 类型dist/index.d.ts具名/默认双导出结语grafserv/persisted是一个小而精的中间件插件通过processGraphQLRequestBody在 Grafserv 管线中拦截请求将哈希/文档 ID 解析为预批准的 GraphQL 文档并以目录、静态映射、惰性 getter 三种来源配合 LRU 缓存与目录扫描/ watch 机制兼顾了安全性、灵活性与性能。其 CHANGELOG 忠实记录了一条从实验性 alpha 到1.0.0稳定版的工程化路径——包括 Node 22 基线、可擦除 TypeScript 配置、悬空 Promise 治理、ESM 互操作与发布流程现代化——对希望在自己的服务中落地持久化操作、或想了解 Crystal Monorepo 包级工程实践的读者都是不错的参考。进一步阅读可查看 grafast/grafserv-persisted/README.md 的完整选项文档以及 grafast/grafserv/src/middleware/graphql.ts 中中间件的挂载上下文。【免费下载链接】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

延伸阅读

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