Skip to content
Linbudu's Blog

你不知道的 GraphQL Directives

1 min read

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

基本定义

GraphQL Directives是被注入到(或者说书写在)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 = 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<anyany>) {
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<TK> & {
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<anyany>
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<anyany>;
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。OBJECTDirectiveLocation。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。OBJECTDirectiveLocation。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<TK> = GraphQLField<TK> & {
    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<anyany>) {
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<anyany>) {
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);