Skip to content
Linbudu's Blog

探索 GraphQL Resolver 的中间件

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 提案中要求的参数传入函数中:

  • source,上一级 Field Resolver 的处理信息,对于顶级 Resolver ,ApolloServer 这一类 GraphQL Server 框架中提供了一个额外的 rootValue 作为其 source,同时在 Apollo 中此参数被命名为 parent。
  • args,当前 Field 所需的参数,从 Operation 解析而来。
  • context,被所有层级的 Resolver 共享的值,一般用于鉴权、DataLoader 注册、埋点等。
  • info,本次 operation 专有的信息,需要通过另一个方法 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 提供的 makeExecutableSchemaTypeGraphQL 提供的 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 schema
9 );
10
11 return modifiedSchema;
12};

我们需要在 attachSingleMiddlewareToSchema 方法中完成中间件的注册,这一步我们需要:

  • 对 Query 以及 Mutation(Subscription 也一样,但为了精简这里不做实现),拿到其所有的 Field,篡改每一个 Field 的 Resolver
  • 将新的 Resolver 添加回 Schema,这里我们使用 @graphql-tools/schemaaddResolversToSchema 方法来进行
1const attachSingleMiddlewareToSchema = <
2 TSource = any,
3 TContext = any,
4 TArgs = any
5>(
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 middleware
19 ),
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 = any
5>(
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 fieldMap
13 ).reduce((resolvers, fieldName) => {
14 const currentField = fieldMap[fieldName];
15 // @ts-expect-error
16 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 !== defaultFieldResolver
33 ? {
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};
  • 在 GraphQL16 以前的版本使用 isDeprecated 标识 Field Deprecation,在以后使用 deprecationReason 标识
  • 将 args 属性由 GraphQLArgument[](只读) 转换为 Record<string, GraphQLArgument>,这一点是因为在 GraphQL 实际将 args 传入给 Resolver 时也会有这么一个步骤,因此提前在这里做好确保中间件和原 Resolver 拿到的是一致的形式。
  • 如果 Field 没有定义 Resolver,或使用了默认的内置 Resolver (此默认 Resolver 会直接从 source 上读取一个键名与此 Field 相同的键值返回),那么我们不做中间件的处理,直接返回,否则我们使用 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 info
13 );
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): String
9}
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 hello
3}

控制台打印结果:

11. logInput Start: {}
22. logResult Start
33. Core: resolver: hello
44. 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: String
5 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 的全方位对比。

全文完,感谢你的阅读~