资讯动态

基于Spring Cloud Gateway的类GraphQL请求聚合方案

发布时间:2026/10/19 2:30:25 来源:尧图企业网站定制
最开始其实是联调时被前端同事问了一句你们这个详情页接口为什么客户端要拼三次请求我掰着手指列完清单自己也愣了一下——查用户信息、查订单列表、再按订单里的商品逐条查商品详情一次页面渲染要经历五轮网络往返。就是从那次开始我留意到很多团队都卡在这个问题上于是后面花了几天时间在网关层做了一个轻量的请求聚合能力用 Spring Cloud Gateway 拦截一次调用合并多个微服务的响应再返回也就是标题里说的 GraphQL-liked 方案。这个方案适合谁适合微服务还没拆干净、也不想为合并接口这个需求单独引入一整套 GraphQL 服务器和 Resolver 框架的团队。它的核心思路是在网关上定义一份 JSON Schema网关照着这份 Schema 去并发拉取各服务数据最后组装成一个统一响应。今天这篇就从头到尾讲清楚为什么放在网关层做怎么设计聚合契约怎么写核心过滤器以及在真实项目里会踩到哪些坑。1. 核心思路为什么请求聚合要放在网关层1.1 客户端发起的每一次请求都值得怀疑我先说个观察很多团队的前端调用链是这个样子的——先调 A 服务拿到用户基础信息再用返回里的 userId 调 B 服务拿订单列表订单列表里每个商品 id 又要去 C 服务查名称、价格和库存。听起来每个接口都挺合理但放到用户真实网络环境里五连请求就意味着至少五倍网络往返耗时、五倍失败概率还有五个服务各自为政的响应结构。传统的解决办法是在业务侧额外提供一个聚合接口让某个服务内部去调别的服务。但这种做法有几个老问题聚合逻辑散落在各个业务服务里职责不清聚合接口一多每个服务都成了半个 BFF后期维护非常痛苦。既然所有请求都必经网关那为什么不把拼装数据的活集中到网关这一层来做1.2 为什么不直接上完整的 GraphQL 服务我在方案评审时也被问过为什么不直接用 GraphQL。当时团队里确实有人提议部署一个 graphql-java 服务把所有下游接口包一层 DataFetcher。老实说如果你们团队已经熟悉 GraphQL、已经有现成的 Schema 治理工具链那直接上确实是正路。但对大多数只是想合并响应的业务团队来说为这一个需求引入完整 GraphQL 技术栈代价偏高要维护一套 GraphQL Schema要把每个下游调用包装成 Resolver要考虑 N1 查询问题还得让前端从 REST 风格切到查询语言风格。我们要的其实只是 GraphQL 的一个子集能力声明式描述我需要哪几个数据然后由网关统一去取、去拼。所以不妨做一个极简的 JSON 版聚合契约只需要 URL、方法、参数依赖关系这三个核心要素就够了。这样既不用前端学新东西也不用在服务端引入重的框架网关里加一个过滤器即可落地。1.3 方案的功能边界必须一开始就定清楚做这种网关统一聚合时最容易犯的错误是希望聚合层什么都干。我建议一开始就划清边界聚合层只做取数和组装不做复杂筛选、分页计算、业务状态流转权限校验仍由各微服务自行完成网关注重透传认证信息聚合层不缓存业务数据除非你明确知道热点在哪它只是个编排器。边界定了后续实现才不会跑偏。网关聚合的本质是批处理 编排把多次网络请求合并成一次而不是把业务逻辑上移。想清楚这点后面的设计就顺了。2. 类 GraphQL 聚合的 Schema 设计与原理2.1 一个只有三要素的聚合契约既然不打算引入完整 GraphQL我参考了 GraphQL 里按需取字段、声明式依赖的思想设计了一个极简 JSON Schema。整个契约只围绕三个概念字段名field、下游请求描述url/method/headers、变量替换depends。下面是一份最简样例{ fields: { userInfo: { url: http://user-service/api/users/{userId}, method: GET }, userOrders: { url: http://order-service/api/users/{userInfo.id}/orders, method: GET }, orderDetails: { url: http://product-service/api/products/batch, method: POST, body: { productIds: {#orderList.items[*].productId} } } }, response: { user: $userInfo.data, orders: $userOrders.data.list, products: $orderDetails.data } }我来拆一下这套设计userInfo这一步不需要依赖任何前置字段直接用入参{userId}替换路径userOrders的 URL 里出现了{userInfo.id}意思是从userInfo这一步返回结果里取data.id作为路径参数orderDetails的请求体是一个数组参数用{#orderList.items[*].productId}这种类 JSONPath 表达式把上一步订单列表中的多个商品 id 聚合到一个 POST 请求里。前端调用方不用关心这些字段是怎么来的网关最终会返回一份统一 JSON字段名和契约里的response段严格对应。这个设计有个好处对前端而言它像是在请求一个虚拟的胖接口对后端团队而言每新增一个聚合视图只是往 JSON 里加一段描述不用动 Java 代码。2.2 多阶段拉取处理字段之间的依赖关系GraphQL 里 Resolver 可以嵌套调取我的实现思路是把它拆成多阶段顺序执行。每一阶段可以并行拉取多个互不依赖的字段等这一阶段全部完成后再把结果注入到下一阶段的 URL 或 Body 模板里。这样做有几个现实理由。第一现实中的依赖往往是一层层展开的——先拿用户 id才能查订单列表拿到订单里的商品 id才能查商品信息。强依赖只能顺序执行硬要并行只会把代码复杂化。第二把阶段拆开之后每层请求的失败范围可以被单独控制用户信息查不到后续订单步骤直接跳过返回一个明确的错误片段订单列表查不到商品信息自然没必要再查。所以我实现的执行引擎本质上是一个带依赖检查的 DAG 执行器遍历 Schema 中所有字段先执行无依赖的字段每个字段完成后将结果写入一个共享变量上下文反复遍历直到所有字段都执行完毕或者没有新的可执行字段为止。实际工程里我会优先用拓扑排序来编排而不是每次全量扫描。但如果字段数量不多于二十个全量扫描式的多轮遍历也不会有什么性能问题。2.3 响应式并行是聚合场景的自然选择Spring Cloud Gateway 基于 Spring WebFlux整个请求处理模型是事件驱动的。这意味着在网关里发起下游 HTTP 调用时线程不会被阻塞而是通过异步回调接收结果。这个特性对聚合场景来说是天然利好。想象一下如果网关是传统的 Tomcat 线程模型每来一个聚合请求就要占用一个工作线程在这个线程里再同步等待下游多个服务返回那网关很容易成为线程耗尽的重灾区。WebFlux 模型下一次聚合请求的多个下游调用虽然同时发起但等待期间不占用线程IO 完成后指定回调继续执行即可。这也是我坚持在网关上用 WebClient 而非 RestTemplate 发起下游请求的原因——两者虽然都能发起 HTTP 调用但 RestTemplate 是阻塞式的在网关这种高并发、多路聚合的场景里不合适。3. 基于 Spring Cloud Gateway 的核心实现3.1 关键技术点拦截指定路径并缓存请求体Spring Cloud Gateway 里做拦截有两种常用姿势GlobalFilter和按路由配置的GatewayFilterFactory。我选用的是GlobalFilter因为聚合能力是横切关注点不应该绑定在某一条业务路由上同时我加了路径判断只有访问/aggregate/**的请求才进入聚合逻辑。拦下来之后最先遇到的问题是怎么读取请求体。在 WebFlux 里ServerHttpRequest.getBody()返回的是一个FluxDataBuffer而且流只能读一次一旦在过滤器里消费掉了后面转发到下游时请求体就空了。解决思路是做一个请求装饰器把读出来的请求体缓存到底层内存里之后无论谁来读getBody()都返回缓存后的数据。我在代码里大致是这样处理的public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); if (!request.getPath().value().startsWith(/aggregate/)) { return chain.filter(exchange); } return DataBufferUtils.join(request.getBody()) .flatMap(dataBuffer - { byte[] bytes new byte[dataBuffer.readableByteCount()]; dataBuffer.read(bytes); DataBufferUtils.release(dataBuffer); String body new String(bytes, StandardCharsets.UTF_8); // 缓存到请求装饰器 ServerHttpRequest cachedRequest new ServerHttpRequestDecorator(request) { Override public FluxDataBuffer getBody() { return Flux.just(StandardCharsets.UTF_8.encode(body)); } }; ServerWebExchange cachedExchange exchange.mutate().request(cachedRequest).build(); return doAggregate(cachedExchange, body); }); }这里要注意DataBufferUtils.release不能漏不然会出现内存泄漏告警。缓存请求体这种写法本质是把流式请求体转换成可重复读取的字节数组这也是过滤器里做任何 body 解析的通用前置操作。3.2 聚合执行引擎解析 Schema 并请求下游聚合核心引擎我抽象成一个独立类不直接写在过滤器里方便单测。引擎入口接收 Schema JSON 字符串和原始请求参数输出一个AggregateResult。首先解析 Schema把每个字段定义转成内部模型public static class FieldDefine { String name; String url; HttpMethod method; MapString, Object headers; MapString, Object bodyTemplate; ListString dependencies; // 从 URL/Body 模板里解析出的依赖字段 }然后开始多阶段执行。每个字段的拉取逻辑统一走 WebClientprivate MonoFieldResult fetchOne(FieldDefine field, MapString, Object ctx) { String resolvedUrl TemplateResolver.resolve(field.url, ctx); if (resolvedUrl null) { return Mono.just(FieldResult.skipped(field.name, missing dependency)); } return webClient.method(field.method) .uri(resolvedUrl) .headers(h - { // 这里会把网关收到的 Header 透传给下游见 5.3 }) .bodyValue(TemplateResolver.resolveBody(field.bodyTemplate, ctx)) .retrieve() .bodyToMono(JsonNode.class) .map(node - FieldResult.success(field.name, node)) .timeout(Duration.ofSeconds(3)) .onErrorResume(e - Mono.just(FieldResult.failed(field.name, e.getMessage()))); }每一个字段请求完成之后引擎会把结果写入上下文ctx下一个依赖它的字段就能从上下文里取到数据。3.3 对响应合并的 JSON 做统一建模多个下游服务的响应结构五花八门聚合层必须在返回给客户端前做一次统一建模。我的习惯是永远返回一个信封结构不管中间某个服务失败与否{ code: 0, data: { userInfo: { id: 1, name: 张三 }, userOrders: [ { id: A001, productId: P100 } ] }, errors: [] }如果某个字段失败了比如userOrders超时我会把code置为非 0在errors数组里写清楚哪个字段失败、失败原因同时data里能返回的部分照常返回。这么设计的好处是前端拿到响应后可以部分渲染不至于整页空白而且排查问题时errors 里的字段名能精确定位是哪一步下游调用出了问题。关于response段的字段映射我实现了一个简单的路径解释器支持$userInfo.data.id这种取数语法。本质上就是把某个字段的返回结果按 JSONPath 取出一部分映射到最终响应的指定位置。这个能力让聚合结果只暴露前端真正需要的字段而不是把所有下游数据原样铺开。3.4 注册过滤器与路由配置过滤器注册成功后需要把它挂到网关上。我在application.yml里固定配置一条聚合路由spring: cloud: gateway: routes: - id: aggregate_route uri: no://op predicates: - Path/aggregate/query由于实际处理逻辑都在过滤器里uri并不会真正转发到某个具体地址。设置成no://op只是为了让路由断言生效进入过滤器后引擎会按照 Schema 里的 URL 去请求真实微服务。如果你希望聚合路径更灵活也可以直接在过滤器中判断任意路径前缀不一定非要配一条假路由。但把路由显式声明出来团队其他人看配置就能一眼知道网关存在这样一个聚合入口运维侧也好排查链路。4. 实操验证订单详情页的一次聚合4.1 用一个具体场景把概念串起来为了讲清楚实际使用方法我模拟了一个电商场景总共三个微服务用户服务GET /api/users/{id}返回用户昵称、头像订单服务GET /api/users/{userId}/orders返回该用户的订单列表订单中含商品 id商品服务POST /api/products/batch接收商品 id 数组返回商品名称、价格、库存。传统做法下前端要加载一个订单列表页先取用户信息、再取订单列表、再按订单里的商品 id 逐个查商品。如果某个订单里五个商品前端甚至要发七次请求。用这套聚合方案前端只需要向网关发一次请求剩下的拼装全部交给网关完成。4.2 手写这次聚合的 Schema下面是针对该场景我实际用于验证的 Schema{ fields: { userInfo: { url: http://user-service/api/users/1001, method: GET }, userOrders: { url: http://order-service/api/users/{userInfo.id}/orders, method: GET }, productBatch: { url: http://product-service/api/products/batch, method: POST, body: { productIds: {#userOrders.list[*].productId} } } }, response: { user: $userInfo.data, orders: $userOrders.data.list, products: $productBatch.data } }这里关键的是{#userOrders.list[*].productId}这个表达式它表示把userOrders返回结果里list数组每一项的productId字段取出来构成一个数组作为 POST body 里productIds字段的值。这样多个商品 id 就能一次性批量查询而不是循环发 N 次商品查询效果上很接近 GraphQL 里 DataLoader 的批处理能力。4.3 运行结果回放与耗时对比我本地用 WireMock 模拟了这三个服务对比同一场景客户端分别调用和网关一次聚合的差异方式请求次数串行耗时模拟网络延迟 200ms/次并发耗时客户端逐调用7 次约 1400ms约 1400ms网关聚合方案1 次约 600ms约 350ms注意这里有个细节三步请求中userInfo是前置依赖必须先拿到 id但userOrders和后续的productBatch是有依赖链的无法并行。最终并发耗时取决于最长的依赖链而不是所有字段之和。实际上我对比后发现只要把无依赖的字段放到同一阶段并发执行整体延迟就能比串行少一半以上。实际调用时请求体里只需要带这份 Schema。网关聚合返回的 JSON 就是前端真正需要的数据结构里面没有任何多余的下游字段。前端一次拿到user、orders、products直接渲染即可。5. 实战中容易踩的坑与排查记录5.1 请求体只能读一次的经典问题这个问题我在 3.1 里带了一句但值得单独拿出来强调。ServerHttpRequest.getBody()是冷流只能订阅一次。有些同事第一次写过滤器时会在过滤器里读 body 做校验然后又转发请求结果下游收到的 body 是空的排查半天找不到原因。避免这个问题的标准做法就是请求装饰器 缓存字节数组。我额外加了一条经验如果聚合 Schema 体积很大可以考虑限制请求体大小避免网关内存被打爆。一般限制在 1MB 以内足够用了正常的聚合 Schema 也就几百字节到几 KB。5.2 某个下游服务不可用时的容错策略聚合请求依赖多个下游服务任何一个服务抖动都会影响整个聚合接口。我见过最糟糕的情况是商品服务卡了 10 秒客户端整个聚合请求也跟着挂 10 秒。后来我在 WebClient 调用上显式加了超时并且按字段定义了降级策略。降级策略分几种核心字段失败直接让整个聚合请求返回错误避免前端拿到残缺数据做误判非核心字段失败返回 null 并在 errors 里记录前端可以靠兜底逻辑渲染允许配置重试次数但重试只对 GET 请求开启POST 请求重试要谨慎防止重复下单或重复扣款。具体选哪种要看业务对字段的依赖程度。我建议在产品需求阶段就明确每个字段的失败容忍度不要等到线上故障了才拍脑袋。5.3 认证信息在聚合请求中的透传网关通常承担认证职责校验通过后会往后端传递用户身份信息。在聚合场景里这个传递容易被忽略子请求虽然是网关内部发起的但如果下游服务依赖用户身份做数据权限隔离子请求就必须带上原始 Header。我的实现是从当前ServerWebExchange里提取认证相关 Header然后放到 WebClient 请求里String authHeader exchange.getRequest().getHeaders().getFirst(Authorization); webClient.method(field.method) .uri(resolvedUrl) .headers(h - { if (authHeader ! null) { h.setBearerAuth(authHeader.replace(Bearer , )); } // 还可以透传 traceId、appVersion 等 })有件事我吃过亏有些团队习惯在网关里把 Header 里的用户 id 解析出来用请求参数往下游传。但在聚合场景里多个下游服务同时需要用户身份建议统一从 Header 透传而不是从聚合请求体里取。因为请求体是前端可控的前端传入的 userId 不一定可信而网关从 Token 里解析出的身份是认证过的用它做数据权限才是安全的。5.4 聚合响应缓存与性能兜底网关聚合虽然把客户端请求次数降到了 1 次但网关和后端之间的调用次数并没有减少反而可能在高峰期放大。所以必须考虑热点数据的缓存。我做的比较简单对某些更新时间不敏感、且调用量很大的字段比如商品基础信息在聚合引擎里加了一层 Caffeine 本地缓存缓存 30 秒。这样即使一百个订单都查同一个商品网关也只会真的访问一次商品服务其余请求直接命中缓存。缓存要注意维度不要直接在聚合引擎里缓存整个响应。正确姿势是缓存下游单个字段的响应比如CacheString, JsonNode cache Caffeine.newBuilder() .expireAfterWrite(Duration.ofSeconds(30)) .maximumSize(10000) .build(); private MonoJsonNode fetchWithCache(String url) { JsonNode cached cache.getIfPresent(url); if (cached ! null) { return Mono.just(cached); } return webClient.get().uri(url).retrieve().bodyToMono(JsonNode.class) .cache() .doOnNext(node - cache.put(url, node)); }但缓存的 key 必须包含完整 URL含查询参数否则会出现数据串号问题。另外登录用户相关的接口不要加缓存或者至少把 userId 拼进 key否则就是严重的数据泄漏事故。还有个兜底手段是给整个聚合请求设置一个总超时比如 5 秒。即使某个字段的单个超时没拦住总量也能拦住。这个总超时我会通过配置暴露出去线上调优时不需要重新发布代码。最后再分享一个小技巧在做字段间数据合并时不要只盯着 JSONPath 表达式可以把每个字段的响应写入上下文这个动作做成可观测的——在返回结果里加一个_debug字段打印每个阶段的解析和替换结果。线上遇到拼装出来的 URL 不对时这个_debug字段能省下大量排查时间。我在项目里也是踩了几次坑之后才养成这个习惯现在团队内部排查聚合问题基本都在五分钟以内。这套方案用到现在最深刻的体会是聚合层的气质一定要简洁它只是一个帮前端少跑几趟路的组装车间而不是业务逻辑的第二现场。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑