指令(directive)是 GraphQL 里最被低估的语言特性。多数开发者只熟悉 @deprecated、@include、@skip 这三个内置指令,却不知道指令本质上是 Schema 上的元编程钩子——它让你把「横切关注点」从几十个 resolver 里抽出来,声明式地挂在字段、类型或查询上。字段级鉴权、输入校验、大小写转换、审计日志、成本标注,都可以用一条指令表达。
但指令也是把双刃剑。用得好,Schema 自解释、横切逻辑集中;用得滥,Schema 变成「靠隐式魔法运行的黑盒」,新人读不懂、工具链不支持、性能悄悄劣化。本文从指令的两种分类讲起,覆盖类型系统指令的运行时实现(mapSchema + MapperKind)、字段级鉴权的完整写法、可执行指令与查询计划的交互,最后给出指令设计的取舍准则与测试方法。
一、指令的两大类
1.1 可执行指令 vs 类型系统指令
GraphQL 规范把指令分成两大类,理解这个区分是避免混乱的第一步:
| 维度 | 可执行指令(Executable) | 类型系统指令(Type System) |
|---|---|---|
| 出现位置 | 客户端查询文档中 | Schema 定义(SDL)中 |
| 作用时机 | 请求执行期 | Schema 构建期 / 请求期 |
| 典型例子 | @include、@skip、@defer | @deprecated、@key、@auth |
| 谁定义 | Schema 声明 location: QUERY/... | Schema 声明 location: FIELD_DEFINITION/... |
| 实现方式 | 引擎内置或执行器钩子 | 服务端在 Schema 构建/解析时处理 |
# 类型系统指令:定义在 SDL 上
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
type Query {
me: User! @auth
adminStats: Stats! @auth(requires: ADMIN)
}
# 可执行指令:出现在查询里
query GetData($withOrders: Boolean!) {
me {
nickname
orders @include(if: $withOrders) { edges { node { id } } }
}
}
关键区别在于:类型系统指令是服务端的事,可执行指令是客户端与引擎的事。你自己定义的 @auth 是类型系统指令,客户端永远不会写它,它只在服务端解析 Schema 时被读取并转换成行为。
1.2 指令定义语法
一条指令定义包含三部分:名字、参数、可出现的 location。
directive @constraint(
minLength: Int
maxLength: Int
pattern: String
format: String
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
directive @audit(level: AuditLevel = INFO) on FIELD_DEFINITION
directive @tag(name: String!) repeatable on FIELD_DEFINITION | OBJECT
location 决定了指令能挂在哪里。常用取值:
| location | 含义 |
|---|---|
QUERY / MUTATION / SUBSCRIPTION | 可执行:操作类型 |
FIELD | 可执行:查询中的字段 |
FRAGMENT_DEFINITION / FRAGMENT_SPREAD | 可执行:片段 |
FIELD_DEFINITION | 类型系统:字段定义 |
OBJECT / INTERFACE / UNION / ENUM | 类型系统:类型定义 |
ARGUMENT_DEFINITION / INPUT_FIELD_DEFINITION | 类型系统:参数与输入字段 |
SCHEMA / SCALAR / ENUM_VALUE | 类型系统:其他位置 |
repeatable 关键字允许同一位置重复使用该指令(如多个 @tag),这在打标签类指令中很有用。
二、类型系统指令的运行时实现
2.1 mapSchema 与 MapperKind
SDL 里写下的指令本身不会做任何事——它只是一段被解析成 AST 的元数据。要让它「生效」,必须在服务端遍历 Schema,找到带指令的节点并改写其行为。@graphql-tools/utils 的 mapSchema 提供了这个能力:
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver } from 'graphql';
function authDirectiveTransformer(schema, directiveName = 'auth') {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const directive = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!directive) return fieldConfig;
const { requires = 'USER' } = directive;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const role = context.user?.role;
if (!role || !roleSatisfies(role, requires)) {
throw new GraphQLError('Forbidden', {
extensions: { code: 'FORBIDDEN', requiredRole: requires },
});
}
return resolve.call(this, source, args, context, info);
};
return fieldConfig;
},
});
}
mapSchema 按 MapperKind 分类遍历,常用的几种:
| MapperKind | 遍历对象 | 典型用途 |
|---|---|---|
OBJECT_FIELD | 对象类型的字段 | 字段级鉴权、日志、大小写转换 |
OBJECT_TYPE | 对象类型本身 | 类型级鉴权、类型重命名 |
ARGUMENT | 字段参数 | 参数级校验、默认值注入 |
INPUT_OBJECT_FIELD | 输入对象字段 | 输入校验、标量约束 |
SCALAR_TYPE | 标量类型 | 标量实现替换 |
ENUM_VALUE | 枚举值 | 枚举重命名 |
INTERFACE_FIELD | 接口字段 | 接口层横切 |
2.2 组合多条指令
多个指令变换可以用函数组合的方式叠加,顺序即执行顺序:
let schema = buildSchema(typeDefs);
schema = authDirectiveTransformer(schema, 'auth');
schema = upperDirectiveTransformer(schema, 'upper');
schema = constraintDirectiveTransformer(schema, 'constraint');
顺序很重要:鉴权变换若放在日志变换之后,就会记录到「被拒绝」的请求;放在之前则只记录通过的请求。把横切顺序写成一条显式的、有注释的流水线,并在 CI 里对最终 Schema 做快照测试。
2.3 指令参数的校验
自定义指令的参数不会自动校验——如果有人写了 @auth(requires: SUPERADMIN) 而角色枚举里没有它,运行时就会静默失效(roleSatisfies 返回 false,所有请求被拒)。必须在 Schema 构建期校验:
function assertValidAuthArgs(schema, directiveName = 'auth') {
const validRoles = new Set(['GUEST', 'USER', 'ADMIN', 'OWNER']);
mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const d = getDirective(schema, fieldConfig, directiveName)?.[0];
if (d && !validRoles.has(d.requires)) {
throw new Error(`Invalid @auth requires: ${d.requires}`);
}
return fieldConfig;
},
});
}
构建期失败优于运行期静默失效。这也是「指令是元编程」的代价:编译器(GraphQL 引擎)只保证语法合法,语义合法性要你自己把关。
三、实战:字段级鉴权指令
3.1 角色层级与满足关系
const ROLE_LEVEL = { GUEST: 0, USER: 1, ADMIN: 2, OWNER: 3 };
function roleSatisfies(actual, required) {
return (ROLE_LEVEL[actual] ?? -1) >= (ROLE_LEVEL[required] ?? Infinity);
}
用「层级数值比较」而非「集合包含」,可以表达角色继承(ADMIN 天然满足 USER 的要求)。若要支持多角色并集(ADMIN 或 OWNER 任一即可),把 requires 改成列表并做 some 判断。
3.2 字段级、类型级与行级
三层权限往往需要组合:
type Query {
me: User!
user(id: ID!): User @auth(requires: ADMIN) # 字段级:仅管理员能查任意用户
publicFeed: [Post!]! @auth(requires: GUEST) # 类型级:允许匿名
}
type User @auth(requires: USER) { # 类型级:整个类型需登录
id: ID!
email: String! @auth(requires: OWNER) # 字段级:仅本人可见
}
- 类型级:在
OBJECT_TYPE上挂@auth,对该类型的所有字段生效; - 字段级:在
OBJECT_FIELD上挂@auth,只影响单个字段; - 行级:无法用指令表达,必须在 resolver 里基于数据判断(如「只能看自己的订单」)。
行级权限的典型写法是在返回前过滤:
orders: async (_, args, ctx) => {
const rows = await db.orders.findMany({ where: { userId: ctx.user.id } });
return rows; // 强制按 userId 过滤,而非依赖客户端传参
}
指令解决「能不能访问这个字段」,resolver 解决「能访问哪些行」,两者缺一不可。权限体系的完整设计参见 认证与授权深度 。
3.3 与 Federation 的协同
如果服务在联邦子图中,@auth 变换必须在子图 Schema 构建后应用,且要注意 @key 等联邦指令的保留:
let schema = buildSubgraphSchema([{ typeDefs, resolvers }]);
schema = authDirectiveTransformer(schema, 'auth'); // 在联邦包装之后
若在联邦包装之前应用,mapSchema 遍历到的字段可能还不是最终形态,导致部分字段漏掉鉴权——这是联邦 + 指令组合时最隐蔽的 bug。
四、实战:字段变换指令
4.1 @upper:返回值转换
一个简单的字符串大写指令,演示「读字段值再加工」的模式:
directive @upper on FIELD_DEFINITION
type User {
nickname: String! @upper
}
function upperDirectiveTransformer(schema, directiveName = 'upper') {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const hasUpper = getDirective(schema, fieldConfig, directiveName)?.[0];
if (!hasUpper) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async function (source, args, context, info) {
const value = await resolve.call(this, source, args, context, info);
return typeof value === 'string' ? value.toUpperCase() : value;
};
return fieldConfig;
},
});
}
4.2 变换指令的适用边界
变换类指令(大小写、格式化、单位换算)用起来很爽,但要警惕三点:
- 它们对客户端不可见:客户端看到的是
String!,不知道会被大写。若契约要求明确,应在字段文档里写清楚; - 它们破坏了「字段名即语义」:
nickname变成大写后还是nickname吗?语义漂移会让调试变难; - 它们叠加在 DataLoader 之后:如果变换涉及 IO,会破坏批处理。变换指令应只做纯内存加工,绝不发起额外请求。
结论:变换指令适合展示层的规范化(如统一大写、去除首尾空格),不适合承载业务逻辑。
4.3 @constraint:输入校验指令
输入校验更适合用指令声明在参数上:
directive @constraint(
minLength: Int
maxLength: Int
pattern: String
min: Int
max: Int
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
type Mutation {
createUser(input: CreateUserInput!): User!
}
input CreateUserInput {
email: String! @constraint(pattern: "^[^@]+@[^@]+$")
nickname: String! @constraint(minLength: 2, maxLength: 20)
age: Int @constraint(min: 0, max: 150)
}
function constraintDirectiveTransformer(schema) {
return mapSchema(schema, {
[MapperKind.INPUT_OBJECT_FIELD]: (fieldConfig) => {
const rules = getDirective(schema, fieldConfig, 'constraint')?.[0];
if (rules) constraintRegistry.set(fieldConfig.astNode.name.value, rules);
return fieldConfig;
},
});
}
输入校验的完整策略(标量 vs 指令 vs resolver vs 数据库约束的分层)参见 Schema 设计进阶:接口、联合类型与可空性策略 。
五、可执行指令
5.1 内置的可执行指令
客户端可用的内置指令有限,但很实用:
| 指令 | 位置 | 作用 |
|---|---|---|
@include(if: Boolean!) | FIELD / FRAGMENT_SPREAD / INLINE_FRAGMENT | 条件包含 |
@skip(if: Boolean!) | 同上 | 条件跳过 |
@defer | FRAGMENT_SPREAD / INLINE_FRAGMENT | 延迟交付 |
@stream | FIELD | 流式列表元素 |
query Profile($withEmail: Boolean!, $isMobile: Boolean!) {
me {
nickname
email @include(if: $withEmail)
avatar @skip(if: $isMobile) { url width }
... @defer { heavyStats { visits clicks } }
}
}
@include/@skip 由引擎在执行期求值,是最可靠的「条件字段」手段——它比在客户端做 if 判断更省流量,因为未包含的字段根本不会进入查询。
5.2 自定义可执行指令
你可以在 Schema 里声明一条可执行指令,让客户端在查询中使用它,并在执行期读取:
directive @log(level: LogLevel = INFO) on FIELD
读取的方式是遍历 info.fieldNodes 上的指令:
function readFieldDirectives(info) {
const nodes = info.fieldNodes ?? [];
return nodes.flatMap((n) => n.directives ?? []);
}
不过自定义可执行指令的生态支持很差:持久化查询会把它固化进哈希、部分网关不识别、客户端工具(codegen)不会为它生成类型。因此除非有强需求,建议优先用类型系统指令 + 变量来表达可变行为,把可执行指令留给内置的那几个。
5.3 指令与持久化查询的冲突
持久化查询(APQ)会把查询文本哈希化。如果查询里带了自定义可执行指令,而服务端版本升级后指令语义变了,同一个哈希对应的行为就变了——这与「持久化查询保证行为稳定」的初衷矛盾。启用 APQ 时,可执行指令的集合应当冻结,作为协议的一部分。相关机制见 持久化查询与生产安全 。
六、性能与陷阱
6.1 指令会带来包装开销
每条作用于字段的指令都会把原来的 resolver 包一层函数。在深层嵌套查询里,这层包装的累积开销不可忽略:
| 指令数量 | 每字段额外开销 | 10 万字段/秒的影响 |
|---|---|---|
| 0 | 0 | 基线 |
| 1~2 | 极小 | < 1% |
| 5+ | 可感知 | 5%~15% |
| 每字段都挂 | 显著 | 20%+ |
原则:只在真正需要的字段上挂指令。不要为了「统一」给每个字段都挂 @audit——审计应当用拦截器(plugin)而非逐字段指令实现。
6.2 常见陷阱
- 指令未生效:忘了调用
mapSchema,或变换顺序错误,指令只是 SDL 里的装饰。对策是集成测试断言「未授权请求被拒」。 - 指令静默覆盖:多个变换都改写
fieldConfig.resolve,后者的包装可能丢掉前者的行为。对策是统一在一条流水线里组合,而非散落各处。 - 联邦指令被误改:
mapSchema若改写了@key/@external相关的字段,可能破坏实体解析。对策是变换只针对业务字段,且对_前缀的内部字段直接跳过。 - 指令参数漂移:SDD 改了但实现没跟上。对策是构建期校验参数合法性。
七、测试指令
7.1 三层测试
| 层 | 测什么 | 手段 |
|---|---|---|
| Schema 层 | 指令是否被正确解析、参数是否合法 | 构建期断言 + Schema 快照 |
| 行为层 | 挂指令的字段行为是否符合预期 | executeOperation 集成测试 |
| 回归层 | 指令组合后是否互相干扰 | 全量 Schema 的黄金用例 |
7.2 行为层测试示例
import { executeOperation } from '@apollo/server/helpers';
test('@auth 拒绝未授权访问', async () => {
const res = await executeOperation(server, {
query: `query { adminStats { totalUsers } }`,
// 不传 context.user,模拟匿名
});
expect(res.body.singleResult.errors?.[0].extensions.code).toBe('FORBIDDEN');
});
test('@upper 转换返回值', async () => {
const res = await executeOperation(server, { query: `query { me { nickname } }` }, {
contextValue: { user: { id: '1', role: 'USER' } },
});
expect(res.body.singleResult.data.me.nickname).toBe('LEETING');
});
指令的正确性无法靠「Schema 里有这条指令」来证明,必须用行为断言。测试体系的整体设计参见 契约测试与自动化验证 。
FAQ
Q1:什么时候该用指令,什么时候该写 resolver?
判断标准是「横切程度」:同一种逻辑要重复写在 3 个以上字段时,考虑抽成指令;只影响单个字段的业务逻辑,直接写在 resolver 里更清晰。指令的价值在于消除重复,不在于「显得高级」。
Q2:指令能访问请求上下文吗?
类型系统指令在 Schema 构建期被读取(此时没有请求上下文),但指令改写的 resolver 在请求期执行,可以访问 context。所以 @auth 能读 context.user,但「在构建期就根据用户决定是否挂载字段」是做不到的——那需要 Schema 按用户动态生成,属于另一种模式。
Q3:客户端能看到自定义类型系统指令吗?
能。自省(introspection)会返回指令定义,客户端工具(如 GraphiQL)会显示它们。若不想暴露内部指令,要么在自省层过滤,要么干脆不用 SDL 指令、改用构建期插件注入行为。
Q4:指令能替代中间件吗?
部分能。鉴权、日志、限流这类横切关注点,指令可以表达,但更推荐用执行器插件(Apollo 的 plugin、Envelop 的 plugin)——插件作用于整个请求,开销更低、语义更集中。指令更适合字段级、声明式的差异化处理。
Q5:如何避免指令滥用到无法维护?
两条纪律:一是限制指令数量,团队维护的指令集合不应超过 5~8 条,每新增一条要有明确的使用场景与文档;二是Schema 快照 + 构建期校验,任何指令参数错误在构建期失败。指令是 Schema 的一部分,应当像 Schema 一样接受评审。
小结
自定义指令的本质是 Schema 上的元编程钩子:它把字段级鉴权、输入校验、返回值变换这些横切逻辑从 resolver 里抽出来,用声明式语法表达,再用 mapSchema + MapperKind 在构建期改写成行为。它的价值在于消除重复、让 Schema 自解释;它的代价是隐式性——指令不生效时不会报错,参数写错时可能静默失效。因此工程上要守住三条线:构建期校验指令参数、变换写成显式流水线、用行为测试而非 Schema 断言验证指令。指令数量控制在个位数,横切逻辑优先用执行器插件,只有真正字段级差异化的需求才落到指令上——这样它才是资产,而不是负债。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。