Spring Cloud Contract 实战——微服务间 API 契约的自动化验证
Spring Cloud Contract 实战——微服务间 API 契约的自动化验证
适读人群:Spring Cloud 微服务开发者 | 阅读时长:约 17 分钟 | 核心价值:用 Spring Cloud Contract 在 Spring 生态内实现 API 契约自动化验证
如果你的团队全栈都在用 Spring Boot,那 Spring Cloud Contract 比 Pact 更适合你。
我在一个全 Java 的微服务项目里对比过这两个框架。Pact 生态更广泛,支持多语言;Spring Cloud Contract 更深度集成 Spring,契约定义用 Groovy 或 YAML,和 Spring Boot Test 的整合更丝滑,生成的 Stub 可以直接作为 Spring Boot 应用跑起来。
有一次,我们的商品服务下线了一个字段 promotionPrice,被四个消费者依赖:积分服务、推荐服务、购物车服务、比价服务。没有 Spring Cloud Contract 之前,这种变更需要逐个通知,没有自动化保障,经常有漏通知的。有了契约测试,商品服务跑 mvn verify 的时候,四个消费者的契约全部被验证一遍,有一个不满足就立刻失败。
那次下线没有造成任何生产问题,因为所有不满足的消费者在 CI 阶段就被挡住了,各自修改适配后才能发布。
一、Spring Cloud Contract 核心概念
核心流程:
- 提供者定义契约文件(
src/test/resources/contracts/) - 构建时自动生成两样东西:
- 提供者端的测试代码(验证实现符合契约)
- 消费者端的 Stub JAR(消费者测试时用它替代真实服务)
- 消费者引入 Stub JAR,用它做测试,保证消费者代码与契约一致
- Stub JAR 发布到 Maven 仓库,消费者通过 Maven 依赖获取
二、依赖配置
提供者(Provider)
<build>
<plugins>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>4.1.1</version>
<extensions>true</extensions>
<configuration>
<testFramework>JUNIT5</testFramework>
<!-- 生成测试的基类 -->
<baseClassForTests>
com.example.product.contract.BaseContractTest
</baseClassForTests>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
</dependencies>消费者(Consumer)
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
<scope>test</scope>
</dependency>
</dependencies>三、提供者端:定义契约
契约文件(YAML 格式):
src/test/resources/contracts/product/shouldReturnProductById.yaml
description: "获取存在商品的详情"
request:
method: GET
url: /api/products/1001
headers:
Accept: application/json
response:
status: 200
headers:
Content-Type: application/json
body:
productId: 1001
name: "iPhone 15 Pro"
price: 8999.00
promotionPrice: 7999.00
status: "ACTIVE"
stock: 50
categoryId: 10
createdAt: "2024-01-15T10:00:00"
matchers:
body:
- path: $.productId
type: by_type
- path: $.name
type: by_type
- path: $.price
type: by_type
- path: $.createdAt
type: by_regex
value: "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"Groovy 格式(更灵活):
src/test/resources/contracts/product/shouldReturnProductsByCategory.groovy
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "按分类查询商品列表"
request {
method GET()
url '/api/products' {
queryParameters {
parameter 'categoryId': value(consumer(regex('\\d+')), producer('10'))
parameter 'page': value(consumer(regex('\\d+')), producer('0'))
parameter 'size': value(consumer(regex('\\d+')), producer('10'))
}
}
headers {
accept applicationJson()
}
}
response {
status OK()
headers {
contentType applicationJson()
}
body([
content: [
[
productId: $(producer(regex('\\d+')), consumer(1001)),
name: $(producer(anyNonEmptyString()), consumer('iPhone 15 Pro')),
price: $(producer(anyPositiveDouble()), consumer(8999.00)),
status: $(producer(regex('ACTIVE|INACTIVE')), consumer('ACTIVE'))
]
],
totalElements: $(producer(anyInteger()), consumer(5)),
totalPages: $(producer(anyInteger()), consumer(1))
])
}
}四、提供者端:测试基类
契约测试会自动生成测试类,但需要一个基类来配置上下文:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class BaseContractTest {
@Container
@ServiceConnection
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.36");
@LocalServerPort
private int port;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
RestAssured.port = port;
prepareTestData();
}
@AfterEach
void tearDown() {
productRepository.deleteAll();
}
private void prepareTestData() {
// 契约里用到的 productId=1001 必须存在
productRepository.save(Product.builder()
.id(1001L)
.name("iPhone 15 Pro")
.price(new BigDecimal("8999.00"))
.promotionPrice(new BigDecimal("7999.00"))
.status(ProductStatus.ACTIVE)
.stock(50)
.categoryId(10L)
.createdAt(LocalDateTime.now())
.build());
}
}运行 mvn generate-test-sources,Spring Cloud Contract 会自动生成类似这样的测试:
// 自动生成,不需要手写
public class ProductTest extends BaseContractTest {
@Test
public void validate_shouldReturnProductById() throws Exception {
// given
MockMvcRequestSpecification request = given()
.header("Accept", "application/json");
// when
ResponseOptions response = given().spec(request)
.get("/api/products/1001");
// then
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).matches("application/json.*");
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['productId']").matches("(.*)");
assertThatJson(parsedJson).field("['name']").matches("(.*)");
// ... 更多断言
}
}五、消费者端:使用 Stub 做测试
@SpringBootTest
@AutoConfigureStubRunner(
ids = "com.example:product-service:+:stubs:8090", // groupId:artifactId:版本:分类:端口
stubsMode = StubRunnerProperties.StubsMode.LOCAL // 从本地 Maven 仓库加载
)
class PointsServiceIntegrationTest {
@Autowired
private PointsCalculationService pointsService;
@Test
void 计算积分_调用商品服务获取价格_积分计算正确() {
// Stub Runner 已经启动了商品服务的 Stub,监听 8090 端口
// PointsCalculationService 配置指向 http://localhost:8090
// when - 实际上会调用 Stub(不是真实的商品服务)
PointsResult result = pointsService.calculatePoints(1001L, 2);
// then
assertThat(result.getPoints()).isEqualTo(200); // 基于 promotionPrice 8999*2*0.01
assertThat(result.getProductId()).isEqualTo(1001L);
}
}六、三个踩坑实录
坑 1:契约文件里的正则表达式匹配失败
现象: 提供者的测试失败,报 value '2024-01-15T10:00:00' does not match regex,但看起来格式是对的。
原因: 契约里写的正则是 \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2},但在 YAML 文件里反斜杠需要转义。
解法:
# 错误:单反斜杠在 YAML 里可能被解释为转义字符
- path: $.createdAt
type: by_regex
value: "\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"
# 正确:用双反斜杠或单引号
- path: $.createdAt
type: by_regex
value: '\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}'坑 2:Stub 端口冲突
现象: 多个测试类使用不同服务的 Stub,偶发端口冲突,Address already in use: 8090。
原因: 每个测试类都在同一个固定端口启动 Stub,并行测试时冲突。
解法: 使用动态端口(0 表示随机):
@AutoConfigureStubRunner(
ids = "com.example:product-service:+:stubs:0", // 0 = 随机端口
stubsMode = StubRunnerProperties.StubsMode.LOCAL
)然后通过 StubFinder 获取实际端口:
@Autowired
private StubFinder stubFinder;
@BeforeEach
void setUp() {
int stubPort = stubFinder.findStubUrl("com.example", "product-service").getPort();
// 更新被测服务的配置指向这个端口
}坑 3:Stub 版本和提供者实际版本不一致
现象: 消费者测试通过了,但上线后还是出了问题,提供者实际返回的字段和 Stub 不一样。
原因: Stub JAR 是老版本的,提供者已经更新了接口,但没有重新发布 Stub。消费者用的还是老 Stub。
解法: 在 CI 里强制消费者使用提供者的最新 Stub:
@AutoConfigureStubRunner(
ids = "com.example:product-service:+:stubs", // + 代表最新版本
stubsMode = StubRunnerProperties.StubsMode.REMOTE, // 从远程仓库拉取
repositoryRoot = "https://nexus.company.com/repository/maven-snapshots/"
)并且在 CI 中,必须先构建提供者(发布新 Stub),再构建消费者(验证与最新 Stub 的兼容性)。
七、Spring Cloud Contract vs Pact:如何选择
| 维度 | Spring Cloud Contract | Pact |
|---|---|---|
| 生态 | Spring 生态专属 | 多语言支持 |
| 契约定义 | Groovy/YAML(提供者定义) | Java DSL(消费者定义) |
| Stub 使用方式 | 独立 JAR,可作为服务启动 | 内嵌 MockServer |
| CI 集成 | Maven Plugin,开箱即用 | 需要 Pact Broker |
| 学习曲线 | 对 Spring 开发者低 | 需要额外学习 |
| 推荐场景 | 全 Java/Spring 技术栈 | 多语言混合技术栈 |
Spring Cloud Contract 在 Spring 生态里是最自然的契约测试方案,如果你的团队不涉及其他语言,直接用它。
