GraphQL 接口:schema、resolver、鉴权与 N+1
GraphQL 把"一个 URL 一个资源"换成"一个端点、客户端声明要哪些字段"。面试常停在"Query/Mutation",答不出真正决定成败的三件事:字段级解析如何被批处理(N+1)、错误为什么 HTTP 仍是 200、鉴权发生在 URL 层还是字段层。本页固定 Spring for GraphQL 2.0.6 / graphql-java 25.1 / Boot 4.0.9 / JDK21,全部观察来自 graphql-interface 实验的真实 HTTP 运行(2026-10-29),运行证据见 evidence JSON。REST 与内容协商见MVC 消息转换;安全链机制见Security 过滤链;MyBatis 的 N+1 对照见MyBatis 篇。
schema 先行:schema 文件与注解控制器
Boot 在类路径 graphql/**/*.graphqls 找 schema(spring.graphql.schema.locations 可改),启动时没有 schema 则 GraphQL 自动配置不生效——@ConditionalOnGraphQlSchema 是整套 GraphQL 栈的开关。@QueryMapping/@MutationMapping/@SchemaMapping 方法按"方法名=字段名、返回类型=GraphQL 类型"绑定;参数用 @Argument 拿 GraphQL 输入。实验的 schema 含 Book.author(N+1 写法)与 Book.authorBatch(批量写法)两个字段,对照同一数据的两种解析成本。
原码连续摘录(仅统一公共缩进,截取方法局部):GraphQlArgumentBinder.java,v2.0.6,L155–L181。
throws BindException {
Object rawValue = (name != null) ? environment.getArgument(name) : environment.getArguments();
boolean isOmitted = (name != null && !environment.getArguments().containsKey(name));
return bind(rawValue, isOmitted, targetType);
}
/**
* Variant of {@link #bind(DataFetchingEnvironment, String, ResolvableType)}
* with a pre-extracted raw value to bind from.
* @param rawValue the raw argument value (Collection, Map, or scalar)
* @param isOmitted {@code true} if the argument was omitted from the input
* and {@code false} if it was provided, but possibly {@code null}
* @param targetType the type of Object to create
* @since 1.3.0
*/
public @Nullable Object bind(@Nullable Object rawValue, boolean isOmitted, ResolvableType targetType) throws BindException {
ArgumentsBindingResult bindingResult = new ArgumentsBindingResult(targetType);
Class<?> targetClass = targetType.resolve(Object.class);
Object value = bindRawValue("$", rawValue, isOmitted, targetType, targetClass, bindingResult);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
}
return value;
}绑定失败累积成 BindException(字段路径定位到参数路径)。实验实测:authorId: "not-a-number" 这类转换失败在 @Argument 绑定阶段抛 BindException 层次异常,被自定义 DataFetcherExceptionResolver 归为 BAD_REQUEST——输入错误在 resolver 执行前就被拦住,符合"schema 已经声明类型,非法输入不该进业务代码"的契约。
参数校验:@Valid 走 Bean Validation,@Validated 走方法校验
schema 保证类型正确,但 title: "" 仍是合法字符串。@Argument @Valid BookInput 让 AnnotatedControllerConfigurer 用 Bean Validation 校验参数;失败抛 ConstraintViolationException,交给异常解析器翻译。
原码连续摘录(仅统一公共缩进,截取方法局部):ValidationHelper.java,v2.0.6,L150–L170。
private final Class<?>[] validationGroups;
HandlerMethodValidator(HandlerMethod handlerMethod, Class<?> @Nullable[] validationGroups) {
Assert.notNull(handlerMethod, "HandlerMethod is required");
this.method = handlerMethod.getMethod();
this.validationGroups = (validationGroups != null) ? validationGroups : new Class<?>[] {};
}
@Override
public void accept(Object controller, @Nullable Object[] arguments) {
Set<ConstraintViolation<Object>> violations =
ValidationHelper.this.validator.forExecutables()
.validateParameters(controller, this.method, arguments, this.validationGroups);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}注意校验发生在方法调用之前(validateParameters 先跑再 invoke),所以 resolver 内不需要重复防御 null/空串,但资源级鉴权("这个用户能不能改这本书")仍必须进业务逻辑——参数校验回答"输入格式对不对",不回答"这个人该不该做"。
错误模型:HTTP 200 + errors 数组
GraphQL 规范把错误放进响应的 errors 数组,HTTP 状态仍 200——这不是实现偷懒,是协议语义:部分字段可以成功、部分字段可以失败。实验四条观察:语法/未知字段校验失败→errors 里有 message、data 为 null;@Valid 失败→BAD_REQUEST 分类;@PreAuthorize 拒绝→FORBIDDEN;未处理异常(NumberFormatException)→服务端转成不透明的 INTERNAL_ERROR(不泄漏异常细节)。
原码连续摘录(仅统一公共缩进,截取方法局部):SecurityDataFetcherExceptionResolver.java,v2.0.6,L63–L72。
return SecurityExceptionResolverUtils.resolveUnauthorized(environment);
}
if (ex instanceof AccessDeniedException) {
SecurityContext securityContext = SecurityContextHolder.getContext();
return SecurityExceptionResolverUtils.resolveAccessDenied(environment, this.trustResolver, securityContext);
}
return null;
}
}两层鉴权失败的落点
关键分层:HTTP 层鉴权失败(无凭据访问 /graphql)在 Security 过滤链就被拦,返回 401,根本不进 GraphQL 执行;字段级鉴权失败(@PreAuthorize 拒绝)进入 errors 数组,HTTP 仍是 200。实验两条都验证了。这意味着监控只看 HTTP 状态码会漏掉 GraphQL 层的错误率——指标要读 errors 数组/GraphQL interceptors,而不是 4xx/5xx 计数器。
N+1 与批量加载:DataLoader 的两次机会
{ books { author { name } } } 的天真实现:先查 3 本书(1 条 SQL),再对每本书调一次 author resolver(3 条 SQL)——共 4 条,书越多线性增长,这就是 N+1。GraphQL 的解药是 DataLoader:把 N 次 load(key) 攒成一次批查询,并且按 key 去重。
原码连续摘录(仅统一公共缩进,截取方法局部):DefaultExecutionGraphQlService.java,v2.0.6,L93–L120。
ExecutionInput executionInputToUse = registerDataLoaders(executionInput);
return Mono.fromFuture(this.graphQlSource.graphQl().executeAsync(executionInputToUse))
.onErrorResume((ex) -> ex instanceof GraphQLError, (ex) ->
Mono.just(ExecutionResult.newExecutionResult().addError((GraphQLError) ex).build()))
.map((result) -> new DefaultExecutionGraphQlResponse(executionInputToUse, result))
.doOnCancel(executionInputToUse::cancel);
});
}
private ExecutionInput registerDataLoaders(ExecutionInput executionInput) {
if (this.hasDataLoaderRegistrations == null) {
this.hasDataLoaderRegistrations = initHasDataLoaderRegistrations();
}
if (this.hasDataLoaderRegistrations) {
GraphQLContext graphQLContext = executionInput.getGraphQLContext();
DataLoaderRegistry existingRegistry = executionInput.getDataLoaderRegistry();
if (existingRegistry == EmptyDataLoaderRegistryInstance.EMPTY_DATALOADER_REGISTRY) {
DataLoaderRegistry newRegistry = DataLoaderRegistry.newRegistry().build();
applyDataLoaderRegistrars(newRegistry, graphQLContext);
executionInput = executionInput.transform((builder) -> builder.dataLoaderRegistry(newRegistry));
}
else {
applyDataLoaderRegistrars(existingRegistry, graphQLContext);
}
}
return executionInput;
}DataLoader 注册在每个请求的执行输入上(不是跨请求单例):请求 A 的缓存不会泄漏到请求 B,这保证了租户隔离;代价是同一 key 在一个请求内重复出现时,批函数每次请求都会重跑,且批函数只收到当前请求去重后的 key。
DataLoader 去重与请求级生命周期
实验三种写法对照(3 本书、2 个作者,自建 SqlCounter 统计):
| 写法 | SQL 次数 | 说明 |
|---|---|---|
@SchemaMapping 每书一查 | 1 + 3 = 4 | 经典 N+1,随书数线性增长 |
@BatchMapping | 1 + 1 = 2 | 本请求所有 Book 一次 IN 查询 |
BatchLoaderRegistry 显式 DataLoader | 1 + 1 = 2,批函数调用 1 次、收到 2 个 key | 按 key 去重;3 本书共享 2 作者故 5 次 load → 2 key |
@BatchMapping 本质是 BatchLoaderRegistry 的语法糖:AnnotatedControllerConfigurer 在启动时把方法注册成命名 DataLoader(DataLoaderKey = 类型.字段),每个请求执行时由 DefaultExecutionGraphQlService.registerDataLoaders 装进 DataLoaderRegistry。maxBatchSize 默认 -1(不限批大小)——大结果集要显式分批,否则一次 IN 查询可能拉回过多行。
鉴权与租户:把身份放在 context,不要放在参数里
GraphQL 只有一个 URL,URL 级鉴权只能回答"能不能进这个端点",回答不了"能不能看这个字段"。实验的分层:Security 过滤链保护 /graphql(未认证直接 401);@PreAuthorize("hasRole('ADMIN')") 保护 addBook mutation(READER 用户拿 FORBIDDEN error);租户号从 HTTP 头 X-Tenant 经 WebGraphQlInterceptor 进 GraphQLContext,再进 ThreadLocal 供 resolver 读取——租户绝不能作为 GraphQL 参数,否则客户端可以任意伪造。
实验验证:X-Tenant: tenant-a 返回 3 本书,X-Tenant: tenant-b 只返回 1 本——租户过滤在 SQL 的 where 子句里,不在 GraphQL 层。忘记这一点的典型事故:resolver 用 book(id) 拿数据但 where 只带 id 不带 tenant,tenant-b 的用户能读到 tenant-a 的书。
租户上下文用 ThreadLocal 在同步执行下工作,但 GraphQL 的 CompletableFuture 异步执行可能让 resolver 跑在别的线程——生产级实现应走 GraphQLContext(执行上下文随请求传递)而非 ThreadLocal。实验的拦截器同时写了两处(context+ThreadLocal)以演示,正文如实标注这个陷阱。
Boot 集成与投影:schema 位置、GraphiQL 与 spring-graphql-test
Boot 的 GraphQlAutoConfiguration 在类路径有 schema 文件时装配 GraphQlSource、ExecutionGraphQlService(注册 BatchLoaderRegistry)、AnnotatedControllerConfigurer;不满足 @ConditionalOnGraphQlSchema 时整套 GraphQL 栈完全不建(不会"半死")。spring.graphql.graphiql.enabled=false 是生产默认(默认关闭),schema printer 同理;实验关闭 GraphiQL,通过 HttpGraphQlTester 测试。
测试用 spring-graphql-test 的 HttpGraphQlTester 打真实 HTTP(随机端口、真实 Security 链、真实执行引擎),而不是 mock GraphQlSource——错误分类、401、租户头都是端到端行为,mock 掉就测不到。
能力选型与运维边界
| 决策 | 选 GraphQL | 选 REST |
|---|---|---|
| 客户端字段需求差异大 | 客户端声明字段,减少 over-fetch | 多端点/多版本 |
| 缓存策略 | 单一 POST 端点,HTTP 缓存基本失效;要按字段/查询做应用层缓存 | URL 级缓存天然可用 |
| 错误处理 | body 内 errors 数组,状态码恒 200 | 4xx/5xx 语义 |
| N+1 风险 | 必须 DataLoader/批量,否则放大 | 显式端点控制 |
GraphQL 不是"更好的 REST":它把请求形状的控制权交给客户端,代价是服务端必须为任意字段组合设预算(query 复杂度/深度限制、DataLoader、超时)。实验未做深度/复杂度限制——生产必须加,否则恶意查询可绕开所有 N+1 优化。
条件变化后的选择与恢复
换数据库时 DataLoader 的 IN 查询要考虑参数上限(Oracle 1000、PostgreSQL 65535),大 key 集合要分批;GraphQL 订阅走 WebSocket 时错误模型、租户上下文传播与 HTTP 路径完全不同(本页未运行);多实例部署时 DataLoader 缓存不跨实例,热点 key 要在数据层解决(缓存/批量视图),不能靠 GraphQL 层。N+1 优化的验收口径是 SQL 条数随父对象数量不变,而不是"感觉快了"。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
官方来源与复现限制
| 来源 | 版本 | 用途 |
|---|---|---|
| Spring for GraphQL 参考 2.0 | 2.0.6 | 注解控制器、DataLoader、异常、安全 |
| Boot GraphQL 文档 | 4.0.9 | schema 位置、GraphiQL、transports |
| spring-graphql 源码 | v2.0.6 | BatchLoaderRegistry/DefaultExecutionGraphQlService/ValidationHelper |
本地 graphql-interface 实验 | Boot 4.0.9 / graphql-java 25.1 / H2 2.4.240 | 本页全部实测观察 |
访问日期:2026-10-29。WebSocket 订阅/SSE、multipart、RSocket、federation、分页 Connection、maxBatchSize 分批、AOT/native 未运行,标注为源码/文档推演。