这篇文章可以看作 《Contract First 工作流:让 AI 帮你写 OpenAPI YAML》 的实现篇。上一篇讨论的是“spec 如何成为协作中心”;本文只讨论当接口边界已经确定之后,JVM 团队如何把contract变成验证、stubs 和 CI Quality Gates。

微服务接口兼容性的问题,通常不是在设计阶段暴露,而是在某个 PR 合并之后才通过联调、回归测试,甚至线上流量被发现。这个 java-contract 仓库的目的,就是把这类问题尽量前移到提交阶段,用 contract testing 把 producer 和 consumer 之间的接口约束固定下来。

这篇文章不打算再展开 AI 生成 OpenAPI、style guide 或 breaking change 治理这些协作层话题。它更像是一次基于当前仓库完成态的工程复盘:为什么这个 Java 25 + Spring Boot 3.5 + Maven 多模块项目最终选择了 Spring Cloud Contract,contract如何在仓库里流动,以及这套做法如何进入 CI,成为一条可执行的兼容性 Quality Gates。

项目代码:github.com/meirongdev/java-contract

有了 OpenAPI 和集成测试,还需要 SCC 吗?#

采用 Contract First 工作流的团队常会遇到这个问题。我的理解是:三者关注的粒度不同,不互相替代。

OpenAPI spec 描述的是接口形状:字段、类型、必填约束、状态码有哪些。集成测试验证的是系统整体行为:多个服务部署在一起能否跑通。SCC 填的是中间那层——接口行为:在某个具体场景下,一次请求会得到什么确定性响应,且这个断言在提交阶段就能被执行。

/users/{id} 为例。OpenAPI 会告诉你:

GET /users/{id}:
  responses:
    '200':
      schema: $ref: '#/components/schemas/User'
    '404':
      schema: $ref: '#/components/schemas/ErrorResponse'

这份 spec 定义了”200 返回 User,404 返回 ErrorResponse”——但它不告诉你:

  • GET /users/1(用户存在)的响应体里具体有哪些字段、值是什么
  • GET /users/999(用户不存在)时,message 字段写的是 ”User not found” 还是 ”Not found”

集成测试可以回答这个问题,但代价是:consumer 要等到真实 provider 部署好之后才能跑,反馈慢,问题定位混杂了环境、数据、网络和业务逻辑。

SCC 把这个问题的答案固定成 contract,在构建阶段就执行:

// getUserById.groovy
Contract.make {
    request { method GET(); url '/api/users/1' }
    response {
        status 200
        body([id: 1, name: 'Alice', email: 'alice@example.com'])
        headers { contentType(applicationJson()) }
    }
}

// getUserNotFound.groovy
Contract.make {
    request { method GET(); url '/api/users/999' }
    response {
        status 404
        body([message: 'User not found', code: 404])
        headers { contentType(applicationJson()) }
    }
}

有了这两份 contract,SCC 至少能做两件很有帮助的事:

在 producer 侧生成验证测试,跑在真实 Spring MVC context 上。OpenAPI schema validator 只能检查”响应是否符合字段定义”,不会检查”controller 在用户不存在时是否真的返回 404 而不是 500”。

生成版本化 stubs JAR,consumer 在自己的测试里启动 WireMock,用这份 stub 替代真实 provider。Prism 这类工具也能基于 OpenAPI 生成 mock server,但返回的是基于 example 的随机响应;SCC stubs 是 producer 和 consumer 协商后确定的场景,随代码一起版本化。

三者的分工:

关注点 OpenAPI spec 集成测试 Spring Cloud Contract
字段类型、必填约束 ✓(通过 matcher)
状态码分类
每个场景的具体行为 ✓(慢) ✓(快,构建阶段)
Consumer stubs(版本化) △ Prism,无版本绑定 ✓ stubs JAR
Provider 行为验证 △ 只验证 schema 合规 ✓(依赖部署环境) ✓ 生成测试,跑真实 context
消息格式约定(Kafka/MQ) ✗ 不覆盖消息协议 ✓ 消息 contract DSL

如果你的 API 是简单 CRUD 且没有太多状态分支,OpenAPI + schema 校验可能就够了。一旦出现错误场景、consumer 需要可复现 stub、或者你希望接口兼容性在 PR 阶段就被拦下来,SCC 才值得引入。

值得特别提一句:OpenAPI 只覆盖 HTTP 接口,不覆盖 Kafka 消息的格式。如果系统里同时有 REST API 和事件消息,contract testing 的价值就不只是"填集成测试的慢反馈",而是覆盖了一整类 OpenAPI 根本无法描述的接口——消息体的字段名和类型一旦变动,consumer 如何感知?这正是消息 contract 存在的意义。

Spring Cloud Contract 和 Pact,这个项目为什么选前者?#

这个仓库最终选 Spring Cloud Contract,不是因为 Pact 不行,而是因为它的工程背景非常明确:纯 Java 25、Spring Boot 3.5、多模块 Maven、producer 和 consumer 都在 JVM 生态内。

在这种前提下,SCC 有几个更贴近当前栈的地方:

  • contract由 producer 用 Groovy DSL 维护,和 API 变更责任更贴近
  • spring-cloud-contract-maven-plugin 可以直接生成 provider 侧验证测试和 stubs JAR
  • contract传播复用 Maven 体系,不需要额外引入 Pact Broker
  • 和 Spring Boot 测试栈结合自然,CI 里也更容易收敛成一条标准 Maven 流程

Pact 当然仍然是成熟方案,但它更适合两类场景:一类是跨语言团队,另一类是已经围绕 Pact Broker 建好了协作流程的组织。当前这个仓库并不属于这两种情况,所以文章主线落在 SCC,而不是把 Pact 当成主实现。

顺带一提,这个仓库的 consumer 侧并没有继续沿用老式的 Stub Runner 路径,而是直接使用官方 wiremock-spring-boot。这也让整体结构更直接:producer 负责定义和验证contract,consumer 负责验证自己对contract的消费行为。

这个仓库是怎么组织的?#

这个 POC 是一个典型的 Maven 多模块工程:

java-contract/
├── pom.xml
├── contract-model/                          ← 共享 DTO:User、UserCreatedEvent
├── producer-service/
│   └── src/test/resources/
│       └── contracts/
│           ├── getUserById.groovy           ← HTTP contract
│           ├── getUserByIdWithMatchers.groovy
│           ├── getUserNotFound.groovy
│           └── messaging/
│               └── publishUserCreated.groovy  ← Kafka 消息 contract
└── consumer-service/
    └── src/test/resources/stubs/

三个模块各自承担的职责很清楚:

  • contract-model 放共享 DTO:User(HTTP 响应体)和 UserCreatedEvent(Kafka 消息体)
  • producer-service 放 provider API、事件发布逻辑以及 contract 定义
  • consumer-service 放 HTTP 调用客户端、消息监听逻辑和 consumer contract testing

其中最关键的两个目录是:

  • producer-service/src/test/resources/contracts/
  • consumer-service/src/test/resources/stubs/

前者按类型分子目录:HTTP contract 在根目录,消息 contract 在 messaging/。两种 contract 通过插件的 baseClassMappings 分别指向不同的测试基类。后者放的是 consumer HTTP 测试使用的 WireMock stub 文件。

除了代码目录,CI 结构也已经固定下来。.github/workflows/ci-contracts.yml 把流程拆成 enforcerqualityproducer-contractsconsumer-contractscoverage 五层,这意味着这个项目的 contract testing 不是临时脚本,而是和依赖约束、静态检查、覆盖率报告一起进入了标准流水线。

contract 在这个项目里是怎么流动的?#

这套实现的核心,不是“写了几份 Groovy 文件”,而是contract如何从定义一路流到验证。

第一步,producer 在 producer-service/src/test/resources/contracts/ 中定义contract。以 getUserById.groovy 为例,请求是 GET /api/users/1,响应是 200,并返回固定 JSON 结构。getUserByIdWithMatchers.groovy 则展示了 SCC 的 matcher 能力,例如 consumer(regex('/api/users/[0-9]+'))producer('/api/users/2') 这种“consumer 看规则、producer 看具体值”的写法。getUserNotFound.groovy 则补上了 404 场景。

第二步,producer-service 通过 spring-cloud-contract-maven-plugin 把这些contract转成 provider 侧验证测试。插件配置放在 producer-service/pom.xml,并指定了基类 com.example.producer.ContractBaseTest。这个基类里用 RestAssuredMockMvc.webAppContextSetup(context) 把 Spring MVC 上下文接进来,SCC 生成的测试会基于它验证 controller 的真实实现是否满足contract。

第三步,同一套contract会产出 stubs JAR。这个项目在 CI 的 producer-contracts 阶段不仅运行 producer 合约验证,还会把 stubs 部署到 GitHub Packages,并额外上传为构建产物。这一点很关键,因为它把“contract”从源码层面的描述,变成了可以被下游消费的产物。

第四步,consumer 侧不再去碰真实 producer,而是在 consumer-service 里用 wiremock-spring-boot 起一个 WireMock 服务,验证自己的调用代码。UserServiceContractTest 上使用的是 @EnableWireMock@ConfigureWireMock,并把 user.service.base-url 动态指向 WireMock 随机端口。这样 consumer 的测试只关心一件事:UserService 在面对contract规定的响应时,是否还能正确工作。

从工程视角看,这条链路把职责切得很清楚:

  • producer 对contract和实现一致性负责
  • consumer 对调用方式和解析逻辑负责
  • CI 对整个兼容性链路是否继续成立负责

REST 接口之外,contracts/messaging/ 里还有一条并行的消息链路,下一节单独展开。

Kafka 消息的 contract 怎么验证?#

OpenAPI 只描述 HTTP 接口,Kafka 消息格式它覆盖不到。如果 producer 悄悄把 userId 字段改成 id,consumer 的反序列化代码会静默失效——这类问题在联调之前很难被发现。SCC 的 messaging contract 填的正是这个空缺。

消息 contract 长什么样#

publishUserCreated.groovy 定义了一个完整的消息场景:

// producer-service/src/test/resources/contracts/messaging/publishUserCreated.groovy
Contract.make {
    description "should publish UserCreatedEvent when a user is created"
    label 'user_created'
    input {
        triggeredBy('triggerUserCreated()')  // 调用基类里的方法触发发送
    }
    outputMessage {
        sentTo('user-events')               // Kafka topic 名
        body([
                userId  : 1,
                username: "alice",
                email   : "alice@example.com"
        ])
        headers {
            header('contentType': 'application/json')
        }
    }
}

和 HTTP contract 的区别在于:没有 request/response,而是 input(触发条件)和 outputMessage(期望发出的消息)。三个关键字段的分工:

  • label:消息的逻辑标识,consumer 侧通过 stubTrigger.trigger('user_created') 引用它
  • triggeredBy:producer 基类里的方法名,SCC 生成的测试会调用它,然后捕获随之发出的消息
  • sentTo:目标 channel / Kafka topic

当字段需要更严格的类型约束——例如 userId 更适合保持为数字、email 需要符合某种格式——可以仿照 HTTP 侧的 getUserByIdWithMatchers.groovy,用 $(consumer(...), producer(...)) 指定不同角色看到的值:

outputMessage {
    sentTo('user-events')
    body([
            userId  : $(consumer(anyNumber()),         producer(1)),
            username: $(consumer(anyNonEmptyString()), producer("alice")),
            email   : $(consumer(regex('.+@.+\\..+')), producer("alice@example.com"))
    ])
    headers {
        header('contentType': 'application/json')
    }
}

consumer(...) 是 consumer 侧生成 stub 时使用的匹配规则,producer(...) 是 producer 验证自己实现时对照的具体值。这样 contract 既能精确验证 producer 的实现,又能在 consumer 侧容忍不同测试数据。

producer 侧:两类 contract 各自对应一个基类#

HTTP contract 和消息 contract 需要不同的测试基类,通过插件的 baseClassMappings 区分:

<baseClassMappings>
    <baseClassMapping>
        <contractPackageRegex>.*messaging.*</contractPackageRegex>
        <baseClassFQN>com.example.producer.MessagingContractBaseTest</baseClassFQN>
    </baseClassMapping>
</baseClassMappings>
<baseClassForTests>com.example.producer.ContractBaseTest</baseClassForTests>

MessagingContractBaseTest 加上 @AutoConfigureMessageVerifier,SCC 用它捕获 StreamBridge 实际发出的消息:

@SpringBootTest
@AutoConfigureMessageVerifier
public abstract class MessagingContractBaseTest {

    @Autowired
    private UserEventPublisher userEventPublisher;

    void triggerUserCreated() {
        userEventPublisher.publishUserCreated(
                new UserCreatedEvent(1L, "alice", "alice@example.com"));
    }
}

UserEventPublisherStreamBridge 把消息发到 user-events,测试阶段由 spring-cloud-stream-test-binder 接管,不需要真实 Kafka。

测试用真实 Kafka 还是 in-memory binder?#

这是落地消息 contract 时的第一个岔路口。本仓库走的是 spring-cloud-stream-test-binder:in-memory、启动快、没有外部依赖,适合把 contract testing 保持在"单元测试级"的成本上。代价是没有验证 Kafka 本身的行为(序列化器、分区、offset),也不覆盖 KafkaTemplate 这类 Spring Kafka 原生 API。

另一条路是 Testcontainers + 真实 Kafka + 自定义 MessageVerifierReceiver

public class KafkaMessageVerifier implements MessageVerifierReceiver<Message<?>> {

    private final Map<String, BlockingQueue<Message<?>>> broker = new ConcurrentHashMap<>();

    @KafkaListener(topics = "#{@allTopics}", groupId = "random")
    public void listen(Message<?> payload,
                       @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
        broker.computeIfAbsent(topic, k -> new ArrayBlockingQueue<>(10)).add(payload);
    }

    @Override
    public Message<?> receive(String destination, long timeout,
                              TimeUnit timeUnit, YamlContract contract) throws Exception {
        return broker.computeIfAbsent(destination, k -> new ArrayBlockingQueue<>(10))
                .poll(timeout, timeUnit);
    }
}

基类里用 @ServiceConnection 启动 Kafka 容器,consumer 侧再对称实现一个 MessageVerifierSender 把消息写回真实 topic。这种做法 contract 本身完全不变,只是替换了消息传输的底层。如果想先看官方对 Kafka 消息 contract 的支持边界,可以参考 Spring Cloud Contract 的 Messaging/Kafka 文档。自定义 MessageVerifier 再叠加 Testcontainers,会更偏向工程实现细节,需要按团队现状继续补。

怎么选:如果团队只关心"消息体字段格式"是否兼容——这也是 OpenAPI 覆盖不到、contract testing 一个比较直接的价值——test-binder 可能就够了;如果还想一并覆盖 Kafka 侧的配置、序列化、header 传播,那 Testcontainers 路线会更贴近生产。本文代码走前者,是为了让 contract testing 在 PR 流水线里跑得足够快。

consumer 侧:用 StubTrigger 触发消息验证#

consumer 有一个 UserEventListener 监听 user-events

@Configuration
public class UserEventListener {

    @Bean
    public Consumer<UserCreatedEvent> handleUserCreated() {
        return event ->
                log.info("User created: id={}, username={}", event.userId(), event.username());
    }
}

对应的 contract 测试通过 StubTrigger 触发 producer contract 里定义的消息,验证 consumer 能正确处理:

@SpringBootTest
@AutoConfigureStubRunner(
        ids = "com.example:producer-service:+:stubs",
        stubsMode = StubRunnerProperties.StubsMode.CLASSPATH)
class UserEventContractTest {

    @Autowired
    private StubTrigger stubTrigger;

    @Test
    void shouldConsumeUserCreatedEvent() {
        stubTrigger.trigger("user_created");
    }
}

stubsMode = CLASSPATH 表示从 consumer pom 里已声明的 producer-service:stubs JAR 加载 contract,不需要从远程拉取。trigger("user_created") 对应 contract 里的 label,stub runner 把 outputMessage 里定义的消息发到 consumer 的输入 channel,handleUserCreated 随即被执行。

这套验证能发现什么#

场景一:字段重命名。 producer 把 UserCreatedEvent 里的 userId 改成 id

  • producer 测试:SCC 生成的测试失败——实际消息里没有 userId 字段,和 contract 对不上
  • consumer 测试:stub runner 按 contract 发带 userId 的消息,consumer 反序列化时字段为 null,断言失败

场景二:值格式收紧。 publishUserCreatedWithMatchers.groovyregex('.+@.+\\..+') 约束了 email,producer 不小心返回空字符串:

  • producer 测试:SCC 用 consumer 侧的 regex 校验实际输出,"" 不匹配,立即报错
  • 未来若 consumer 想进一步收紧约束(例如要求企业邮箱),只需修改 contract 的 regex,两端都会在下一次 CI 跑时被强制对齐

场景三:DTO 类型漂移。 这是 OpenAPI 不太能覆盖的——Kafka 消息通常没有统一的 schema registry,producer 和 consumer 只靠“双方各自维护一份 UserCreatedEvent”维持兼容。contract 让这份 DTO 成为两端都要对照的第三方参照,而不是隐式约定。

三类问题通常都能在 PR 阶段更早暴露,不需要等到联调环境才发现消息格式已经不兼容。

本地怎么跑?CI 怎么接?#

这个仓库没有发明新的命令体系,核心仍然是 Maven wrapper:

./mvnw clean test
./mvnw -pl producer-service -am test
./mvnw -pl consumer-service -am test
./mvnw -DskipTests verify

对应关系也很直接:

  • ./mvnw clean test 适合跑完整工程
  • ./mvnw -pl producer-service -am test 只看 producer contract验证
  • ./mvnw -pl consumer-service -am test 只看 consumer 的 WireMock contract testing
  • ./mvnw -DskipTests verify 负责质量检查,包括 Spotless、Checkstyle 和 SpotBugs

CI 的分层和本地命令也是一一对应的。

enforcer 先检查 Java 版本和依赖一致性;quality 再跑格式化和静态分析;producer-contracts 负责执行 producer 侧contract验证,并把 stubs 部署到 GitHub Packages;consumer-contracts 在此基础上执行 consumer 侧 contract testing;最后 coverage 汇总 JaCoCo 报告并上传 Codecov。

这样的分层有一个比较实际的好处:当 PR 失败时,你知道自己坏的是哪一层。是依赖和版本问题、代码质量问题、provider 没满足contract,还是 consumer 已经不再符合contract,定位成本会比把所有东西塞进一个 mvn test 低得多。

什么时候继续用 SCC,什么时候再看 Pact?#

如果你的项目和这个仓库类似,我自己会先用下面几条判断:

  • 全部是 Java / Spring Boot / Maven,可以先优先看 SCC
  • producer 希望主导 contract 定义,并希望把 contract 验证并入现有构建流程,可以先优先看 SCC
  • 需要复用 Maven 仓库、stubs JAR 和 Spring 测试栈,也比较适合先看 SCC

只有当你的组织进入下面这些场景时,Pact 才会更有吸引力:

  • consumer 和 producer 分散在多种语言栈里
  • 团队已经围绕 Pact Broker 建立了发布和协作规范
  • 你更需要 consumer-driven 的协作模型,而不是 provider-driven 的contract收敛方式

所以这篇文章想说明的并不是“Spring Cloud Contract 一定比 Pact 更好”,而是更具体的一句话:对于这个已经落地完成的 java-contract 仓库,Spring Cloud Contract 目前更贴合工程现实。它没有把问题变复杂,而是把 contract 定义、实现验证、consumer 校验和 CI Quality Gates 收拢进了同一套 Java/Spring/Maven 工作流里。

如果你现在关心的是另一层问题——例如 AI 如何生成 OpenAPI、spec 风格怎么统一、breaking change 谁来拍板——可以回到《Contract First 工作流:让 AI 帮你写 OpenAPI YAML》。那篇回答“contract 如何成为协作中心”,本文回答“contract 如何成为工程 Quality Gates”。