📦 本文基于的完整项目源码:meirongdev/shop

先说我的做法#

如果你在做一组 Java 微服务,已经把 contracts 独立成 jar、每个 caller 自己写 client,你会很快遇到下一个问题:谁来阻止下一次 contracts 变更悄悄打穿所有下游?

我现在更倾向的做法是——把五层防线组合起来,单独任何一层都不太够:

  1. ArchUnit COMPAT-01:每个 *ApiBASE_PATH 最好带显式版本段(/vN/internal)。
  2. ArchUnit COMPAT-02:事件契约最好容忍未知字段,并带上 schemaVersion
  3. ArchUnit COMPAT-03:任何 @ApiDeprecation 最好都声明 replacement
  4. japicmp 二进制兼容门禁:在 Maven verify 阶段对比新旧 contracts jar,binary-incompatible 变更直接 fail build。
  5. WireMock 契约测试:consumer 侧冻结自己对契约的理解,任何反序列化漂移都暴露在单元测试阶段。

加上运行时的 ApiCompatibilityInterceptor(自动写入 X-API-Version / Deprecation / Sunset / X-API-Replacement)——这些一起构成了一个可执行的"契约安全带"。

这篇文章把 shop 项目里具体是怎么落地的写下来,也把近两年可以对照的业界参考一起贴上。

为什么只共享 contracts 还不够#

上一篇 微服务契约共享的 Tradeoff 讲过——共享 @HttpExchange 接口是反模式,真正该共享的是 contracts(路径常量 + DTO)。问题是,一旦契约成了跨服务的共识,它就变成了一个隐式的耦合面

  • 某个 caller 在 OrderApi.AddToCartRequest 里加了一个字段,provider 不知道。
  • Provider 改了 CartView 里一个字段的类型,caller 不知道。
  • 有人把 /order/v1/cart/list 改成了 /order/v1/carts,谁都不知道到底什么时候会炸。
  • RefundResponse 多了一个 String reviewer 字段,旧消费者反序列化直接失败。
  • Kafka 事件多了个字段,旧 consumer 不认识,直接进 DLT 循环。

这些问题的共同点是——单靠 code review 和手工 changelog 很难撑住,我最后还是把检查能力写进 CI

我查这些资料时,Confluent 关于 schema evolution 的文档、Redocly 关于 API versioning 的整理,以及 Zalando 的 RESTful API Guidelines,都比泛泛的“最佳实践”文章更能直接支撑这里的规则。

第 1 层:ArchUnit — 契约纪律作为可执行测试#

shop 的 ArchUnit 测试里有一个专门的 CompatibilityConventionsTest。它做三件事:

COMPAT-01:路径最好带显式版本段。

@Test
void apiContracts_useVersionedBasePaths() throws Exception {
    for (Class<?> apiType : importApiConstantsClasses()) {
        Field basePath = resolveBasePathField(apiType);
        String value = (String) basePath.get(null);
        if (!value.matches("^/.+/(v\\d+|internal)$")) {
            violations.add(apiType.getName() + " BASE_PATH was " + value);
        }
    }
    assertTrue(violations.isEmpty());
}

这条规则把“API 要走 /vN//internal/”从约定变成可执行断言。项目里所有 *Api 类(OrderApiWalletApiActivityApi …)都会被扫一遍。这背后的理念是 URI path versioning——比较常见的 REST API 版本化策略之一,原因也很朴素:URL 上直接写着版本,debug、observability、gateway routing 都更直观。

📖 参考API Versioning Best Practices — RedoclyRESTful API Guidelines — Zalando

COMPAT-02:事件 DTO 最好容忍未知字段。

List<String> missingAnnotation = importTopLevelClasses(EVENT_PACKAGE).stream()
        .filter(type -> !type.isAnnotationPresent(JsonIgnoreProperties.class))
        .map(Class::getName)
        .toList();

assertTrue(missingAnnotation.isEmpty(),
        () -> "Missing @JsonIgnoreProperties(ignoreUnknown = true): ...");

配合 EventEnvelope 里的 schemaVersion + assertSupportedSchema(...),producer 可以大胆加字段、consumer 可以显式拒绝未知 schemaVersion。这是我在 Confluent 文档和 theburningmonk 那类材料里反复看到的一个思路——producer backward-compatible, consumer forward-compatible

📖 参考Event Versioning Strategies for Event-Driven Architectures (theburningmonk.com, 2025-04)Schema Evolution and Compatibility — Confluent Docs

COMPAT-03:@ApiDeprecation 最好声明 replacement。

@Test
void deprecatedApis_mustDeclareReplacement() {
    ArchRule typesRule = classes()
            .that().areAnnotatedWith(ApiDeprecation.class)
            .should(haveNonBlankReplacement())
            .because("@ApiDeprecation on a controller must name the replacement endpoint "
                    + "so ApiCompatibilityInterceptor can emit X-API-Replacement for downstream migrations");
    // ... 方法级别同样检查
}

这条规则和下面第 3 层的 ApiCompatibilityInterceptor 配套:interceptor 会自动把 replacement 字段写入 X-API-Replacement 响应头,消费者能在运行时显式地知道该去哪里。

第 2 层:japicmp — 把契约变更变成 CI 门禁#

ArchUnit 能约束"契约怎么写",但它看不见"上一版契约长什么样"。真正需要对比"新旧 jar"的,是 japicmp。

shop 项目里,japicmp 的接入方式是这样:

  1. 根 pom 的 pluginManagement 里声明 japicmp 插件 + 一个 compat-check profile,平时默认不跑:

    <plugin>
      <groupId>com.github.siom79.japicmp</groupId>
      <artifactId>japicmp-maven-plugin</artifactId>
      <configuration>
        <oldVersion>
          <dependency>
            <groupId>${project.groupId}</groupId>
            <artifactId>${project.artifactId}</artifactId>
            <version>${japicmp.baseline.version}</version>
          </dependency>
        </oldVersion>
        <parameter>
          <onlyBinaryIncompatible>true</onlyBinaryIncompatible>
          <breakBuildOnBinaryIncompatibleModifications>true</breakBuildOnBinaryIncompatibleModifications>
          <ignoreMissingOldVersion>true</ignoreMissingOldVersion>
          <skipPomModules>true</skipPomModules>
        </parameter>
      </configuration>
    </plugin>
    
  2. shared/shop-contracts/pom.xmlbuild/plugins引用它,于是所有 15 个 shop-contracts-* 子模块都自动继承这个行为。

  3. Makefile 里加一个入口:

    contract-compat-check:
    	$(MVNW) -q -pl shared/shop-contracts -am -Pcompat-check verify
    

跑起来之后,japicmp 会生成一个带 semver badge 的 markdown 报告(target/japicmp/*.md):

# Compatibility Report
![semver OK](https://img.shields.io/badge/semver-OK-green?logo=semver "semver OK")

> No incompatible changes found while checking backward compatibility of
> version `0.1.1-SNAPSHOT` with the previous version `0.1.0`.

几个选型细节值得说:

  • onlyBinaryIncompatible=true + breakBuildOnBinaryIncompatibleModifications=true——只对 binary-incompatible 变更 fail build。加字段、加方法这类 additive 变更不会失败,但会出现在报告里。
  • ignoreMissingOldVersion=true——第一次 publish 或模块刚加入时没有 baseline,japicmp 会跳过而不是炸 build,避免 bootstrap 阶段被自己绊倒。
  • skipPomModules=true——skip shop-contracts 父 pom 和 shop-contracts-bom,只检查真正的 jar。
  • baseline 版本用 Maven property japicmp.baseline.version(默认 0.1.0)——CI 里可以通过 -Djapicmp.baseline.version=1.4.2 针对性地对比任意发布版。

这种"把 binary-compatible-only check 钉死在 CI 里"的做法,是 Apache Commons 生态一直推的——How we handle binary compatibility at Apache Commons那篇博客里明确推荐 japicmp Maven plugin + 一个 Travis/GHA default goal 的组合。japicmp 本身也会在报告里给出 semver 建议(patch / minor / major),可以直接指导 contracts 的版本号升级。

📖 参考japicmp 项目主页How we handle binary compatibility at Apache Commons (Gary Gregory)

第 3 层:运行时 Deprecation / Sunset 头#

静态规则只管"还没 release 的代码",一旦 API 已经在线、要走"提前通知、逐步下线"流程,就需要运行时信号。RFC 8594 定义了 Sunset 响应头,IETF 草案 draft-ietf-httpapi-deprecation-header 定义了 Deprecation——这两个组合起来就是 2024 年 Zalando 推荐给全公司的 API 弃用信号。

shopshop-starter-api-compat 就是这套机制的落地:

public class ApiCompatibilityInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(...) {
        resolveApiVersion(request.getRequestURI())
                .ifPresent(v -> response.setHeader(CompatibilityHeaderNames.API_VERSION, v));

        if (handler instanceof HandlerMethod handlerMethod) {
            ApiDeprecation deprecation = resolveDeprecation(handlerMethod);
            if (deprecation != null) {
                response.setHeader("Deprecation", "true");
                response.setHeader("X-API-Deprecated-Since", deprecation.since());
                if (hasText(deprecation.sunsetAt())) {
                    response.setHeader("Sunset", formatSunset(deprecation.sunsetAt()));
                }
                if (hasText(deprecation.replacement())) {
                    response.setHeader("X-API-Replacement", deprecation.replacement());
                }
            }
        }
        return true;
    }
}

Controller 侧只需要一个注解:

@ApiDeprecation(
    since = "2026.03",
    sunsetAt = "2026-07-31",
    replacement = "/buyer/v2/profile")
@GetMapping("/buyer/v1/profile")
public ApiResponse<ProfileResponse> getProfile() { ... }

运行时响应会自动带上:

X-API-Version: 1
Deprecation: true
X-API-Deprecated-Since: 2026.03
Sunset: Fri, 31 Jul 2026 00:00:00 GMT
X-API-Replacement: /buyer/v2/profile

ArchUnit 的 COMPAT-03 规则保证了 replacement 不会漏填——否则这个信号最有用的部分(指路到新 API)就丢了。

📖 参考RFC 8594: The Sunset HTTP Header FieldThe Deprecation HTTP Header Field (IETF draft)Zalando RESTful API Guidelines — DeprecationHow to Create API Deprecation Headers (OneUptime, 2026-01)

第 4 层:WireMock 契约测试#

japicmp 管"jar 层的签名兼容",但 contracts 里的 record 可能全都兼容、路径里的字符串值却悄悄改了。这种"常量值改了,签名没变“的变更 japicmp 看不见。

补这条缺口的是 BFF 侧的 WireMock 契约测试。以 services/buyer-bff/src/test/java/.../contract/OrderContractTest 为例:

wireMockServer.stubFor(post(urlEqualTo(OrderApi.CART_LIST))   // <- 常量
        .willReturn(okJson(objectMapper.writeValueAsString(
                ApiResponse.ok(new OrderApi.CartView(List.of(...), subtotal))))));

OrderApi.CartView result = orderClient.listCart(
        new OrderApi.ListCartRequest("buyer-1")).data();

assertThat(result.subtotal()).isEqualByComparingTo("99.00");

这段测试把 BFF 对 order-service 的期望完整地用契约常量 + DTO 写了一遍。一旦 OrderApi.CART_LIST/order/v1/cart/list 改成别的、或者 CartView 的字段类型变了,测试会在本地 mvn test 就失败,根本不会走到集成环境。

这不是真正的 producer-driven contract test(producer 并不验证这些契约),但它对 consumer 的反序列化安全已经覆盖得相当彻底。想做到双向保证,可以接下来接入 Spring Cloud Contract producer 侧——docs/CONTRACT-TESTING-GUIDE.md 已经规划了方案,现在只是还没落到 groovy 文件这一步。

📖 参考Spring Cloud Contract 官方项目页Spring Cloud Contract CDC 指南

第 5 层:ArchUnit SPRING-02 / SPRING-03 守住 contracts 边界#

这一层不是"检测兼容性变更”,而是"防止契约模块自己先腐化":

  • SPRING-02 contracts_must_be_lightweightshop-contracts-* 不得依赖 Spring Web / Data / Kafka / JPA 运行时。防止契约包里混进 @Entity@KafkaListener@Service——那是把实现细节伪装成契约泄露给所有 caller。
  • SPRING-03 http_exchange_clients_must_live_in_caller_module@HttpExchange 接口不得下沉到共享层。Client 由 caller 各自维护,契约模块只出数据 + 常量。

这两条规则的背后逻辑是:契约越薄、越被动,兼容性问题才越收敛。一旦契约里开始出现“逻辑”,就更容易积累隐式耦合,japicmp 也追不上。

一张总结表#

防什么 工具 何时触发 追得上什么漂移
1. ArchUnit COMPAT-01/02/03 契约纪律 CompatibilityConventionsTest mvn test 路径无版本、事件漏 ignore-unknown、@ApiDeprecation 漏 replacement
2. japicmp Java 二进制兼容 japicmp-maven-plugin -Pcompat-check verify 删字段、改方法签名、缩类型
3. 运行时 Deprecation 头 下线预告 ApiCompatibilityInterceptor 每次请求 通知消费者迁移;不"检测"漂移,只"告知"
4. WireMock 契约测试 消费者序列化期望 *ContractTest (BFF 侧) mvn test 路径常量值改、DTO 反序列化失败
5. ArchUnit SPRING-02/03 契约模块边界 SpringRulesTest mvn test 契约模块混入 Spring / JPA / 共享 client

还没补上的那一层#

我没有把所有东西都塞进 demo,因为它毕竟是 demo。有几件事是有意识留下的"下一步",也在 docs/COMPATIBILITY-DEVELOPMENT-STANDARD-2026.md 的增强项里列着:

  1. Spring Cloud Contract producer 侧。当前只有 consumer 侧 WireMock。要做到“producer 改了 controller,consumer CI 也红”,需要 producer 写 groovy contract + 发 stubs.jar。单仓里收益边际递减,但多仓之后这就更像是必需品。
  2. OpenAPI + oasdiff。japicmp 管的是 Java binary 层。如果未来 contracts 要发 OpenAPI 给前端 / 其他语言,就需要一层 spec-level breaking-change 检测。oasdiff 自带 450+ 条规则,GitHub Action 直接接上 PR gate。
  3. Kafka schema registry。现在事件用 EventEnvelope.schemaVersion@JsonIgnoreProperties 做软兼容。如果想做到“生产者升 schema 要过 broker registry 的 compatibility check”,还需要 Confluent Schema Registry / Apicurio。

为什么选这个顺序?因为这三件事的共同点是 有治理成本。它们不是技术难题,而是组织上的事:谁来 own spec、schema registry、stubs.jar 的发布流程?demo 里先把"能自动化的、无治理负担的"安全带系上,更有治理能力的那层等有真实多仓 / 多语言 / 多团队诉求再补。

小结#

契约兼容性不是一个点,是一个层。每一层都只能覆盖一类漂移:

  • ArchUnit 覆盖"纪律漂移"——代码还没 release 就阻止。
  • japicmp 覆盖"签名漂移"——编译期 + CI。
  • WireMock 覆盖"序列化漂移"——consumer 测试阶段。
  • 运行时头覆盖"下线漂移"——给消费者留迁移窗口。

按我目前的理解,真正让这套机制更容易长期维持的,还是规则可执行——不是“团队约定了这样做”,而是 mvn test 会红、CI 会拒 merge、X-API-Replacement 响应头会自动出来。这也更接近 Neal Ford 在《Building Evolutionary Architectures》里反复讲的 fitness function 思路:把“应该怎样”写成可执行测试。

参考资料#

japicmp 与 Java 二进制兼容

API 版本化 / Deprecation / Sunset

Kafka / 事件 schema evolution

契约测试 / OpenAPI diff

架构演化

项目内相关文档