— 1 min read
这篇文章来自数月前的随手记录,由于觉得内容较为小众(GraphQL 接触的人已经不多了,其中的 Directive 了解的人更少)就没有发出来。这次心血来潮炒个冷饭。实际上我一直很想出一系列 GraphQL 的文章,涵盖入门到实战再到原理,奈何一直没有动力,也许要到今年入职以后才会尝试开始吧。
GraphQL Directive 是被注入到(或者说书写在)GraphQL Schema 中的特殊语法,以@
开头,例如:
1directive @camelCase on FIELD_DEFINITION2directive @date(format: String = "DD-MM-YYYY") on FIELD_DEFINITION3directive @auth(4 requires: Role = ADMIN,5) on OBJECT | FIELD_DEFINITION6
7enum Role {8 ADMIN9 REVIEWER10 USER11 UNKNOWN12}13
14query {15 user @auth(requires: LOGIN){16 name17 date @formatDate(template: "YYYY-MM-DD")18 desc @camelCase19 }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 Definitions3 // "根级别"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 Definitions14 // 各种类型中的级别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 中,使用了一系列VisitXXX
方法来达到这个效果,如:
1import { SchemaDirectiveVisitor } from "graphql-tools";2
3export class DeprecatedDirective extends SchemaDirectiveVisitor {4 visitSchema(schema: GraphQLSchema) {}5 visitObject(object: GraphQLObjectType) {}6 visitFieldDefinition(field: GraphQLField<any, any>) {}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 // getDirectiveDeclaration18 // implementsVisitorMethod19 // visitSchemaDirectives20}
这里的 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
等少数类型上有isDeprecated
和deprecationReason
,也就是只有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 节点信息。
指令最重要的作用就是在运行时动态的去修改返回的数据格式,包括值以及字段结构等都能改变,举例来说,在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 即可),完全可以说是为所欲为。
指令还有个重要作用就是类似 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<T, K> = 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 any49 >[];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: GraphQLSchema79 ): 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 的选项中新增指令相关的配置。
当 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: StringTransformer6): 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: GraphQLSchema26 ): 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 camelCase19);20
21export const StartCaseDirective = CreateStringDirectiveMixin(22 "startCase",23 startCase24);25
26export const CapitalizeDirective = CreateStringDirectiveMixin(27 "capitalize",28 capitalize29);30
31export const KebabCaseDirective = CreateStringDirectiveMixin(32 "kebabCase",33 kebabCase34);35
36export const TrimDirective = CreateStringDirectiveMixin("trim", trim);37
38export const SnakeCaseDirective = CreateStringDirectiveMixin(39 "snake",40 snakeCase41);