从 REST 到 GraphQL:Spring 栈上的契约、解析器与实时推送#
一场 DJ 控制台要把当前混音会话、曲目列表、艺人信息、观众投票和现场状态同时摆在屏幕上。用 REST 也能做——一个 GET 把整棵 JSON 树拉回来——但 DJ 屏往往只要其中一部分字段,而投票页只要另一套;实时状态若靠轮询,延迟和负载都会难看。Spring I/O 2026 上 Frederieke Scheper 与 Peter Eijgermans 用 Disc Jockey Console 演示了在 Spring GraphQL 与 Angular + Apollo 上从 REST 思路迁到 schema 驱动 API 的完整路径。下文按机制拆解,不当作现场实录;演示类名来自讲者代码,框架行为以官方文档与 GraphQL 规范(October 2021) 为准。

何时仍用 REST,何时值得引入 GraphQL#
为什么:GraphQL 语言规范 写明,客户端通过 selection set 只取所需字段,以避免 over-fetching 与 under-fetching。REST 在「简单 CRUD、领域稳定、消费者固定」时成本更低;讲者归纳(演讲者观点)为:单消费者、已有 OpenAPI / Swagger 投资、强依赖 HTTP 缓存时,继续 REST 往往更稳妥。另一方面,DJ 控制台需要 sessions、tracks、songs、artists 等多形态数据,且存在 live 更新——规范将 subscription 定义为 long-lived、按事件推送数据的请求,与 REST 轮询在架构上形成对照。
机制与约束:Spring 侧 Query/Mutation 默认走 HTTP POST 到 /graphql,正文为 JSON(见 Spring GraphQL — Transports)。这与常见「REST GET + URL 级 CDN 缓存」不同——并非断言 GraphQL 完全不能缓存(客户端 normalized cache、persisted query 等另有路径),而是默认 POST 单端点下,HTTP 层缓存不如 REST GET 资源那样开箱即用。
怎么做:在引入 GraphQL 前先写清消费者数量、字段差异、是否要推送、团队学习成本;若仅一个内部服务、契约稳定,不必为 GraphQL 而 GraphQL。
常见误区:把「REST 能缓存、GraphQL 不能」当成绝对律;忽略 persisted query、字段级 CDN 等补充手段。把选型当成宗教战争——讲者收束为「REST isn’t wrong / GraphQL isn’t magic」(演讲者观点)。
若你维护的是荷兰警方或铁路这类大型组织里的内部系统(讲者背景,演讲者观点),GraphQL 往往出现在「多团队、多 UI、同一后端」的接缝处;但若只有一个批处理消费者、契约十年不变,引入 schema 与 WebSocket 基础设施的边际收益可能为负。务实做法是先用一张表列出「字段差异 × 消费者数量 × 是否要推送 × 团队熟练度」,再决定是否在新边界上引入 GraphQL,而不是一次性替换所有 REST 端点。

| 维度 | REST 更顺手时 | GraphQL 更值得评估时 |
|---|---|---|
| 消费者 | 单一、契约固定 | 多前端、字段需求分化 |
| 数据形状 | 稳定 CRUD | 嵌套聚合、按需字段 |
| 实时性 | 轮询可接受 | 需要 subscription |
| 缓存 | 强依赖 ETag / CDN | 可接受应用层与客户端缓存策略 |
Schema 作为唯一契约与传输分工#
为什么:多个微前端(讲者场景含 Module Federation 远程组件,属前端架构,Spring 文档不涵盖)若各维护一套 REST 聚合端点,契约容易漂移。GraphQL schema 是类型系统层面的单一合同;客户端可通过 introspection(生产可关闭)与工具链消费同一形状。
机制与约束:
- Query / Mutation:HTTP(Spring 的
GraphQlHttpHandler,POST/graphql)。 - Subscription:WebSocket,Spring 基于 graphql-ws(子协议
graphql-transport-ws),旧版subscriptions-transport-ws已不活跃。

怎么做:在 src/main/resources/graphql/ 放置 .graphqls(Boot 默认自动加载,见 Spring Boot — GraphQL Schema),Query 字段名与 Java 方法名对齐(如 currentMixSession)。
常见误区:口头说「GraphQL WebSocket」却不区分协议名;在 schema 未稳定前就让多团队各自扩展 mutation 而无 additive 演进与 @deprecated 策略。
最小 schema 片段(字段名需与控制器一致)可写成:
type Query {
currentMixSession: MixSession
}
type Mutation {
crowdCheered(id: ID!): MixSession
}
type Subscription {
mixSessionUpdated(id: ID!): MixSession
}
配合 spring.graphql.graphiql.enabled=true(开发环境)可在 GraphiQL 中对照契约与 resolver。spring.graphql.schema.locations 与 file-extensions 可在多模块时改为 classpath*:graphql/**/(Schema Resources)。

注解控制器:按字段映射,而非按 URL#
为什么:REST 常为每个资源配 @GetMapping / @PostMapping;GraphQL 入口通常是单一 HTTP 端点,解析按 operation 与 schema 字段 分发。Spring 用 @QueryMapping、@MutationMapping、@SubscriptionMapping 作为 @SchemaMapping 的快捷形式(Annotated Controllers)。
机制与约束:AnnotatedControllerConfigurer 将带注解方法注册为 DataFetcher;未在注解中声明名称时,默认用 Java 方法名 映射字段名。@Argument 绑定 GraphQL 参数。
怎么做(演示结构,类名来自现场):
@Controller
public class DiscJockeyConsoleGraphQLController {
@QueryMapping
public MixSession currentMixSession() {
return mixSessionService.getCurrentSession();
}
@MutationMapping
public MixSession crowdCheered(@Argument UUID id) {
return mixSessionService.applyCrowdCheered(id);
}
}
常见误区:在 GraphQL 控制器里写 URL 路径思维;一个 mutation 塞满跨聚合副作用而不下沉领域服务。


字段级 @SchemaMapping 与列表参数#
为什么:即便客户端用 selection set 省略字段,DJ 列表仍可能过长;在 schema 上为 MixSession.tracks(last: Int) 增加参数,可在服务端只返回最后 N 条,减轻渲染与传输。
机制与约束:@SchemaMapping 将方法绑到类型的字段 DataFetcher,方法第一个参数为 source(父对象),@Argument 注入字段参数(文档)。在方法内 subList 属于应用层切片;规范不强制此种参数化,但是合法设计。演讲未展开 DataLoader 与 N+1——生产嵌套列表应单独评估。
怎么做:
@SchemaMapping
public List<SessionTrack> tracks(MixSession mixSession, @Argument Integer last) {
List<SessionTrack> all = mixSession.tracks();
if (last == null || last >= all.size()) return all;
return all.subList(all.size() - last, all.size());
}
常见误区:把所有列表裁剪都堆在 resolver,而不在持久层分页;嵌套字段循环查询导致 N+1(本场未演示防护)。

Mutation、领域服务与订阅广播#
为什么:观众点击「CROWD CHEERED」一类交互既要持久化领域状态,又要让已订阅的 DJ 屏/看板收到更新。GraphQL mutation 宜作薄编排层,状态变更与事务边界放在领域服务内。
机制与约束:@MutationMapping 方法调用 MixSessionServiceImpl 等 Spring bean;@Transactional 与 repository.save 属演示领域模型(未能从官方文档核实类名)。保存后通过 MixSessionUpdatePublisher.publish 向 Reactor 流发射事件,供 @SubscriptionMapping 消费。Spring 文档写明 subscription 响应在 GraphQL Java 侧为 Reactive Streams Publisher(Transports)。
怎么做:
@Transactional
public MixSession applyCrowdCheered(UUID id) {
var session = getSessionById(id);
var updated = session.applyEvent(new CrowdCheered(LocalDateTime.now()));
var saved = repository.save(updated);
mixSessionUpdatePublisher.publish(saved);
return saved;
}
常见误区:在 resolver 里直接操作 repository,导致测试与 ArchUnit 分层难以约束;mutation 成功但未 publish,订阅端永远收不到事件。


Subscription:Sinks、Flux 与 WebSocket#
为什么:替代对 currentMixSession 的轮询,在混音会话或投票 tally 变化时主动推送。
机制与约束:
@SubscriptionMapping可返回Flux<T>(Controllers)。- 演示用
Sinks.many().multicast().onBackpressureBuffer()与tryEmitNext(Reactor Sinks API)在进程内广播;多实例、粘性会话、鉴权 不在 Spring GraphQL 文档范围内,属运维专题。 - 按 session
id过滤:sink.asFlux().filter(...)。讲者提到可用Flux.concat先推当前快照再跟更新流(演示技巧,非框架必选)。

怎么做:
public class MixSessionUpdatePublisher {
private final Sinks.Many<MixSession> sink =
Sinks.many().multicast().onBackpressureBuffer();
public void publish(MixSession s) { sink.tryEmitNext(s); }
public Flux<MixSession> streamForSession(UUID id) {
return sink.asFlux().filter(s -> s.id().value().equals(id));
}
}
@SubscriptionMapping
public Flux<MixSession> mixSessionUpdated(@Argument UUID id) {
return mixSessionUpdatePublisher.streamForSession(id);
}
常见误区:把内存 Sinks 当成跨 Pod 广播方案;忽略 EmitResult 失败与背压;在未鉴权 WebSocket 上推送敏感会话。讲者直言生产里 subscription「difficult / you skip it」(演讲者观点)——与文档强调协议复杂度并不矛盾,但不应理解为规范禁止 subscription。若仅需「偶尔刷新」,SSE 或短周期轮询有时比维护 graphql-ws 集群更便宜——这属于工程权衡,而非规范裁决。

错误模型:HTTP 2xx 与 JSON errors#
为什么:查询不存在的 session 时,前端(Apollo)需要稳定解析业务错误,而不是只判断 HTTP 4xx。
机制与约束:
- GraphQL 规范 — Errors:响应可同时含
data与errors;字段错误时data仍可能存在(partial result)。规范不规定 HTTP 状态码。 - GraphQL.org — Serving over HTTP:当存在非 null
data时,即使伴随errors,对 JSON 响应宜返回 2xx(partial success)。因此「HTTP 常为 200」是常见实践,网关若改写状态码需自行验证。 - Spring:继承
DataFetcherExceptionResolverAdapter,用GraphqlErrorBuilder的extensions附加errorCode(Exceptions)。
怎么做:
@Component
class GraphQLExceptionResolver extends DataFetcherExceptionResolverAdapter {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof DJConsoleException dce) {
return GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.extensions(Map.of("errorCode", dce.getCode()))
.build();
}
return super.resolveToSingleError(ex, env);
}
}
服务层 orElseThrow(() -> new MixSessionNotFoundException(...)) 会进入上述解析链。
常见误区:假设所有 GraphQL 错误都是 HTTP 200;解析失败、无 data 等情形仍可能 4xx。只在 REST 客户端里写 response.ok 判断,忽略 errors[0].extensions。
用 curl 向本地 /graphql 发送查询不存在 id 的 operation(需替换为实际 schema 字段),典型响应形态类似:
curl -s -X POST http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query { currentMixSession { id } }"}'
响应体可能同时含 "data": null 与 "errors": [{ "message": "...", "extensions": { "errorCode": "..." } }];HTTP 状态在 Spring Boot 默认配置下常为 200,但以你方网关与 spring.graphql 版本为准,未在本文环境复现的部署不应外推。

测试与分层:GraphQlTester 与 ArchUnit#
为什么:在不启动完整 HTTP/WebSocket 的情况下断言字段路径;并防止 GraphQL 类型渗入领域包。
机制与约束:
@GraphQlTest(spring-boot-graphql-test)切片测试控制器;GraphQlTester的documentName从 classpath(如graphql-test/)加载.graphql文档。- Spring for GraphQL 构建于 GraphQL Java 之上。
- ArchUnit 包依赖规则为演示约束(Layer Checks),非 Spring 内置。
怎么做:
@GraphQlTest(DiscJockeyConsoleGraphQLController.class)
class DiscJockeyConsoleGraphQLControllerTests {
@Autowired GraphQlTester graphQlTester;
@MockitoBean MixSessionService mixSessionService;
@Test void shouldGetCurrentMixSession() {
given(mixSessionService.getCurrentSession()).willReturn(session);
graphQlTester.documentName("currentMixSession").execute()
.path("currentMixSession.status").entity(String.class)
.isEqualTo("WARM_UP");
}
}
常见误区:只测 HTTP 集成不测 resolver 路径;让 service 模块 import org.springframework.graphql 导致 ArchUnit 失败被随意 @SuppressWarnings 掉。

前端:微前端、Apollo 与一次性投票流#
为什么:观众扫码打开远程组件 CrowdVotePageComponent,需从 query param 读取 slot,先查当前 session 再发起投票 mutation;缓存中的陈旧 session 会导致投错场次。
机制与约束:Apollo Client fetchPolicy: 'no-cache' 表示始终走网络且不写入 Apollo 缓存(与 network-only 相近但忽略外部 cache 更新)。路由 app-crowd-vote-page、mutation CAST_CROWD_VOTE、exhaustMap / takeUntilDestroyed 为演示代码(未能从 apollo-angular 单页核实与投票场景的绑定)。
怎么做(结构示意):
const slot = Number(this.route.snapshot.queryParamMap.get('slot'));
this.apollo.query({ query: CURRENT_MIX_SESSION, fetchPolicy: 'no-cache' })
.pipe(
take(1),
map(r => r.data?.currentMixSession),
filter((s): s is MixSession => !!s),
exhaustMap(session => this.apollo.mutate({
mutation: CAST_CROWD_VOTE,
variables: { id: session.id, slot },
})),
takeUntilDestroyed(this.destroyRef),
).subscribe();
常见误区:投票页用默认 cache-first 读到上一场 session;mutation 成功却不处理 GraphQL errors 数组。


上线前:订阅加固、分页与安全缺口#
为什么:演示证明「能跑通」≠「能在生产存活」。讲者清单(主体为演讲者观点,部分与文档精神一致)包括:需要 HTTP 缓存偏 REST、团队无学习时间则别硬上、多消费者与 additive schema 偏 GraphQL、生产难点在 subscription / 分页 / @PreAuthorize 等——本场未演示 Spring Security。
机制与约束(幻灯片 Before You Ship 与字幕交叉,具体条目以团队验证为准):
- Subscription 加固:WebSocket 握手后鉴权常依赖
connectionParams传 token;重连需配合 graphql-ws 与快照重放(如Flux.concat),避免 UI 展示过期状态;帧级指标需显式埋点(如 Micrometer)。 - 分页:长列表宜用 cursor(
first/after);Spring GraphQL 提供ScrollSubrange、Window<T>等(见参考文档),演讲未写端到端示例。


收束#
GraphQL 在 Spring 上的落地,核心是把 schema 合同、按字段的 DataFetcher、HTTP 与 graphql-ws 的分工 和 可测试的解析层 串成一条链;REST 并未失效,尤其在单消费者与 HTTP 缓存仍占优势的系统里。若你的场景是多个前端、嵌套聚合与实时推送,Spring GraphQL 提供的路径比「再加一个 BFF REST 聚合」更一致,但 subscription 运维、分页、安全与 N+1 仍需单独设计——这些在本场演示中刻意留白,不应误以为框架会自动解决。
未验证边界(显式列出):演示仓库的公开 Git URL 未在核验材料中给出,本文类名与方法链来自现场 OCR/字幕交叉,可能与最终开源分支有出入;DataLoader、cursor 分页、@PreAuthorize 与多副本 Sinks 广播均未在演讲中实现,读者若直接复制演示代码上生产,应单独做负载、安全与契约演进评审。HTTP REST 的 Accept / vendor media type 式版本策略本场未讨论,故本文不展开 REST 内容协商章节。
参考与延伸阅读#
- Spring GraphQL 参考文档
- Spring Boot — Spring for GraphQL
- Spring GraphQL — Annotated Controllers
- Spring GraphQL — Transports(HTTP POST 与 WebSocket)
- Spring GraphQL — Exceptions 与 DataFetcherExceptionResolver
- Spring GraphQL — Testing 与 GraphQlTester
- GraphQL 规范(October 2021)
- GraphQL 规范 — Field Deprecation
- GraphQL.org — Serving over HTTP(状态码与 partial success)
- graphql-ws 协议说明(graphql-transport-ws)
- Reactor Core — Sinks API
- Apollo Client — Queries 与 fetchPolicy



