— 1 min read
在 GraphQL 中,Resolver 的存在类似于 RESTFul API 中的 Controller 层级,这一点在 NestJS、MidwayJS 等提供了 @Controller
装饰器的 Node 框架中更为明显。以 Midway Koa 为例,其洋葱中间件模型能够对请求以及响应进行篡改,一个简单的示例是这样的:
1import { Provide } from "@midwayjs/decorator";2import {3 IWebMiddleware,4 IMidwayKoaContext,5 IMidwayKoaNext,6} from "@midwayjs/koa";7
8@Provide()9export class ReportMiddleware implements IWebMiddleware {10 resolve() {11 return async (ctx: IMidwayKoaContext, next: IMidwayKoaNext) => {12 const startTime = Date.now();13 await next();14 console.log(Date.now() - startTime);15 };16 }17}
说到这里,我们知道 Koa 和 Express 的中间件模型是不同的,Koa 的中间件按照注册顺序,以 中间件 1 进-中间件 2 进-中间件 2 出-中间件 1 出 的顺序执行,而 Express 则简单的按注册顺序依次执行,且通常由最后一个中间件负责响应请求。
那 GraphQL 中是否能够做到中间件,对应的在 Resolver 的前后执行?当然是可以的,而且也比较简单,我们知道 GraphQL Schema 中 Resolver 是这样存储的(如果你之前不知道,恭喜你现在知道了):
1export declare class GraphQLSchema {2 description: Maybe<string>;3 getTypeMap(): TypeMap;4 // ... 其他无关的定义5}6
7declare type TypeMap = ObjMap<GraphQLNamedType>;8
9// GraphQLObjectType 是 GraphQLNamedType 的子类型之一10export declare class GraphQLObjectType<TSource = any, TContext = any> {11 name: string;12 description: Maybe<string>;13 getFields(): GraphQLFieldMap<TSource, TContext>;14}15
16export declare type GraphQLFieldMap<TSource, TContext> = ObjMap<17 GraphQLField<TSource, TContext>18>;19
20export interface GraphQLField<TSource, TContext, TArgs = any> {21 name: string;22 description: Maybe<string>;23 type: GraphQLOutputType;24 // 就是这儿!25 resolve?: GraphQLFieldResolver<TSource, TContext, TArgs>;26}
沿着类型找下来我们发现 Resolver 被定义在 GraphQL Field 上,也即意味着当我们使用以下方式定义 Resolver 时:
1const resolvers: IResolvers = {2 Query: {3 hello: (root, args, context, info) => {4 return `Hello ${args.name ? args.name : "world"}!`;5 },6 bye: (root, args, context, info) => {7 return `Bye ${args.name ? args.name : "world"}!`;8 },9 },10};
顶级对象类型 Query 的 Field:hello、bye 会被分别的绑定上这里对应的函数,再看 GraphQL 源码中的执行逻辑(精简版),见execute.ts:
1function executeField(): PromiseOrValue<unknown> {2 const fieldDef = getFieldDef(exeContext.schema, parentType, fieldNodes[0]);3
4 const resolveFn = fieldDef.resolve ?? exeContext.fieldResolver;5
6 try {7 const contextValue = exeContext.contextValue;8 const result = resolveFn(source, args, contextValue, info);9 } catch (rawError) {}10}
可以看到,Resolver 的执行实际上就是将 GraphQL 提案中要求的参数传入函数中:
buildResolveInfo
拼装,具体信息参看 definition.ts这也就说明了 Resolver 并没有什么特别的,我们需要做的只是拿到这个函数本身的定义以及入参,然后在执行前后分别执行一个中间件函数即可。并且,由于能够拿到有哪些 Type、Field,我们可以很容易的控制中间件的应用级别,如默认全局生效、仅对某一 field (不)生效,仅对顶级 Field 生效,等等,类比过来就是 Midway 中的全局中间件、路由中间件等概念。
确定了实现可能性以后,我们期望的中间件应该是这样的:
1const middleware1 = (rawResolver, source, args, context, info) => {2 console.log('In!');3 const result = await rawResolver(root, args, context, info);4 console.log('Out!');5 return result;6}
中间件的注册也应当从简,直接传入 GraphQL Schema 与中间件即可:
1const middlewareRegisteredSchema = applySchema(rawSchema, middleware1, middleware2, ...);
rawSchema 意为着你需要提前构建一次 Schema,如
@graphql-tools/schema
提供的makeExecutableSchema
或TypeGraphQL
提供的buildSchemaSync
以及其他类似的工具。
在 applySchema 方法中,我们首先遍历中间件数组,为每一个中间件执行一次注册。这里需要注意的是,我们期望的顺序是类似 Koa 的洋葱模型,即 mw1 进-mw2 进-实际逻辑-mw2 出-mw1 出 的顺序,所以在注册时位置靠后的中间件反而需要仙先被注册,来确保其位于中间件队列内侧,我们可以很简单的使用 reduceRight 方法实现:
1export const applyMiddleware = <TSource = any, TContext = any, TArgs = any>(2 schema: GraphQLSchema,3 ...middlewares: IMiddlewareResolver<TSource, TContext, TArgs>[]4): GraphQLSchema => {5 const modifiedSchema = middlewares.reduceRight(6 (prevSchema, middleware) =>7 attachSingleMiddlewareToSchema(prevSchema, middleware),8 schema9 );10
11 return modifiedSchema;12};
我们需要在 attachSingleMiddlewareToSchema
方法中完成中间件的注册,这一步我们需要:
@graphql-tools/schema
的 addResolversToSchema
方法来进行1const attachSingleMiddlewareToSchema = <2 TSource = any,3 TContext = any,4 TArgs = any5>(6 schema: GraphQLSchema,7 middleware: IMiddlewareResolver<TSource, TContext, TArgs>8): GraphQLSchema => {9 const typeMap = schema.getTypeMap();10
11 const modifiedResolvers: IResolvers = Object.keys(typeMap)12 .filter((type) => ["Query", "Mutation"].includes(type))13 .reduce(14 (resolvers, type) => ({15 ...resolvers,16 [type]: attachSingleMiddlewareToObjectType(17 typeMap[type] as GraphQLObjectType,18 middleware19 ),20 }),21 {}22 );23
24 const modifiedSchema = addResolversToSchema({25 schema,26 resolvers: modifiedResolvers,27 updateResolversInPlace: false,28 resolverValidationOptions: {29 requireResolversForResolveType: "ignore",30 },31 });32
33 return modifiedSchema;34};
通过 updateResolversInPlace
以及 resolverValidationOptions
参数,我们确保了原有的 Resolver 会被覆盖掉。
然后就是最重要 attachSingleMiddlewareToObjectType
方法了,在这里我们要拿到 ObjectType 上的所有 GraphQL Field 并依次的去修改它们的 resolve 属性:
1const attachSingleMiddlewareToObjectType = <2 TSource = any,3 TContext = any,4 TArgs = any5>(6 type: GraphQLObjectType<TSource, TContext>,7 middleware: IMiddlewareResolver<TSource, TContext, TArgs>8): IResolvers<TSource, TContext> => {9 const fieldMap = type.getFields();10
11 const modifiedFieldResolvers: IResolvers<TSource, TContext> = Object.keys(12 fieldMap13 ).reduce((resolvers, fieldName) => {14 const currentField = fieldMap[fieldName];15 // @ts-expect-error16 const { isDeprecated, ...rest } = currentField;17
18 const argsMap = currentField.args.reduce(19 (acc, cur) => ({20 ...acc,21 [cur.name]: cur,22 }),23 {} as Record<string, GraphQLArgument>24 );25
26 const parsedField = {27 ...rest,28 args: argsMap,29 };30
31 const modifiedFieldData =32 parsedField.resolve && parsedField.resolve !== defaultFieldResolver33 ? {34 ...parsedField,35 resolve: wrapResolverInMiddleware(parsedField.resolve, middleware),36 }37 : { ...parsedField, resolve: defaultFieldResolver };38
39 return {40 ...resolvers,41 [fieldName]: modifiedFieldData,42 };43 }, {});44 return modifiedFieldResolvers;45};
isDeprecated
标识 Field Deprecation,在以后使用 deprecationReason
标识GraphQLArgument[]
(只读) 转换为 Record<string, GraphQLArgument>
,这一点是因为在 GraphQL 实际将 args 传入给 Resolver 时也会有这么一个步骤,因此提前在这里做好确保中间件和原 Resolver 拿到的是一致的形式。wrapResolverInMiddleware
来完成临门一脚:中间件的注入。最后的 wrapResolverInMiddleware
则是一个简单的高阶函数:
1function wrapResolverInMiddleware<TSource, TContext, TArgs>(2 resolver: GraphQLFieldResolver<TSource, TContext, TArgs>,3 middleware: IMiddlewareResolver<TSource, TContext, TArgs>4): GraphQLFieldResolver<TSource, TContext, TArgs> {5 return (parent, args, ctx, info) =>6 middleware(7 (_parent = parent, _args = args, _ctx = ctx, _info = info) =>8 resolver(_parent, _args, _ctx, _info),9 parent,10 args,11 ctx,12 info13 );14}
来实际使用下,用 ApolloServer 起一个简单的 GraphQL Server:
1import { ApolloServer } from "apollo-server";2import { makeExecutableSchema } from "@graphql-tools/schema";3import { applyMiddleware } from "./graphql-middleware-core/core";4import type { IMiddleware, IResolvers } from "./graphql-middleware-core/core";5
6const typeDefs = `7type Query {8 hello(name: String): String9}10`;11
12const resolvers: IResolvers = {13 Query: {14 hello: (root, args, context, info) => {15 console.log(`3. Core: resolver: hello`);16 return `Hello ${args.name ? args.name : "world"}!`;17 },18 },19};20
21const logInput: IMiddleware = async (resolve, root, args, context, info) => {22 console.log(`1. logInput Start: ${JSON.stringify(args)}`);23 const result = await resolve(root, args, context, info);24 console.log(`5. logInput End`);25 return result;26};27
28const logResult: IMiddleware = async (resolve, root, args, context, info) => {29 console.log(`2. logResult Start`);30 const result = await resolve(root, args, context, info);31 console.log(`4. logResult End: ${JSON.stringify(result)}`);32 return result;33};34
35const schema = makeExecutableSchema({ typeDefs, resolvers });36
37const schemaWithMiddleware = applyMiddleware(schema, logInput, logResult);38
39const server = new ApolloServer({40 schema: schemaWithMiddleware,41});42
43(async () => {44 await server.listen({ port: 8008 });45
46 console.log(`http://localhost:8008`);47})();
我们注册了两个简单的中间件,logInput 打印入参、logResult 打印结果,并期望最终的打印结果按照序号顺序,在 GraphQL Playground 或 Apollo Studio 中使用以下语句发起请求:
1query TestQuery {2 hello3}
控制台打印结果:
11. logInput Start: {}22. logResult Start33. Core: resolver: hello44. logResult End: "Hello world!"55. logInput End
可以看到我们预期的结果已经生效了。
实际上,以上这些处理逻辑是 graphql-middleware 的核心逻辑,这个库同时也是 graphql-shield graphql-middleware-apollo-upload-server 等提供特定部分的功能如鉴权、上传、日志等的 GraphQL Middleware 的基础库。
如果说,GraphQL Middleware 提供了自由的中间件注册逻辑,你只要传递合法的 GraphQL Schema 即可,无论你使用什么工具来构建。我们在上面说到 TypeGraphQL 也提供了构建 Schema 的 API buildSchema
,实际上它本身就提供了中间件相关的功能。
使用 TypeGraphQL ,我们可以使用 Class 以及 Decorator 语法来描述 GraphQL Schema,如以下的 GraphQL Schema
1type Recipe {2 id: ID!3 title: String!4 description: String5 creationDate: Date!6 ingredients: [String!]!7}
对应的 Class 代码:
1@ObjectType()2class Recipe {3 @Field((type) => ID)4 id: string;5
6 @Field()7 title: string;8
9 @Field({ nullable: true })10 description?: string;11
12 @Field()13 creationDate: Date;14
15 @Field((type) => [String])16 ingredients: string[];17}
对应的 Resolver 代码:
1@Resolver()2class RecipeResolver {3 @Query((returns) => [Recipe])4 async recipes(): Promise<Recipe[]> {5 // ...6 }7}
声明一个中间件并添加:
1export const ResolveTime: MiddlewareFn = async ({ info }, next) => {2 const start = Date.now();3 await next();4 const resolveTime = Date.now() - start;5 console.log(`${info.parentType.name}.${info.fieldName} [${resolveTime} ms]`);6};7
8@Resolver()9export class RecipeResolver {10 @Query()11 @UseMiddleware(ResolveTime)12 randomValue(): number {13 return Math.random();14 }15}
这样 ResolveTime
即在 RecipeResolver.randomValue
上生效了。事实上我们甚至可以直接定义在 ObjectType Class 中,这样所有涉及到此 Field 的属性都会生效:
1@ObjectType()2export class Recipe {3 @Field((type) => [Int])4 @UseMiddleware(LogAccess)5 ratings: number[];6}
TypeGraphQL 也支持全局的中间件的形式,类似的,我们需要对完整的 GraphQL Schema 做修改,在这里则发生在 buildSchema
中:
1const schema = await buildSchema({2 resolvers: [RecipeResolver],3 globalMiddlewares: [ErrorInterceptor, ResolveTime],4});
TypeGraphQL 中的中间件很明显比 graphql-middleware 在功能上强大的多,但由于后者实际上提供的是抽象的、工具无关的中间件注册能力,所以比较实际上并没有什么意义。
在下一篇 GraphQL 的文章中,我们会来聊一聊 GraphQL Diretives,从它的实现、使用、原理,以及和本文中 GraphQL Middleware 的全方位对比。
全文完,感谢你的阅读~