Skip to content
Linbudu's Blog

你不知道的 GraphQL Directives

1 min read

原文

这篇文章来自数月前的随手记录,由于觉得内容较为小众(GraphQL 接触的人已经不多了,其中的 Directive 了解的人更少)就没有发出来。这次心血来潮炒个冷饭。实际上我一直很想出一系列 GraphQL 的文章,涵盖入门到实战再到原理,奈何一直没有动力,也许要到今年入职以后才会尝试开始吧。

基本定义

GraphQL Directive 是被注入到(或者说书写在)GraphQL Schema 中的特殊语法,以@开头,例如:

1directive @camelCase on FIELD_DEFINITION
2directive @date(format: String = "DD-MM-YYYY") on FIELD_DEFINITION
3directive @auth(
4 requires: Role = ADMIN,
5) on OBJECT | FIELD_DEFINITION
6
7enum Role {
8 ADMIN
9 REVIEWER
10 USER
11 UNKNOWN
12}
13
14query {
15 user @auth(requires: LOGIN){
16 name
17 date @formatDate(template: "YYYY-MM-DD")
18 desc @camelCase
19 }
20}

它可以携带参数(如@auth(requires: LOGIN)),也可以直接调用(如@camelCase)。对于这两种指令的区分是在实现时就已经确定了的,比如格式化日期的指令 formatDate,既可以@formatDate,也可以@formatDate(template: "YYYY-MM-DD")。 在指令实现中,只需要指定一个默认参数即可,如

1const { format = defaultFormat } = this.args;

源码定义

GraphQL Directives 在源码中的定义:

1export class GraphQLDirective {
2 name: string;
3 description: Maybe<string>;
4 locations: Array<DirectiveLocationEnum>;
5 isRepeatable: boolean;
6 args: Array<GraphQLArgument>;
7 extensions: Maybe<Readonly<GraphQLDirectiveExtensions>>;
8 astNode: Maybe<DirectiveDefinitionNode>;
9
10 constructor(config: Readonly<GraphQLDirectiveConfig>);
11
12 toConfig(): GraphQLDirectiveConfig & {
13 args: GraphQLFieldConfigArgumentMap;
14 isRepeatable: boolean;
15 extensions: Maybe<Readonly<GraphQLDirectiveExtensions>>;
16 };
17
18 toString(): string;
19 toJSON(): string;
20 inspect(): string;
21}
22
23export interface GraphQLDirectiveConfig {
24 name: string;
25 description?: Maybe<string>;
26 locations: Array<DirectiveLocationEnum>;
27 args?: Maybe<GraphQLFieldConfigArgumentMap>;
28 isRepeatable?: Maybe<boolean>;
29 extensions?: Maybe<Readonly<GraphQLDirectiveExtensions>>;
30 astNode?: Maybe<DirectiveDefinitionNode>;
31}

大概解释一下各个参数:

  • name 指令名,也就是最终在 schema 中使用的名字

  • description 功能描述

  • locations 指令生效区域:

    1export const DirectiveLocation: {
    2 // Request Definitions
    3 // "根级别"
    4 QUERY: "QUERY";
    5 MUTATION: "MUTATION";
    6 SUBSCRIPTION: "SUBSCRIPTION";
    7 FIELD: "FIELD";
    8 FRAGMENT_DEFINITION: "FRAGMENT_DEFINITION";
    9 FRAGMENT_SPREAD: "FRAGMENT_SPREAD";
    10 INLINE_FRAGMENT: "INLINE_FRAGMENT";
    11 VARIABLE_DEFINITION: "VARIABLE_DEFINITION";
    12
    13 // Type System Definitions
    14 // 各种类型中的级别
    15 SCHEMA: "SCHEMA";
    16 SCALAR: "SCALAR";
    17 OBJECT: "OBJECT";
    18 FIELD_DEFINITION: "FIELD_DEFINITION";
    19 ARGUMENT_DEFINITION: "ARGUMENT_DEFINITION";
    20 INTERFACE: "INTERFACE";
    21 UNION: "UNION";
    22 ENUM: "ENUM";
    23 ENUM_VALUE: "ENUM_VALUE";
    24 INPUT_OBJECT: "INPUT_OBJECT";
    25 INPUT_FIELD_DEFINITION: "INPUT_FIELD_DEFINITION";
    26};
    27
    28export type DirectiveLocationEnum =
    29 typeof DirectiveLocation[keyof typeof DirectiveLocation];

基于 GraphQL-Tools 操作指令

在 GraphQL-Tools 中,使用了一系列VisitXXX方法来达到这个效果,如:

1import { SchemaDirectiveVisitor } from "graphql-tools";
2
3export class DeprecatedDirective extends SchemaDirectiveVisitor {
4 visitSchema(schema: GraphQLSchema) {}
5 visitObject(object: GraphQLObjectType) {}
6 visitFieldDefinition(field: GraphQLField<anyany>) {}
7 visitArgumentDefinition(argument: GraphQLArgument) {}
8 visitInterface(iface: GraphQLInterfaceType) {}
9 visitInputObject(object: GraphQLInputObjectType) {}
10 visitInputFieldDefinition(field: GraphQLInputField) {}
11 visitScalar(scalar: GraphQLScalarType) {}
12 visitUnion(union: GraphQLUnionType) {}
13 visitEnum(type: GraphQLEnumType) {}
14 visitEnumValue(value: GraphQLEnumValue) {}
15
16 // 还有三个静态方法
17 // getDirectiveDeclaration
18 // implementsVisitorMethod
19 // visitSchemaDirectives
20}

这里的 11 个方法对应着DirectiveLocation中的 11 种类型级别定义(根级别的是 GraphQL 运行时内置使用的)

  • args,这里是一个 k-v 的 map,类型定义如下:

    1export interface GraphQLFieldConfigArgumentMap {
    2 [key: string]: GraphQLArgumentConfig;
    3}
    4export interface GraphQLArgumentConfig {
    5 description?: Maybe<string>;
    6 type: GraphQLInputType;
    7 defaultValue?: any;
    8 deprecationReason?: Maybe<string>;
    9 extensions?: Maybe<Readonly<GraphQLArgumentExtensions>>;
    10 astNode?: Maybe<InputValueDefinitionNode>;
    11}

    定义了参数相关的配置。

    注意,这里的deprecationReason应该是参数的废弃原因说明,因为我发现只有GraphQLField等少数类型上有isDeprecateddeprecationReason,也就是只有visitFieldDefinition等少数方法(GraphQLField是此方法的参数之一)可以废弃掉字段(毕竟不能废弃掉整个对象类型甚至整个 schema 吧)

    也可以看一下 GraphQL 内置指令@include中的实现:

    1export const GraphQLIncludeDirective = new GraphQLDirective({
    2 name: 'include',
    3 description:
    4 'Directs the executor to include this field or fragment only when the `if` argument is true.',
    5 locations: [
    6 DirectiveLocation.FIELD,
    7 DirectiveLocation.FRAGMENT_SPREAD,
    8 DirectiveLocation.INLINE_FRAGMENT,
    9 ]
    10 args: {
    11 if: {
    12 type: new GraphQLNonNull(GraphQLBoolean)
    13 description: 'Included when true.',
    14 },
    15 },
    16});

    这里的args.if就是参数~

    我本来想顺便看看内置指令是怎么解析的,但搜include没找到相关代码

  • isRepeatable,指令是否可以重复,同一 location 上能否有多个同名指令。

  • extensions,指令扩展信息,修改这个参数不会反馈到响应的 extensions 中。

  • astNode,指令最终生成的 ast 节点信息。

实际应用

变更 resolver

指令最重要的作用就是在运行时动态的去修改返回的数据格式,包括值以及字段结构等都能改变,举例来说,在visitFieldDefinition方法中,我们可以直接修改field.resolve方法,来修改字段的解析结果,如:

1export class DateFormatDirective extends SchemaDirectiveVisitor {
2 visitFieldDefinition(field: GraphQLField<any, any>) {
3 const { resolve = defaultFieldResolver } = field;
4 const { format = defaultFormat } = this.args;
5
6 field.resolve = async (...args) => {
7 console.log(
8 `@date invoked on ${args[3].parentType}.${args[3].fieldName}`
9 );
10 const date = await resolve.apply(this, args);
11 return DateFormatter(date, format);
12 };
13
14 field.type = GraphQLString;
15 }
16}

在这个例子中我们格式化了返回的 Date 格式,由于 Date 并非内置的标量格式,一部分三方库提供的 DateScalarType 通常会把 Date 解析为时间戳,也就是 Int 类型,因此在这里如果修改为"2021-02-06"的格式,就需要修改字段类型来防止出错。

field 上还存在一些关键信息,如subscribe(订阅操作的解析函数),以及extensions(这里应该就是字段附加的扩展了?)等等。

在 GraphQLObject(即visitObject方法的参数)上,我们甚至可以拿到这个对象类型的所有字段(getFields)以及其实现的接口(getInterfaces)等等(其实字段级别的指令也能够访问父级类型,field.objectType 即可),完全可以说是为所欲为。

实战:@auth 指令

指令还有个重要作用就是类似 TS 装饰器的元数据功能,一个装饰器添加元数据,执行顺序靠后的装饰器收集元数据来使用(就像类装饰器中可以拿到方法/属性/参数装饰器添加的元数据),比如@auth指令,实际上就是拿到当前对象类型的所有字段上定义的所需权限,比对用户权限(通常存放在 resolve 的 context 参数中),判断是否放行。

1export const enum AuthDirectiveRoleEnum {
2 ADMIN
3 REVIEWER
4 USER
5 UNKNOWN
6}
7
8type AuthEnumMembers = keyof typeof AuthDirectiveRoleEnum;
9
10type AuthGraphQLObjectType = GraphQLObjectType & {
11 _requiredAuthRole: AuthEnumMembers;
12 _authFieldsWrapped: boolean;
13};
14
15type AuthGraphQLField<TK> = GraphQLField<T, K> & {
16 _requiredAuthRole: AuthEnumMembers;
17};
18
19const getUser = async (token: string): Promise<AuthEnumMembers[]> => {
20 return ["USER"];
21};
22
23export class AuthDirective extends SchemaDirectiveVisitor {
24 visitObject(type: AuthGraphQLObjectType) {
25 console.log(`@auth invoked at visitObject ${type.name}`);
26 this.ensureFieldsWrapped(type);
27 type._requiredAuthRole = this.args.requires;
28 }
29
30 visitFieldDefinition(
31 field: AuthGraphQLField<any, any>
32 details: {
33 objectType: AuthGraphQLObjectType;
34 }
35 ) {
36 console.log(`@auth invoked at visitFieldDefinition ${field.name}`);
37
38 this.ensureFieldsWrapped(details.objectType);
39 field._requiredAuthRole = this.args.requires;
40 }
41
42 ensureFieldsWrapped(objectType: AuthGraphQLObjectType) {
43 if (objectType._authFieldsWrapped) return;
44 objectType._authFieldsWrapped = true;
45
46 const fields = (objectType.getFields() as unknown) as AuthGraphQLField<
47 any,
48 any
49 >[];
50
51 Object.keys(fields).forEach((fieldName) => {
52 const field = fields[fieldName] as AuthGraphQLField<any, any>;
53 const { resolve = defaultFieldResolver } = field;
54 field.resolve = async (...args) => {
55 const requiredRole =
56 field._requiredAuthRole || objectType._requiredAuthRole;
57
58 console.log("requiredRole: ", requiredRole);
59
60 if (!requiredRole) {
61 return resolve.apply(this, args);
62 }
63
64 // const context = args[2];
65 // const userRoles = await getUser(context?.headers?.authToken ?? "");
66
67 // if (!userRoles.includes(requiredRole)) {
68 // throw new Error("not authorized");
69 // }
70
71 return resolve.apply(this, args);
72 };
73 });
74 }
75
76 public static getDirectiveDeclaration(
77 directiveName: string,
78 schema: GraphQLSchema
79 ): GraphQLDirective {
80 console.log(directiveName);
81 const previousDirective = schema.getDirective(directiveName);
82 console.log("previousDirective: ", previousDirective);
83 if (previousDirective) {
84 previousDirective.args.forEach((arg) => {
85 if (arg.name === "requires") {
86 arg.defaultValue = "REVIEWER";
87 }
88 });
89
90 return previousDirective;
91 }
92
93 return new GraphQLDirective({
94 name: directiveName,
95 locations: [DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION],
96 args: {
97 requires: {
98 type: schema.getType("AuthDirectiveRoleEnum") as GraphQLEnumType,
99 defaultValue: "USER",
100 },
101 },
102 });
103 }
104}

这里有不少细节:

  • @auth既可以放在 field,也可以放在 objectType 上,当一个对象类型被标记后,由于它包含的字段在解析时依赖的也是对象类型的元数据,因此不会重复标记而是直接使用。

  • getDirectiveDeclaration这个方法通常使用在 Schema 被多人添加过指令,这个时候你不能确定你所添加的指令是否已经存在了。 那么你就需要使用这个方法显式的声明最终生成的指令。

    • 如果指令已经存在,比如这里的@auth,但是可能你需要使用的参数默认值或者其他信息变化了,又不想把每个 schema 中的指令都找出来改一遍,只要直接在这里修改参数信息即可。

      1if (previousDirective) {
      2 previousDirective.args.forEach((arg) => {
      3 if (arg.name === "requires") {
      4 arg.defaultValue = "REVIEWER";
      5 }
      6 });
      7 return previousDirective;
      8}
    • 否则,你应该返回一个全新的指令实例(当然这完全不是必须的,SchemaDirectiveVisitor方法会自动生成)

      1return new GraphQLDirective({
      2 name: directiveName,
      3 locations: [DirectiveLocation.OBJECT, DirectiveLocation.FIELD_DEFINITION],
      4 args: {
      5 requires: {
      6 type: schema.getType("AuthDirectiveRoleEnum") as GraphQLEnumType,
      7 defaultValue: "USER",
      8 },
      9 },
      10});

      由于这里实际上是使用的原生 GraphQL 方法,所以需要直接使用其内部的相关 API 比如DirectiveLocation等等。

  • 这里的AuthDirectiveRoleEnum实际上我们已经通过TypeGraphQL.registerEnum注册过了,但还需要确保 Schema 中使用了这个枚举,它才会被添加到生成的 Schema 中。

    而且,如果为了确保类型的可靠,还需要重新写一遍这个 Enum。

    1export const enum AuthDirectiveRoleEnum {
    2 ADMIN,
    3 REVIEWER,
    4 USER,
    5 UNKNOWN,
    6}
    7
    8type AuthEnumMembers = keyof typeof AuthDirectiveRoleEnum;
    9
    10type AuthGraphQLObjectType = GraphQLObjectType & {
    11 _requiredAuthRole: AuthEnumMembers;
    12 _authFieldsWrapped: boolean;
    13};
    14
    15type AuthGraphQLField<T, K> = GraphQLField<T, K> & {
    16 _requiredAuthRole: AuthEnumMembers;
    17};

    因为常量枚举不能被作为参数传给注册函数。

TypeGraphQL 由于是用 TypeScript 的 Class 和 Decorator 来书写 GraphQL Schema,因此必然没有原生的 SDL 那么自由,比如没法将枚举应用在联合类型(Union)、枚举、枚举值上。 这一点估计要等作者着手解决这个问题,比如在 registerEnum 的选项中新增指令相关的配置。

实战:@fetch

当 GraphQL API 涉及到第三方的 DataSource 时(如作为 BFF),一种方案是采用类似 Apollo-DataSource 的库,将第三方的数据源相关获取方法挂载到 Context 中,然后由 resolver 负责调用。另一种方案就是使用指令,因为前面也提到了,指令能够动态的修改 resolver 的执行逻辑。

@fetch的实现也较为简单,拿到参数中的 url,调用 fetch 方法即可。通常情况下这一指令只能用来实现较为简单的请求逻辑,因此并不推荐大面积使用。

1import { defaultFieldResolver, GraphQLField } from "graphql";
2import { SchemaDirectiveVisitor } from "graphql-tools";
3import fetch from "node-fetch";
4
5export class FetchDirective extends SchemaDirectiveVisitor {
6 public visitFieldDefinition(field: GraphQLField<any, any>) {
7 const { resolve = defaultFieldResolver } = field;
8 const { url } = this.args;
9 field。resolve = async (...args) => {
10 const fetchRes = await fetch(url);
11 console.log(
12 `@fetch invoked on ${args[3].parentType}.${args[3].fieldName}`
13 );
14 console.log(`Fetch Status: ${fetchRes.status}`);
15 const result = await resolve.apply(this, args);
16 return result;
17 };
18 }
19}

实战:字符串处理指令

字符串相关的指令是最广泛使用的类型之一,如 Uppercase、Lowercase、Capitalize 等等。Lodash 提供了很多这一类的处理方法,因此我们直接使用即可。

在开始前其实可以多一个考虑,我们肯定不能把所有 case 都放进同一个指令,然后通过参数指定逻辑,比如@string(Uppercase)@string(Lowercase)这样。正确的做法是为每种处理指定一个独立的指令,如@upper lower等。并且,由于这些指令的内置逻辑其实是类似的(获取解析结果-应用变更-返回新的结果),所以把相同逻辑解耦出来。

这里我们使用 Mixin 的方式,基类接收指令名(如upper)与转换方法(来自 lodash)。

1export type StringTransformer = (arg: string) => string;
2
3const CreateStringDirectiveMixin = (
4 directiveNameArg: string,
5 transformer: StringTransformer
6): typeof SchemaDirectiveVisitor => {
7 class StringDirective extends SchemaDirectiveVisitor {
8 visitFieldDefinition(field: GraphQLField<any, any>) {
9 const { resolve = defaultFieldResolver } = field;
10 field。resolve = async (...args) => {
11 console.log(
12 `@${directiveNameArg} invoked on ${args[3].parentType}.${args[3].fieldName}`
13 );
14 const result = await resolve.apply(this, args);
15 if (typeof result === "string") {
16 return transformer(result);
17 }
18
19 return result;
20 };
21 }
22
23 public static getDirectiveDeclaration(
24 directiveName: string,
25 schema: GraphQLSchema
26 ): GraphQLDirective {
27 const previousDirective = schema.getDirective(directiveName);
28
29 if (previousDirective) {
30 return previousDirective;
31 }
32
33 return new GraphQLDirective({
34 name: directiveNameArg,
35 locations: [DirectiveLocation.FIELD_DEFINITION],
36 });
37 }
38 }
39
40 return StringDirective;
41};

一次性创建一批:

1import {
2 lowerCase,
3 upperCase,
4 camelCase,
5 startCase,
6 capitalize,
7 kebabCase,
8 trim,
9 snakeCase,
10} from "lodash";
11
12export const UpperDirective = CreateStringDirectiveMixin("upper", upperCase);
13
14export const LowerDirective = CreateStringDirectiveMixin("lower", lowerCase);
15
16export const CamelCaseDirective = CreateStringDirectiveMixin(
17 "camelCase",
18 camelCase
19);
20
21export const StartCaseDirective = CreateStringDirectiveMixin(
22 "startCase",
23 startCase
24);
25
26export const CapitalizeDirective = CreateStringDirectiveMixin(
27 "capitalize",
28 capitalize
29);
30
31export const KebabCaseDirective = CreateStringDirectiveMixin(
32 "kebabCase",
33 kebabCase
34);
35
36export const TrimDirective = CreateStringDirectiveMixin("trim", trim);
37
38export const SnakeCaseDirective = CreateStringDirectiveMixin(
39 "snake",
40 snakeCase
41);