防护实战:基于 graphql-query-complexity 的成本分析与限流配置)
后端GraphQLAPI设计【免费下载链接】type-graphqlCreate GraphQL schema and resolvers with TypeScript, using classes and decorators!项目地址https://gitcode.com/gh_mirrors/ty/type-graphql点击查看免费下载导读本文讲解 TypeGraphQL 项目中的查询复杂度Query Complexity防护机制如何为字段、查询、变更与订阅声明成本并在 Apollo Server 中接入graphql-query-complexity对每个请求的 AST 进行成本估算从而在单次 GraphQL 查询可能触发数千次数据库操作、形成 DDoS 攻击面时限制单个操作能造成的服务端工作量。读完本文你将掌握complexity装饰器选项的完整用法、估算器estimator的组合规则、与 Apollo Server 插件体系的集成方式以及复杂度阈值拒绝策略的落地写法。为什么需要查询复杂度控制一个 GraphQL 查询可以轻易展开成巨大的服务端工作量客户端请求一个列表字段再递归请求它的每个子字段底层可能对应成千上万次数据库查询。如果放任不管恶意或低质量客户端可以用少量请求就让服务端资源耗尽这也是 GraphQL 服务最常见的 DoS 攻击面之一。TypeGraphQL提供的解法是成本分析cost analysis为每个字段定义一个复杂度cost数值然后在请求到达时分析查询的 AST抽象语法树估算整个查询的总成本。估算的数学细节全部交给第三方库graphql-query-complexity处理该项目声明支持的版本见 package.json仓库中锁定的版本为^1.1.0TypeGraphQL 侧只需完成两件事在装饰器上声明各字段的复杂度在 GraphQL 服务器中实现graphql-query-complexity的调用逻辑。这种方案的优势在于成本值可以按业务语义精细化定制例如读一个数组要乘以数组长度而不是简单的深度/宽度粗粒度限制。声明字段复杂度complexity装饰器选项基础用法数字成本complexity可以作为Field装饰器的选项传入支持直接给数字ObjectType() class MyObject { Field({ complexity: 2 }) publicField: string; Field({ complexity: ({ args, childComplexity }) childComplexity 1 }) complexField: string; }当复杂度恰为 1 时可以省略complexity选项——这是 TypeGraphQL 与估算器协同的默认值见下文simpleEstimator的defaultComplexity: 1数字形式适合成本固定不变的字段。函数形式动态成本complexity也可以传一个函数函数接收{ args, childComplexity }返回数字Field({ complexity: ({ args, childComplexity }) childComplexity 1 }) complexField: string;其中childComplexity是当前字段所有子字段的复杂度之和适合表达嵌套越深越昂贵的递归成本args是当前字段的参数适合表达按输入规模计费的动态成本。从源码层面看Complexity类型被定义为ComplexityEstimator | number见 src/typings/Complexity.ts它直接复用了graphql-query-complexity的ComplexityEstimator类型因此函数签名与库的估算器约定完全一致。适用范围与优先级complexity可以传给任何Field、FieldResolver、Mutation或Subscription装饰器对象类型字段Field({ complexity })字段解析器FieldResolver({ complexity })查询/变更处理器Query({ complexity })、Mutation({ complexity })订阅处理器Subscription({ complexity })。优先级规则当同一个属性同时被Field和FieldResolver装饰且两者都定义了 complexity 时字段解析器装饰器上的复杂度生效。这一点在示例 examples/query-complexity/recipe.resolver.ts 中有明确体现——Recipe类型里的ratingsCount字段本身声明了complexity: 2而 resolver 中的FieldResolver({ complexity: 5 })会覆盖它。复杂度是如何被写进 Schema 的源码原理为了让fieldExtensionsEstimator能读到复杂度TypeGraphQL 在生成 schema 时会把complexity写入每个字段的extensions配置中。相关链路如下Field装饰器把options.complexity存入元数据存储见 src/decorators/Field.tsFieldResolver、Query、Mutation、Subscription同理见 src/helpers/resolver-metadata.ts元数据定义中为字段和处理器分别保留了complexity属性见 src/metadata/definitions/field-metadata.ts 与 src/metadata/definitions/resolver-metadata.ts若FieldResolver与Field指向同一属性元数据存储会把 field resolver 的复杂度覆盖到类型字段上见 src/metadata/metadata-storage.ts生成 schema 时对象类型的字段配置、接口类型的字段配置以及查询/变更/订阅等处理器字段配置都会把complexity挂到extensions上见 src/schema/schema-generator.ts、src/schema/schema-generator.ts、src/schema/schema-generator.ts。正是因为这最后一步把复杂度写进了extensionsgraphql-query-complexity的fieldExtensionsEstimator()才能在运行时从 schema 字段的extensions.complexity中取到每个字段的标价。与 Apollo Server 集成估算器与插件估算器Estimator的协作机制getComplexity接收一个estimators数组graphql-query-complexity会按数组顺序依次调用这些估算器采用第一个返回数字结果的估算器作为该字段的复杂度如果所有估算器都没有返回值则会抛出异常。因此顺序很重要estimators: [ // Using fieldExtensionsEstimator is mandatory to make it work with type-graphql fieldExtensionsEstimator(), // Add more estimators here... // This will assign each field a complexity of 1 // if no other estimator returned a value simpleEstimator({ defaultComplexity: 1 }), ],fieldExtensionsEstimator()读取字段extensions.complexity即我们通过 TypeGraphQL 装饰器声明的复杂度。这是让复杂度配置对graphql-query-complexity生效的关键文档与示例都明确指出其必须放在第一位simpleEstimator({ defaultComplexity: 1 })兜底估算器为所有未声明复杂度的字段统一赋默认值 1——这也解释了为什么复杂度为 1 时可以省略complexity选项中间还可以插入任意数量的自定义估算器按业务需要追加。完整集成代码Apollo Server下面是官方示例 examples/query-complexity/index.ts 的完整实现它展示了从构建 schema 到限流的全过程import reflect-metadata; import path from node:path; import { ApolloServer } from apollo/server; import { startStandaloneServer } from apollo/server/standalone; import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from graphql-query-complexity; import { buildSchema } from type-graphql; import { RecipeResolver } from ./recipe.resolver; // Maximum allowed complexity const MAX_COMPLEXITY 20; async function bootstrap() { // Build TypeGraphQL executable schema const schema await buildSchema({ // Array of resolvers resolvers: [RecipeResolver], // Create schema.graphql file with schema definition in current directory emitSchemaFile: path.resolve(__dirname, schema.graphql), }); // Create GraphQL server const server new ApolloServer({ schema, // Create a plugin to allow query complexity calculation for every request plugins: [ { requestDidStart: async () ({ async didResolveOperation({ request, document }) { /** * Provides GraphQL query analysis to be able to react on complex queries to the GraphQL server * It can be used to protect the GraphQL server against resource exhaustion and DoS attacks */ const complexity getComplexity({ // GraphQL schema schema, // To calculate query complexity properly, // check only the requested operation // not the whole document that may contains multiple operations operationName: request.operationName, // GraphQL query document query: document, // GraphQL query variables variables: request.variables, estimators: [ fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 }), ], }); // React to the calculated complexity, // like compare it with max and throw error when the threshold is reached if (complexity MAX_COMPLEXITY) { throw new Error( Sorry, too complicated query! ${complexity} exceeded the maximum allowed complexity of ${MAX_COMPLEXITY}, ); } console.log(Used query complexity points:, complexity); }, }), }, ], }); // Start server const { url } await startStandaloneServer(server, { listen: { port: 4000 } }); console.log(GraphQL server ready at ${url}); } bootstrap().catch(console.error);几个关键点getComplexity的query必须传解析后的documentAST而不是原始查询字符串operationName用于指定本次请求实际执行的操作——当一个 document 中包含多个 operation查询/变更/订阅并存时只对该操作计费避免误判variables会把客户端传入的变量一并交给估算器使基于args的动态复杂度计算拿到真实数值插件钩子选在didResolveOperation操作解析完成后、执行前此时 schema、document、变量都齐备是最合适的计费时机阈值判定采用抛错方式complexity MAX_COMPLEXITY时抛出Error该错误会以 GraphQL 错误形式返回给客户端同时中止执行。示例中MAX_COMPLEXITY 20你可以按业务压测结果调整。使用express-graphql等其他服务器时同样可行只需把getComplexity挂到对应的请求处理中间件/钩子即可集成逻辑不变。实战示例按参数与子复杂度动态计费仓库的 examples/query-complexity 示例是理解动态成本的最佳教材。查询按args计费在 examples/query-complexity/recipe.resolver.ts 中recipes查询的复杂度是一个函数Query(_returns [Recipe], { /* Pass also a calculation function in the complexity option to determine a custom complexity. This function provide the complexity of the child nodes as well as the field input arguments. That way a more realistic estimation of individual field complexity values is made, e.g. by multiplying childComplexity by the number of items in array */ complexity: ({ childComplexity, args }) args.count * childComplexity, }) async recipes(Arg(count) count: number): PromiseRecipe[] { return this.items.slice(0, count); }这里args.count是客户端请求的条数childComplexity是Recipe类型中每个被请求字段的复杂度之和。count * childComplexity的含义非常直观请求count条数据每条都要计算其子字段因此总成本正比于条数 × 每条子字段成本。当count很大时复杂度会线性增长并超过MAX_COMPLEXITY请求被拒绝——这正是按参数规模动态计费的典型写法。对象类型默认值与自定义值混合在 examples/query-complexity/recipe.type.ts 中ObjectType() export class Recipe { /* By default, every field gets a complexity of 1 */ Field() title!: string; /* Which can be customized by passing the complexity parameter */ Field(_type Int, { complexity: 2 }) ratingsCount!: number; Field(_type Float, { nullable: true, complexity: 10, }) get averageRating(): number | null { // ...计算平均评分 } }title未声明复杂度按默认值 1 计费ratingsCount声明为 2——虽然它在 resolver 中被FieldResolver({ complexity: 5 })覆盖最终按 5 计费见 examples/query-complexity/recipe.resolver.tsaverageRating声明为 10因为它需要遍历所有评分数组求和成本较高。组合后的计费示例若某次查询recipes(count: 5)且只请求title与ratingsCount则总复杂度约为5 × (1 5) 30 20会被示例的MAX_COMPLEXITY阈值拒绝若只请求title总复杂度为5 × 1 5可以正常放行。生成后的 schema 可见 examples/query-complexity/schema.graphql其中recipes(count: Float!): [Recipe!]!与三个字段的声明一一对应。最佳实践与注意事项fieldExtensionsEstimator必须最先声明只有它能读取 TypeGraphQL 写入的extensions.complexity若省略它装饰器里的复杂度配置将完全失效所有字段只能依赖兜底估算器。兜底估算器不可省略除非你有把握simpleEstimator({ defaultComplexity: 1 })保证未声明的字段也有成本避免估算器全部哑火导致异常同时它让你可以只为昂贵字段声明复杂度降低维护成本。复杂度的数值语义建议把数值理解为该字段触发的工作量单位结合你的数据层数据库查询数、计算量来标定例如列表字段按count放大、计算密集型字段给更高固定值。优先级记忆点同一属性上FieldResolver的复杂度覆盖Field的复杂度覆盖逻辑见 src/metadata/metadata-storage.ts。阈值策略示例采用抛错拒绝也可以改为记录日志、返回降级数据等更柔和的手段didResolveOperation中打印的Used query complexity points:可用于压测与调参。适用范围本文方案适用于以 TypeGraphQL 构建 schema、以 Apollo Server或任意支持请求生命周期钩子的 GraphQL 服务器提供 HTTP 服务的场景复杂度计算发生在执行解析器之前不影响正常查询的执行效率。赞分享后端GraphQLAPI设计【免费下载链接】type-graphqlCreate GraphQL schema and resolvers with TypeScript, using classes and decorators!项目地址https://gitcode.com/gh_mirrors/ty/type-graphql点击查看免费下载相关推荐TypeGraphQL 查询复杂度Query Complexity集成指南为 GraphQL API 构建成本分析与 DDoS 防护TypeGraphQL 查询复杂度Query Complexity集成指南为 GraphQL API 构建成本分析与 DDoS 防护 TypeGraphQ后端GraphQLAPI设计TypeGraphQL 查询复杂度限制实战用 graphql-query-complexity 为 Schema 字段定义成本并防 DDoSTypeGraphQL 查询复杂度限制实战用 graphql query complexity 为 Schema 字段定义成本并防 DDoS 本指南讲解 Ty后端GraphQLAPI设计TypeGraphQL 查询复杂度Query Complexity防护实战用字段级成本估算抵御 GraphQL DoS 攻击TypeGraphQL 查询复杂度Query Complexity防护实战用字段级成本估算抵御 GraphQL DoS 攻击 单条 GraphQL 请求往后端GraphQLAPI设计上一篇使用 PaddleSeg 准备自定义数据集目录结构、文件列表生成与训练配置全指南下一篇一条命令拉起 WeKnora 私有知识库问答完整部署实战创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考