跳过正文
从 REST 到 GraphQL:Spring 栈上的契约、解析器与实时推送
  1. 文章/

从 REST 到 GraphQL:Spring 栈上的契约、解析器与实时推送

·5463 字·11 分钟
NeatGuyCoding
作者
NeatGuyCoding

从 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) 为准。

Disc Jockey Console 现场演示:SETLIST 与 CROWD CHEERED 等交互构成选型与 API 设计的业务背景


何时仍用 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 Still Works — Until It Doesn’t:单消费者、OpenAPI 与 HTTP 缓存等仍偏向 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 已不活跃。

Mermaid diagram 1

怎么做:在 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.locationsfile-extensions 可在多模块时改为 classpath*:graphql/**/Schema Resources)。

Act 3 — Mechanics:Angular + Apollo 经 spring-graphql 连接 Schema、Resolver 与 Service


注解控制器:按字段映射,而非按 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 塞满跨聚合副作用而不下沉领域服务。

near-prezentation:public class DiscJockeyConsoleGraphQLController 与 O2-rest-vs-graphql 文档并列

DiscJockeyConsoleGraphQLController:@MutationMapping 与 crowdCheered 入口


字段级 @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(本场未演示防护)。

10-mechanics:@SchemaMapping 与 DiscJockeyConsoleGraphQLController 同屏


Mutation、领域服务与订阅广播
#

为什么:观众点击「CROWD CHEERED」一类交互既要持久化领域状态,又要让已订阅的 DJ 屏/看板收到更新。GraphQL mutation 宜作薄编排层,状态变更与事务边界放在领域服务内。

机制与约束@MutationMapping 方法调用 MixSessionServiceImpl 等 Spring bean;@Transactionalrepository.save 属演示领域模型(未能从官方文档核实类名)。保存后通过 MixSessionUpdatePublisher.publish 向 Reactor 流发射事件,供 @SubscriptionMapping 消费。Spring 文档写明 subscription 响应在 GraphQL Java 侧为 Reactive Streams PublisherTransports)。

怎么做

@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,订阅端永远收不到事件。

MixSessionServiceImpl:repository.findById 与 MixSessionNotFoundException

MixSessionUpdatePublisher:Sinks.many().multicast().onBackpressureBuffer()


Subscription:SinksFlux 与 WebSocket
#

为什么:替代对 currentMixSession 的轮询,在混音会话或投票 tally 变化时主动推送。

机制与约束

  • @SubscriptionMapping 可返回 Flux<T>Controllers)。
  • 演示用 Sinks.many().multicast().onBackpressureBuffer()tryEmitNextReactor Sinks API)在进程内广播;多实例、粘性会话、鉴权 不在 Spring GraphQL 文档范围内,属运维专题。
  • 按 session id 过滤:sink.asFlux().filter(...)。讲者提到可用 Flux.concat 先推当前快照再跟更新流(演示技巧,非框架必选)。

Mermaid diagram 2

怎么做

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 集群更便宜——这属于工程权衡,而非规范裁决。

@SubscriptionMapping 与 DiscJockeyConsoleGraphQLController 测试目录


错误模型:HTTP 2xx 与 JSON errors
#

为什么:查询不存在的 session 时,前端(Apollo)需要稳定解析业务错误,而不是只判断 HTTP 4xx。

机制与约束

怎么做

@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 版本为准,未在本文环境复现的部署不应外推。

DiscJockeyConsoleGraphQLControllerTests 与 GraphQLExceptionResolver 并列


测试与分层:GraphQlTester 与 ArchUnit
#

为什么:在不启动完整 HTTP/WebSocket 的情况下断言字段路径;并防止 GraphQL 类型渗入领域包。

机制与约束

  • @GraphQlTestspring-boot-graphql-test)切片测试控制器;GraphQlTesterdocumentName 从 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 掉。

DiscJockeyConsoleGraphQLControllerTests 与 shouldGetcurrenthixsession


前端:微前端、Apollo 与一次性投票流
#

为什么:观众扫码打开远程组件 CrowdVotePageComponent,需从 query param 读取 slot,先查当前 session 再发起投票 mutation;缓存中的陈旧 session 会导致投错场次。

机制与约束Apollo Client fetchPolicy: 'no-cache' 表示始终走网络且不写入 Apollo 缓存(与 network-only 相近但忽略外部 cache 更新)。路由 app-crowd-vote-page、mutation CAST_CROWD_VOTEexhaustMap / 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 数组。

app-crowd-vote-page 路由与 CrowdVotePageComponent 模板路径

CrowdVotePageComponent:fetchPolicy no-cache 与 CAST_CROWD_VOTE mutation


上线前:订阅加固、分页与安全缺口
#

为什么:演示证明「能跑通」≠「能在生产存活」。讲者清单(主体为演讲者观点,部分与文档精神一致)包括:需要 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 提供 ScrollSubrangeWindow<T> 等(见参考文档),演讲未写端到端示例。

Before You Ship:Subscription Hardening 与 Pagination Discipline 对照生产清单

现场 Disc Jockey Console:CROWD CHEERED 与 SETLIST 驱动 mutation 与查询设计


收束
#

GraphQL 在 Spring 上的落地,核心是把 schema 合同按字段的 DataFetcherHTTP 与 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 内容协商章节。


参考与延伸阅读
#

相关文章