微服务契约兼容性的五层防线:从 ArchUnit 到 japicmp
目录
📦 本文基于的完整项目源码:meirongdev/shop
先说我的做法#
如果你在做一组 Java 微服务,已经把 contracts 独立成 jar、每个 caller 自己写 client,你会很快遇到下一个问题:谁来阻止下一次 contracts 变更悄悄打穿所有下游?
我现在更倾向的做法是——把五层防线组合起来,单独任何一层都不太够:
- ArchUnit COMPAT-01:每个
*Api的BASE_PATH最好带显式版本段(/vN或/internal)。 - ArchUnit COMPAT-02:事件契约最好容忍未知字段,并带上
schemaVersion。 - ArchUnit COMPAT-03:任何
@ApiDeprecation最好都声明replacement。 - japicmp 二进制兼容门禁:在 Maven
verify阶段对比新旧 contracts jar,binary-incompatible 变更直接 fail build。 - 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 类(OrderApi、WalletApi、ActivityApi …)都会被扫一遍。这背后的理念是 URI path versioning——比较常见的 REST API 版本化策略之一,原因也很朴素:URL 上直接写着版本,debug、observability、gateway routing 都更直观。
📖 参考:API Versioning Best Practices — Redocly、RESTful 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 的接入方式是这样:
-
根 pom 的
pluginManagement里声明 japicmp 插件 + 一个compat-checkprofile,平时默认不跑:<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> -
shared/shop-contracts/pom.xml在build/plugins里引用它,于是所有 15 个shop-contracts-*子模块都自动继承这个行为。 -
Makefile 里加一个入口:
contract-compat-check: $(MVNW) -q -pl shared/shop-contracts -am -Pcompat-check verify
跑起来之后,japicmp 会生成一个带 semver badge 的 markdown 报告(target/japicmp/*.md):
# Compatibility Report

> 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——skipshop-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 弃用信号。
shop 的 shop-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 Field、The Deprecation HTTP Header Field (IETF draft)、Zalando RESTful API Guidelines — Deprecation、How 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_lightweight—shop-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 的增强项里列着:
- Spring Cloud Contract producer 侧。当前只有 consumer 侧 WireMock。要做到“producer 改了 controller,consumer CI 也红”,需要 producer 写 groovy contract + 发 stubs.jar。单仓里收益边际递减,但多仓之后这就更像是必需品。
- OpenAPI + oasdiff。japicmp 管的是 Java binary 层。如果未来 contracts 要发 OpenAPI 给前端 / 其他语言,就需要一层 spec-level breaking-change 检测。oasdiff 自带 450+ 条规则,GitHub Action 直接接上 PR gate。
- 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
- RFC 8594 — The Sunset HTTP Header Field
- The Deprecation HTTP Header Field — IETF draft
- Zalando RESTful API Guidelines — Deprecation chapter
- Versioning REST API: Guide to Strategies & Best Practices 2025
- API Versioning Best Practices — Redocly
- How to Create API Deprecation Headers — OneUptime (2026-01)
Kafka / 事件 schema evolution
- Event Versioning Strategies for Event-Driven Architectures — theburningmonk.com (2025-04)
- Schema Evolution and Compatibility — Confluent Docs
契约测试 / OpenAPI diff
- Spring Cloud Contract 项目页
- Spring Cloud Contract CDC 指南
- oasdiff — OpenAPI Breaking Change Detection
- Using oasdiff to Detect Breaking Changes in APIs — Nordic APIs
架构演化
项目内相关文档
- 上一篇:微服务契约共享的 Tradeoff:从 Monorepo 到 Polyrepo,该共享到哪一步
docs/ARCHUNIT-RULES.md— 完整 ArchUnit 规则列表docs/COMPATIBILITY-DEVELOPMENT-STANDARD-2026.md— 项目兼容性规范docs/CONTRACT-TESTING-GUIDE.md— Spring Cloud Contract 落地计划