楼层: 首页/ 软件技术/ Spring Cloud 微服务/ 契约测试与 GraphQL 网关:让上下游"改接口不炸"
十五

契约测试与 GraphQL 网关:让上下游"改接口不炸"

Contract Testing & GraphQL Gateway · Spring Cloud Contract / Spring GraphQL

服务拆开之后,有两个问题会反复出现,而且都很痛。第一个是"接口改动炸下游":订单服务改了个字段名,库存服务上线才发现调用失败,只能紧急回滚。第二个是"前端要的数据散在五个接口里":移动端为了渲染一个页面调了七次接口,弱网下体验极差。这一章讲两个应对工具:契约测试(Spring Cloud Contract)解决前者,GraphQL 聚合层(Spring GraphQL)解决后者。注意,它们解决的都不是"代码怎么写",而是"团队之间怎么协作"。

契约测试:把"我们约定好了"变成可执行的断言

先说清楚传统集成测试为什么在微服务里不灵。单体时代,一次端到端测试就能覆盖所有模块。拆成微服务后,要跑一次完整的集成测试,你得把订单、库存、账户、网关、注册中心、数据库全部拉起来——慢、脆、环境依赖重,CI 上跑一次要几十分钟,还经常因为环境问题误报。于是团队干脆不跑了,改成"靠文档和口头沟通",然后就开始出事故。

论消费者驱动契约(CDC)的核心思想

① 问题的根源:接口有两方,改动的风险是双向的。生产者改了字段,消费者不知道;消费者提了新需求,生产者也不知道。传统做法是"写文档",但文档不会自动过期检测,代码一改文档就撒谎。

② CDC 的翻转:让消费者来写"我希望接口返回什么",这份期望就是契约。生产者必须保证自己的实现满足这份契约,并且这份契约会被自动验证。

③ 三个角色各做什么:消费者写契约文件(声明请求与期望响应)→ 生产者拿契约自动生成测试用例并运行,跑通说明"我符合契约"→ 生产者同时把契约打包成 stub jar,消费者用它在本地启动一个"假的生产者",验证自己解析没问题。

本质:把"接口约定"从一份会过期的文档,变成两边 CI 都会红灯的自动化测试。

对比项端到端集成测试契约测试
依赖必须拉起全部真实服务和数据库生产者、消费者各自独立跑,用 stub 替代对方
速度分钟级到十分钟级秒级,能在每次提交时跑
失败定位难,一个环境问题可能伪装成业务失败精准,直接指出"契约第 3 条不满足"
覆盖的失败类型字段类型、网络、时序、数据状态都能覆盖只覆盖接口约定(字段名/类型/状态码),不覆盖业务逻辑
定位发布前的最后一道验收开发阶段的高频快速反馈,替代绝大部分"为了验证接口"的集成测试

请注意最后一行:契约测试不是要取代集成测试,而是把集成测试里"验证接口约定"的那部分,用更低成本的方式提前到每次提交。真正的端到端验收还是要有,但可以少很多、跑得慢一点也没关系。

生产者侧:写契约 + 自动生成测试

契约文件放在生产者工程(也可以放在单独的契约仓库)的 src/test/resources/contracts/ 下。Spring Cloud Contract 会在构建时读取这些文件,自动生成测试代码并执行——注意是"自动生成测试",你不需要自己写测试方法。

用 Groovy DSL 写一份契约:消费者说"我调这个接口,期望这样的响应"

// src/test/resources/contracts/user/shouldReturnUserById.groovy // 这份文件的"身份"是消费者的期望,不是生产者的实现说明 Contract.make() { request { method 'GET' url '/user/1001' headers { header('Accept', 'application/json') } } response { status 200 headers { contentType(applicationJson()) } // body 里每个字段都会被自动断言:少一个、类型不对,生产者测试就会红 body( id: 1001, name: '张三', level: 'VIP', points: 1200 ) } }

改成 YAML 写也一样,团队更熟悉哪种就用哪种

# src/test/resources/contracts/user/shouldReturnUserById.yml request: method: GET url: /user/1001 response: status: 200 headers: Content-Type: application/json body: id: 1001 name: "张三" level: "VIP" points: 1200 # matchers 用来放宽非关键字段:不用把具体值写死 matchers: body: - path: $.name type: by_regex value: ".+" # 只要求是非空字符串,不关心具体叫什么 - path: $.points type: by_type value: "number"

生产者 pom.xml:加插件,让构建时自动生成并执行测试、顺便产出 stub jar

<plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>4.1.2</version> <extensions>true</extensions> <configuration> <!-- 生成测试的基类:MockMvc 怎么搭在这里定义,生成的测试会继承它 --> <baseClassForTests>com.example.BaseContractTest</baseClassForTests> <!-- stub jar 的坐标后缀,消费者靠它引入 --> <classifier>stubs</classifier> </configuration> </plugin> <!-- 基类:让生成出来的测试知道"怎么调我的 Controller" --> @SpringBootTest public abstract class BaseContractTest { @Autowired protected WebApplicationContext context; @BeforeEach public void setup() { RestAssuredMockMvc.webAppContextSetup(context); // 契约里写的 id=1001,这里把假数据准备好,让真实 Controller 能返回它 } }
契约是"消费者说了算",别写成生产者的自白

最常见的误用是:生产者自己把当前返回值原样抄进契约文件。这样一来契约永远绿的,毫无意义——它变成了"我自己给自己出的考卷"。正确的信号是:契约应该由消费者提交,并且消费者一旦引入新字段需求,生产者 CI 应该立刻变红。如果你们的契约文件从来只有生产者改,说明流程没跑起来,需要回头检查"契约评审"这个环节。另外,baseClassForTests 的基类是唯一让生成测试知道如何启动你的应用的地方,忘了配它就会生成一堆跑不起来的测试。

消费者侧:用 stub 在本地跑起来

生产者在 CI 上把契约变成 stub jar 发布到私服后,消费者就可以用它做"接收测试"了:临时启动一个假的生产者(stub),让它按照契约返回响应,然后验证自己的代码能不能正确解析。整个过程不需要真服务、不需要数据库。

消费者侧接入:一条注解把 stub 拉起来

<!-- 消费者 pom.xml:引入 stub jar,classifier 要和生产者配置的一致 --> <dependency> <groupId>com.example</groupId> <artifactId>user-service</artifactId> <version>1.0.0</version> <classifier>stubs</classifier> <scope>test</scope> </dependency> // 消费者的接收测试:stub 会自动在随机端口上启动,注入到 Feign 客户端 @SpringBootTest @AutoConfigureStubRunner( ids = "com.example:user-service:+:stubs", // + 表示取最新版本 stubsMode = StubRunnerProperties.StubsMode.LOCAL // 本地仓库;CI 上用 REMOTE 走私服 ) class UserClientContractTest { @Autowired private UserClient userClient; // 被 @FeignClient 标注的接口 @Test void 应该能正确解析用户信息() { UserDTO user = userClient.getUserById(1001L); // 断言的是"我这边解析对不对",不是"生产者实现对不对" assertThat(user.getName()).isNotBlank(); assertThat(user.getLevel()).isEqualTo("VIP"); assertThat(user.getPoints()).isGreaterThan(0); } } // 更狠的一种用法:让 Feign 指向 stub 的随机端口,验证真正的 HTTP 编解码 // @AutoConfigureStubRunner 会自动完成这件事——它会把 stub 端口替换掉你的服务地址

论契约测试怎么在 CI 里"挡住回归"

① 生产者流水线:跑 mvn verify 时,spring-cloud-contract-maven-plugin 会根据契约文件生成测试并执行。如果生产者改了字段名,而契约还要求旧字段,这一步直接失败,代码合不进去。同时它会把 stub jar 推到私服。

② 消费者流水线:跑测试时会通过 @AutoConfigureStubRunner 拉取最新 stub 并验证解析逻辑。如果生产者按新契约发布了 stub,而消费者的解析代码没跟上,消费者这边先红。

③ 效果:接口不兼容的问题在两个仓库各自的 CI 上就被拦住,而不是等到联调或上线。这就是契约测试的全部价值——把"上线才发现"变成"提交就发现"。

对比项Spring Cloud ContractPact
契约语言Groovy DSL 或 YAML,由消费者或生产者维护契约由消费者测试运行时自动生成 JSON,更贴"消费者驱动"
生成测试会为生产者自动生成测试代码,生产者需要配基类生产者侧通过 Pact 的 Provider 验证机制跑契约
生态与集成和 Spring 生态集成最顺,@AutoConfigureStubRunner 开箱即用语言中立,支持多语言(Java/JS/Go/Python),有 Pact Broker 做契约版本管理
适用纯 Java / Spring 技术栈,希望配置少多语言混合栈,或者需要契约的版本兼容矩阵管理(Pact Broker 能显示"谁能安全部署")

GraphQL 作为聚合层:把五个接口变成一个图

换一个完全不同的问题。移动端要渲染"商品详情页",需要商品信息、库存、评价、推荐、用户是否收藏——在 REST 世界里这可能是五次请求。弱网下每个请求 200ms,串起来就一秒多。你说"后端加个 BFF 聚合接口"?下个页面需求变了,又得加一个。GraphQL 的答案是:让客户端自己描述要什么字段、要什么关联,服务端一次返回。

论Schema、Query、Resolver 三个概念一次讲清

① Schema(Schema Definition Language):就是"接口契约",用 SDL 描述有哪些类型、能查什么。它是强类型的,客户端能通过 introspection 查到全部结构,前端不用等后端写文档。

② Query / Mutation:客户端发来的请求不是 URL,而是一段"查询文档",形如 { product(id: 1) { name price } }。它精确指定要哪些字段,服务端就只查这些字段,不会多给。

③ Resolver(解析器):每个字段都要有一个函数回答"这个字段的值从哪来"。Product.name 可能来自数据库,Product.reviews 可能来自评价服务。GraphQL 之所以适合做聚合层,就是因为它把"取数据"下沉到了每个字段的 resolver 里——下游是 REST 还是 RPC 还是数据库,schema 层面完全无感。

定义 Schema:类型 + 查询入口

# src/main/resources/graphql/schema.graphqls type Product { id: ID! name: String! price: BigDecimal! stock: Int! # 来自库存服务 reviews: [Review!]! # 来自评价服务,容易被 N+1 拖死 isFavorite: Boolean! # 来自用户服务,依赖当前用户上下文 } type Review { id: ID! content: String! score: Int! authorName: String! } # 查询入口:客户端从这里开始"描述"自己需要的数据 type Query { product(id: ID!): Product products(keyword: String, page: Int = 0, size: Int = 20): [Product!]! } # 写操作单独定义,语义上区分"读"与"写" type Mutation { addFavorite(productId: ID!): Boolean! }

Spring GraphQL 的 Resolver 写法:注解即可,不用实现接口

import org.springframework.graphql.data.method.annotation.*; import org.springframework.stereotype.Controller; import java.util.concurrent.CompletableFuture; @Controller public class ProductGraphqlController { private final ProductClient productClient; // Feign 调商品服务 private final StockClient stockClient; // Feign 调库存服务 public ProductGraphqlController(ProductClient p, StockClient s) { this.productClient = p; this.stockClient = s; } // @QueryMapping 对应 schema 里的 Query.product @QueryMapping public CompletableFuture<ProductDTO> product(@Argument Long id) { // 返回 CompletableFuture 就是非阻塞,下游调用不会占住请求线程 return productClient.getByIdAsync(id); } // @SchemaMapping 给 Product.stock 这个字段提供数据来源 // source 就是上一级 resolver 返回的 ProductDTO @SchemaMapping(typeName = "Product", field = "stock") public CompletableFuture<Integer> stock(ProductDTO source) { return stockClient.getStockAsync(source.getId()); } // 字段级鉴权:每个字段的 resolver 里都能拿到上下文 @SchemaMapping(typeName = "Product", field = "isFavorite") public boolean isFavorite(ProductDTO source, @ContextValue Long currentUserId) { return favoriteService.exists(currentUserId, source.getId()); } // @MutationMapping 对应 schema 里的 Mutation.addFavorite @MutationMapping public boolean addFavorite(@Argument Long productId, @ContextValue Long currentUserId) { favoriteService.add(currentUserId, productId); return true; } } # application.yml 开启 GraphQL 端点 spring: graphql: graphqls: locations: classpath:graphql/**/ # schema 文件位置 path: /graphql # 默认就是这个路径,POST 请求 graphiql: enabled: true # 开发环境开启可视化调试台

N+1 问题与 DataLoader:GraphQL 最大的性能陷阱

这是 GraphQL 新手最容易踩、也最容易被面试问到的问题。它很隐蔽:你在本地用小数据量测试时完全正常,一上生产就慢十倍。

论N+1 到底是怎么发生的

① 场景:客户端查询"20 个商品,每个商品的评价列表"。schema 里 products 返回 20 个 Product,每个 Product 都要解析 reviews 字段。

② 默认行为:GraphQL 引擎会逐字段调用 resolver。于是先查 1 次商品列表(1 次查询),然后对 20 个商品各调用 1 次评价服务(20 次查询)。总共 1 + 20 = 21 次下游调用。这就是 N+1。

③ 为什么危险:N 等于列表长度,而列表长度由客户端决定。如果客户端一次要 100 个商品、每个商品还要 authorName(三级嵌套),调用次数会爆炸式增长,直接把下游打挂。而且这个成本是外部可控的,本质是一个放大攻击面。

④ DataLoader 的解法:它不是逐条查,而是把同一批次的请求攒起来,合并成一次批量查询再分发结果。核心机制是"批处理 + 缓存",在一个请求的同一个执行周期内生效。一句话:把 21 次调用压成 2 次。

DataLoader 完整实现:注册成 batch loader,在字段 resolver 里用它

import org.dataloader.DataLoader; import org.dataloader.DataLoaderRegistry; import org.springframework.graphql.execution.BatchLoaderRegistry; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; // ---------- 1. 注册 batch loader:一次拿一批 id,一次调下游 ---------- @Component public class ReviewBatchLoader { public ReviewBatchLoader(BatchLoaderRegistry registry, ReviewClient reviewClient) { registry.forType("Product.reviews", Long.class) .registerMappedBatchLoader((productIds, env) -> { // 关键:这里只被调用一次,productIds 是一整批(比如 20 个) Map<Long, List<ReviewDTO>> grouped = reviewClient .getByProductIds(new ArrayList<>(productIds)) // 一次批量查询 .stream() .collect(Collectors.groupingBy(ReviewDTO::getProductId)); // 每个 id 都必须有返回值,没有则返回空列表,否则 GraphQL 会报错 Map<Long, List<ReviewDTO>> result = new HashMap<>(); productIds.forEach(id -> result.put(id, grouped.getOrDefault(id, List.of()))); return Mono.just(result); }); } } // ---------- 2. 字段 resolver 里用 DataLoader 取数 ---------- @SchemaMapping(typeName = "Product", field = "reviews") public CompletableFuture<List<ReviewDTO>> reviews( ProductDTO source, DataLoader<Long, List<ReviewDTO>> dataLoader) { // Spring 会自动注入 // 这里看起来像"每个商品查一次",实际底层被合并成一次批量调用 return dataLoader.load(source.getId()); } // ---------- 3. 手动开 GraphQL 的批处理(WebFlux 下尤其要注意)---------- # 一次批量查询替代 20 次单查,效果量化如下 # 优化前:1 次商品列表 + 20 次单个商品评价查询 = 21 次下游调用,P99 约 900ms # 优化后:1 次商品列表 + 1 次批量评价查询 = 2 次下游调用,P99 约 120ms
DataLoader 的三个使用要点

要点一:它只在"同一个请求的同一个执行周期"内做批处理。不要把 DataLoader 缓存跨请求复用——那会把 A 用户的评价数据带给了 B 用户,是数据泄漏级的事故。Spring GraphQL 默认按请求创建,不要自己做成单例。

要点二:每个 key 都必须有返回值。批量查询返回的 Map 里如果某个 id 缺失,DataLoader 会抛异常而不是给 null。所以要用 getOrDefault(id, emptyList) 补全,或者显式 put 一个 null。

要点三:不是所有字段都值得上 DataLoader。如果某个字段只会被查一次(比如 Product.detail),用 DataLoader 只是徒增复杂度。判断标准:这个字段是不是在列表里被反复解析。

GraphQL 与 REST 网关的分工

学到这里你可能会想"那是不是该把所有 REST 接口都改成 GraphQL"。不是。GraphQL 是一种取数风格,它有自己的代价,把它放在合适的位置才能发挥价值。

场景推荐原因
面向多端(Web/iOS/Android/小程序)的聚合层GraphQL各端需要的字段不同,GraphQL 让各端自己决定要什么,后端不用为每个端写一个 BFF 接口。
服务与服务之间的内部调用REST / RPC服务间调用是"点对点固定需求",用 GraphQL 反而引入额外解析成本,且不利于缓存与限流。
强缓存需求的公开 APIRESTREST 靠 URL + HTTP 缓存头就能被 CDN 缓存;GraphQL 通常是 POST,HTTP 层缓存基本失效,需要额外做持久化查询(persisted query)。
文件上传下载、流式接口RESTGraphQL 规范对二进制和流式支持不友好,别硬塞。
客户端字段需求高度动态的页面GraphQL比如后台管理系统、详情页,字段组合变化频繁,GraphQL 能显著减少接口改动次数。

实践中比较稳的架构是分层:最外层用 Gateway 做统一入口(鉴权、限流、路由、灰度),其中面向端的部分挂一个 GraphQL 聚合层,由它把内部若干 REST/RPC 服务聚合成一张图;内部服务之间依然用 REST/RPC。不要试图用一个 GraphQL 网关替代掉全部 REST。

把 GraphQL 挂到 Spring Cloud Gateway 后面,并做深度与复杂度限制

# application.yml(网关侧):GraphQL 端点走统一鉴权与限流 spring: cloud: gateway: routes: - id: graphql-aggregator uri: lb://graphql-gateway predicates: - Path=/graphql filters: - name: RequestRateLimiter # 限流:GraphQL 单请求可能很重,必须限 args: key-resolver: "#{@userKeyResolver}" // 关键防护:限制查询深度与复杂度,防止客户端写出嵌套炸弹 @Configuration public class GraphQlSecurityConfig { @Bean public Instrumentation maxDepthInstrumentation() { // 限制最大嵌套深度:超过 10 层直接拒绝 // 如果不限制,客户端可以写 product { reviews { product { reviews { ... } } } } return new MaxQueryDepthInstrumentation(10); } @Bean public Instrumentation maxComplexityInstrumentation() { // 限制查询复杂度:按字段数量加权计分,超分拒绝 // 列表字段权重给高,因为它会放大下游调用量 return new MaxQueryComplexityInstrumentation(200, (env, field) -> field.getName().equals("reviews") ? 10 : 1); } } # 生产约定:禁用 introspection,并开启持久化查询(Persisted Query) # 客户端先上传查询串换一个 hash,之后只发 hash,服务器只认白名单里的查询 # 好处:① GET + 缓存能工作 ② 防止任意查询攻击 ③ 报文体积大幅缩小 spring: graphql: schema: introspection: enabled: false # 生产关掉,避免把完整 schema 暴露给外部
GraphQL 上生产必做的三件事

第一件:深度与复杂度限制。GraphQL 的"客户端自选字段"是把双刃剑——它同时意味着客户端可以让服务端做任意量级的工作。没有限制的 GraphQL 等于给外部开了一个免费的放大攻击接口:一个 2KB 的查询串就能触发成千上万次下游调用。上生产前必须限制最大深度、最大复杂度、以及单次查询能返回的节点数。

第二件:鉴权要下沉到字段。REST 时代你在接口层面校验"这个用户能不能看这个资源"。GraphQL 下不同的字段属于不同的敏感级别,鉴权必须写在字段 resolver 里(比如 User.phone 只允许本人或管理员看),只在外层做一次"是否登录"的校验是不够的。

第三件:日志与限流要按查询维度。所有请求都是 POST /graphql,日志里看不出是哪段查询导致了慢。要把 operation name 与查询 hash 打进日志和追踪 span 的 tag,限流也要按"查询复杂度"而不是单纯按 QPS。否则你将无法在事故时回答"是哪个页面把后端打挂了"。

记
这一章的核心

① 契约测试把"接口约定"从会过期的文档,变成两端 CI 上的自动断言;它不取代端到端测试,而是替代掉其中"验接口"的部分。

② 契约要由消费者写、生产者验证;生产者的契约如果永远绿,说明流程形式主义了。

③ 纯 Java 用 Spring Cloud Contract 更省事;多语言或需要契约版本兼容矩阵,选 Pact。

④ GraphQL 的价值在"多端聚合层":客户端描述要什么,服务端一次给全,省掉 N 次往返与为每个端写 BFF 的成本。

⑤ N+1 是 GraphQL 的头号性能陷阱,用 DataLoader 批处理解决,但注意它的缓存只能活在单个请求内。

⑥ 上生产必须做三件事:限制深度与复杂度、字段级鉴权、按查询维度打日志和限流。

本章面试题

面试快答 · 契约测试与 GraphQL

Q1. 契约测试和集成测试的区别是什么?它能替代集成测试吗?

参考答案

区别:集成测试需要把真实的生产者、消费者、数据库都拉起来端到端跑;契约测试让两端各自独立跑,用契约生成的 stub 或生成的测试来代替对方,秒级完成、失败定位精准。不能完全替代:契约测试只覆盖"接口约定"(字段名、类型、状态码),覆盖不了业务逻辑、数据状态、时序问题、网络异常。它的定位是把"验证接口有没有对齐"这件事从昂贵的集成测试里剥离出来,提前到每次提交,让集成测试可以少而精。

Q2. Spring Cloud Contract 里,为什么要配 baseClassForTests?

参考答案

因为契约测试的测试代码是插件自动生成的,生成的测试本身不知道"怎么启动你的应用、怎么调用你的 Controller"。baseClassForTests 指定的基类承担了这个职责——通常在里面用 @SpringBootTest 启动上下文,并设置 RestAssuredMockMvc 或 WebTestClient 的上下文,同时把契约里假定的数据准备好。生成的测试继承它之后,就能真正打到你的实现上做验证。不配它,生成的测试跑不起来。

Q3. GraphQL 的 N+1 问题是怎么产生的?DataLoader 为什么能解决?

参考答案

产生:GraphQL 引擎按字段逐个调用 resolver。查询"20 个商品及其评价"时,商品列表查 1 次,但每个商品的 reviews 字段各触发 1 次下游调用,合计 1+20=21 次。嵌套层级或列表长度增加时,调用次数会成倍放大。DataLoader 的解法:它不是立即发起调用,而是把同一批次的 key 收集起来(在同一个执行 tick 内),合并成一次批量查询(如 getByProductIds([...20 ids])),拿到结果后再按 key 分发回各自的 Future。21 次压成 2 次。注意:它的缓存作用域只能是单个请求,跨请求复用会造成用户间数据串号。

Q4. 为什么说 GraphQL 不能用在所有地方?什么场景应该继续用 REST?

参考答案

GraphQL 的代价在于:① HTTP 层缓存失效——查询走 POST,CDN 和浏览器缓存基本用不上;② 安全面变大——任意字段组合意味着必须做深度/复杂度限制与字段级鉴权;③ 可观测性变差——所有请求都是同一个 URL,日志里看不出是哪段查询慢;④ 解析开销——多了一层查询解析与字段编排。所以:服务间内部调用(点对点固定需求)、需要强 CDN 缓存的公开 API、文件上传下载与流式接口,都更适合 REST。GraphQL 的主战场是面向多端的聚合层。

Q5. 你的 GraphQL 网关上线后,监控显示 P99 突然飙高,下游调用量涨了 30 倍。最可能的原因和处置?

参考答案

最可能的原因:客户端发了深层嵌套或大列表的查询,触发了 N+1 放大。比如某端把 products(size: 500) { reviews { authorName } } 写进了首屏请求,500 个商品各查一次评价、每个评价再查一次作者,瞬间就是上万次下游调用。处置顺序:① 立刻按查询复杂度或 operation name 限流,先止血;② 从追踪里找出那个爆量查询对应的 operation,定位到调用方(客户端版本);③ 长期修复:给相关字段上 DataLoader 批量查询、给查询加最大深度与复杂度限制、协商调整客户端分页大小。预防:上生产前就把深度/复杂度限制和 persisted query 白名单配好,这样任意查询根本发不进来。