API 与 event contract 兼容性保障:工具机制与正确用法
目录
📦 本文基于的完整项目源码:meirongdev/shop
上一篇 微服务 contract 兼容性的五层防线:从 ArchUnit 到 japicmp 讨论了如何用 ArchUnit、japicmp、WireMock 和运行时 Deprecation 头构建基础安全带。本文聚焦更具体的问题:BFF 对外的 API、BFF→MS 和 MS→MS 的内部 API、以及 Kafka 事件,各自应该用什么工具保障 contract 兼容性,这些工具的工作机制是什么。
一、两类 contract,两种保障方式#
Q:BFF 对外暴露的 API 和 BFF→MS、MS→MS 之间的内部 API,兼容性的保障方式应该一样吗?
在我的实践中不太一样。主要原因在于 source of truth 不同。
BFF 对外的 API(前端、移动端、三方调用方)采用的是 API-first 方式:先有 OpenAPI YAML,生产者的 controller 实现这份 YAML,消费者根据这份 YAML 生成客户端代码或手写调用。在这条链路里,YAML 就是权威来源。保障兼容性就是保障 YAML 文件本身不引入 breaking change。
BFF→MS 和 MS→MS 之间采用的是 code-first 方式:Java record 是 source of truth,没有提前写 spec,序列化层(Jackson)从 record 推导出 JSON 结构。保障兼容性就是保障 Java record 的 JSON 表现形式不引入 breaking change。
前端 / 移动端
│
▼ API-first:OpenAPI YAML → oasdiff
buyer-bff / seller-bff
│
▼ code-first:Java record → JSON Schema 快照
marketplace-service / order-service / wallet-service / ...
│
▼ code-first:Java record → JSON Schema 快照(与 HTTP contract 相同处理)
Kafka events(string JSON)
两条路线所用的工具、触发时机和覆盖范围差异很大,混用可能会留下盲区。
二、BFF 对外 API:API-first + oasdiff#
Q:API-first 的核心是什么?和 code-first 的根本区别在哪里?
API-first 的核心是:消费者依赖的是 spec 文件,不是服务的实现代码。spec 文件可以是手写的(真正的 spec-first,设计在代码之前),也可以是从运行中的服务生成后提交到 git 的(code-first 实现 + spec-first contract)。无论哪种,一旦 spec 文件进入版本控制,它就成了协作边界,消费者不需要看后端 repo 的源码就能知道 API 的完整形状。
shop 项目里,shop-contracts-order 已经走了这条路:
shared/shop-contracts/shop-contracts-order/
src/main/resources/openapi/order-api.yml ← source of truth
target/generated-sources/openapi-server/ ← 生成的 controller interface
target/generated-sources/openapi-client/ ← 生成的 @HttpExchange client
order-service 的 controller 实现 openapi-server 里生成的接口,order-service 的消费者(buyer-bff)用 openapi-client 里生成的 @HttpExchange 代理。任何 API 变更都先改 YAML,再重新生成,编译不过就是 breaking。
BFF 对外(buyer-bff 暴露给前端的 /buyer/v1/*)也是同样的逻辑——用 springdoc-openapi 从 BuyerController 的注解和 record 类型生成 spec,提交到 git,用 oasdiff 守住不引入 breaking change。
Q:BFF 的 OpenAPI spec 是怎么生成的?怎么保证 spec 文件不过时?
所有 domain 服务已经引入了 springdoc-openapi-starter-webmvc-ui,服务运行时 /v3/api-docs.yaml 就可以直接下载 spec:
curl http://localhost:8080/v3/api-docs.yaml -o buyer-api.yaml
但这要求服务先跑起来,不适合 CI。离线方式是 springdoc-openapi-maven-plugin,在 integration-test 阶段启动内嵌服务器、导出 spec、关闭:
<!-- buyer-bff/pom.xml -->
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>2.1.0</version>
<executions>
<execution>
<phase>integration-test</phase>
<goals><goal>generate</goal></goals>
</execution>
</executions>
<configuration>
<outputFileName>buyer-api.yaml</outputFileName>
<outputDir>${project.basedir}/src/main/resources/openapi</outputDir>
</configuration>
</plugin>
生成的 buyer-api.yaml 提交到 git。这样 spec 文件始终和代码同步——如果有人修改了 controller 但没有重新生成 spec,CI 会发现两者不一致(生成的 spec 和 git 里的 spec 有 diff)。
Q:oasdiff 是怎么工作的?它能检测到哪些 breaking change?
oasdiff 对比两个 OpenAPI YAML 文件的结构差异,把变更分成两类:
- ERR(breaking):会导致现有客户端出错的变更,CI 应该 fail。
- WARN(potentially breaking):有风险但不一定出错的变更,需要人工判断。
典型的 ERR 级别变更:
| 变更 | 说明 |
|---|---|
| 删除一个 path 或 HTTP method | 所有调用方立刻 404 |
| response 字段从 nullable → required | 旧客户端收到 null 时会崩 |
| 请求体新增 required 字段 | 旧客户端不发这个字段,请求被拒 |
| 删除一个 enum 值 | 旧客户端发送了已删除的值 |
修改字段类型(string → integer) |
旧客户端反序列化失败 |
CI 里的用法:
# 对比 main 分支和当前分支的 spec
oasdiff breaking \
origin/main:services/buyer-bff/src/main/resources/openapi/buyer-api.yaml \
services/buyer-bff/src/main/resources/openapi/buyer-api.yaml \
--fail-on ERR
oasdiff 有 450+ 条规则,覆盖了 OpenAPI 3.0 和 3.1 的大多数变更类型。GitHub Action 可以直接用官方的 oasdiff/oasdiff-action,PR 上自动给出 breaking change 报告。
Q:oasdiff 能检测到的,japicmp 为什么检测不到?
japicmp 工作在 Java 字节码层,它比较的是两个 JAR 里的类结构:方法签名、字段类型、访问修饰符。这些都是 JVM 层面的概念。
oasdiff 工作在 HTTP 协议层,它比较的是 JSON 序列化后的接口形状:字段名(包括 @JsonProperty 重命名后的名字)、nullable 约束、enum 值列表、required 约束。
两者的盲区正好互补:
| 变更 | japicmp | oasdiff |
|---|---|---|
| 删除 record 字段 | ✅ binary-incompatible | ✅ breaking |
@JsonProperty("name") 改成 @JsonProperty("fullName") |
❌ binary-compatible | ✅ breaking(字段名变了) |
字段加 @NotNull(nullable → required) |
❌ binary-compatible | ✅ breaking(required 约束变了) |
| 删除一个 enum 字面量 | ❌ binary-compatible | ✅ breaking |
| 删除一个 path 常量的字符串值 | ❌ binary-compatible | ✅ breaking |
这就是为什么 API-first 路线不依赖 japicmp,而用 oasdiff——因为 oasdiff 理解的是 HTTP 客户端真正在意的那层语义。
Q:前端需要 OpenAPI spec,但不应该有后端 repo 的访问权限,有什么方案?
三种常见模式,按复杂度递增:
独立 spec 仓库(最常见,最小成本)
创建一个轻量级仓库 shop-api-specs,只放 YAML 文件。后端 CI 在 spec 发生变更时自动推送:
# .github/workflows/publish-spec.yml(后端仓库)
- name: Push to spec repo
uses: cpina/github-action-push-to-another-repository@main
with:
source-directory: services/buyer-bff/src/main/resources/openapi
destination-repository-name: shop-api-specs
前端团队只需要 shop-api-specs 的读权限,从这里拿 YAML 生成 TypeScript 类型或文档。
托管 API 文档(有 changelog 需求时)
Bump.sh 专门为这个场景设计:后端 CI push spec,Bump.sh 自动生成带 diff changelog 的文档页面。前端拿到一个 URL,直接下载 YAML 或浏览文档,不需要任何 repo 权限。
npm package(前端是 TypeScript 时)
把 spec 打包成 @meirongdev/shop-api-spec 发布到 npm registry,前端用 openapi-typescript 直接从包里生成类型定义。这个项目的 KMP 前端用 Kotlin,openapi-generator 的 kotlin generator 更合适。
三、内部 API:code-first + JSON Schema 快照#
Q:japicmp 已经在 CI 里了,为什么还需要 JSON Schema 快照?
japicmp 守的是 Java 类型级别的 binary compatibility,它的检测范围和 JSON 消费者真正关心的范围之间有一段空白:
// japicmp 看不见的 breaking change 示例
// 1. @JsonProperty 重命名:Java 签名没变,JSON 字段名变了
public record ProductResponse(
@JsonProperty("product_name") String name // 原来是 @JsonProperty("name")
) {}
// 2. 加 @NotNull:Java binary compatible,但 JSON required 约束变了
// 旧消费者发送的请求不含这个字段,现在会被 validation 拒绝
public record UpsertProductRequest(@NotNull String imageUrl) {}
// 3. @JsonIgnoreProperties 被意外删除:事件消费者可能因未知字段崩溃
// @JsonIgnoreProperties(ignoreUnknown = true) ← 被删掉了
public record OrderEventData(String orderId, ...) {}
这三类变更 japicmp 都会放行,因为它们都是 binary-compatible 的。但对 JSON 消费者来说,每一个都是 breaking change。
JSON Schema 快照补上了这个盲区:它从 Java record 生成 JSON Schema,把 @JsonProperty、@NotNull、@JsonIgnoreProperties 这些 Jackson / Bean Validation 注解的语义都翻译进来,对比快照发现任何 JSON 层面的结构变化。
Q:JSON Schema 快照方案具体是怎么工作的?victools/jsonschema-generator 做了什么?
com.github.victools:jsonschema-generator 是目前 Java 生态里对 record + Jackson 支持最完善的 JSON Schema 生成库(4.28 版起原生支持 Java records)。它有三个模块:
jsonschema-generator:核心,从 Java 类型生成 JSON Schema Draft 2020-12jsonschema-module-jackson:理解@JsonProperty(字段重命名)、@JsonIgnoreProperties、@JsonInclude等 Jackson 注解jsonschema-module-jakarta-validation:理解@NotNull、@NotBlank(映射为required)、@Pattern、@Size等 Bean Validation 注解
给 MarketplaceApi.ProductResponse 生成 JSON Schema 的代码:
SchemaGenerator generator = new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
.with(new JacksonModule(JacksonOption.RESPECT_JSONPROPERTY_REQUIRED))
.with(new JakartaValidationModule(INCLUDE_PATTERN_EXPRESSIONS))
.without(Option.SCHEMA_VERSION_INDICATOR)
.build()
.createSchemaGenerator();
JsonNode schema = generator.generateSchema(MarketplaceApi.ProductResponse.class);
生成的 JSON Schema 示例:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"sellerId": { "type": "string" },
"name": { "type": "string" },
"price": { "type": "number" },
"inventory": { "type": "integer" },
"published": { "type": "boolean" },
"status": { "type": "string" },
"reviewCount": { "type": "integer" }
},
"required": ["sellerId", "name", "price", "inventory"]
}
把这个文件提交到 git(src/test/resources/contract-schemas/MarketplaceApi.ProductResponse.json),之后每次 mvn test 都重新生成,和快照对比:
class MarketplaceApiSchemaTest {
@Test
void productResponse_schemaMatchesSnapshot() {
ContractSchemaSnapshot.assertMatchesSnapshot(
MarketplaceApi.ProductResponse.class,
"contract-schemas/MarketplaceApi.ProductResponse.json");
}
}
比对失败时,测试报错并展示 diff,开发者需要明确决定:是回滚变更,还是更新快照(更新快照意味着承认这是一次有意识的 contract 变更,需要同步通知消费者)。
Q:快照文件怎么更新?breaking change 和 additive change 应该怎么区分对待?
快照更新的入口:
# 删除快照文件后重新运行,或者用系统属性
./mvnw -pl shared/shop-contracts -am test -DupdateSchemas=true
ContractSchemaSnapshot.assertMatchesSnapshot 在实现里检测这个属性:如果是 updateSchemas=true,就把生成的 schema 写入文件而不是和快照对比。
Additive change(加字段、加可选约束):消费者已经有 @JsonIgnoreProperties(ignoreUnknown=true),旧消费者能透明忽略新字段。更新快照,记录在 commit message 里,不需要特别的消费者迁移。
Breaking change(删字段、重命名、加 required 约束):最好先确认所有消费者已经能处理新结构,再更新快照。步骤:
- 消费者先发布一个版本,能兼容新旧两种结构
- 生产者更新快照,发布新版本
- 消费者再发布,去掉对旧结构的兼容代码
快照文件本身是标准 JSON,git diff 直观可读——代码审查时一眼能看出删了哪个字段、哪个字段变成了 required。
Q:这套方案需要什么 Maven 依赖?加在哪里?
加到 shared/shop-contracts/pom.xml(父 pom),所有子 contract 模块自动继承,test scope,不影响生产打包:
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>4.36.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
<version>4.36.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jakarta-validation</artifactId>
<version>4.36.0</version>
<scope>test</scope>
</dependency>
快照文件的目录约定:
shared/shop-contracts/shop-contracts-marketplace/
src/
main/java/.../MarketplaceApi.java
test/resources/contract-schemas/
MarketplaceApi.ProductResponse.json
MarketplaceApi.UpsertProductRequest.json
test/java/.../MarketplaceApiSchemaTest.java
四、Kafka 事件 contract#
Q:Kafka 事件的兼容性和 HTTP 接口有什么本质区别?
HTTP 是同步请求-响应:producer(服务端)和 consumer(客户端)同时在线,接口不兼容立刻报错,出错的位置和时间都清晰。
Kafka 是异步消息:消息在 broker 里等待消费,producer 和 consumer 可以独立部署,版本可能相差很多。常见场景:
- Producer 升级了事件 schema,consumer 还是旧版本,消息已经在 topic 里
- Consumer 重启回放历史消息,遇到了几个月前的旧格式
- 多个不同版本的 consumer 同时消费同一个 topic
这些场景在 HTTP 接口里很少发生,但在 Kafka 里是常态。所以事件 contract 需要明确定义向后兼容(backward compat)和向前兼容(forward compat)的边界,而不是像 HTTP 那样可以让 consumer 和 producer 原子升级。
Q:@JsonIgnoreProperties(ignoreUnknown = true) 解决了什么问题?解决不了什么?
这个注解解决的是向前兼容(forward compat):consumer 运行旧版本,producer 已经在事件里加了新字段,旧 consumer 遇到这些未知字段时静默忽略,不抛异常,不进 DLT。
shop 项目里所有事件 DTO 都带了这个注解,ArchUnit COMPAT-02 规则强制执行:
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderEventData(
String orderId,
String buyerId,
String status,
BigDecimal totalAmount,
List<OrderItemSummary> items
) {
@JsonIgnoreProperties(ignoreUnknown = true) // 嵌套 record 也需要
public record OrderItemSummary(...) {}
}
它解决不了的:
- Producer 删除了一个字段:consumer 期望这个字段存在(比如用
data.totalAmount()做积分计算),得到null,可能触发 NPE 或业务错误 - Producer 改变了字段的类型(
String status→ 枚举OrderStatus status,序列化格式发生变化):consumer 反序列化失败 - Producer 改变了字段名(不带
@JsonProperty向前兼容的情况下):consumer 拿到null
Q:EventEnvelope.CURRENT_SCHEMA_VERSION 这个全局常量有什么问题?
shop 项目现在所有事件 listener 都写:
envelope.assertSupportedSchema(EventEnvelope.CURRENT_SCHEMA_VERSION);
// CURRENT_SCHEMA_VERSION = 1
这个全局常量的问题是:不同事件类型的演进速度不同,但版本号被捆绑在一起。OrderEventData 可能需要升级到 v2,但 WalletTransactionEventData 还是 v1——全局常量无法表达这个差异。
如果有人把 CURRENT_SCHEMA_VERSION 从 1 改成 2(因为某个事件需要),所有其他事件的所有 listener 里的 assertSupportedSchema(CURRENT_SCHEMA_VERSION) 也跟着变成了 assertSupportedSchema(2),它们可能对 v2 根本没有任何处理逻辑,只是编译通过了。
Q:应该怎么正确处理事件的 schemaVersion?
在每个事件 DTO 上声明自己的 SCHEMA_VERSION 常量,和全局 CURRENT_SCHEMA_VERSION 解耦:
@JsonIgnoreProperties(ignoreUnknown = true)
public record OrderEventData(
String orderId,
String buyerId,
String status,
BigDecimal totalAmount,
List<OrderItemSummary> items
) {
public static final int SCHEMA_VERSION = 1; // 独立于全局版本
}
Consumer 绑定到具体事件类型的版本:
// loyalty-service OrderEventListener.java
envelope.assertSupportedSchema(OrderEventData.SCHEMA_VERSION);
当 OrderEventData 需要演进时(比如加了 String sellerNote 但这是 breaking,需要 v2):
// 过渡期:consumer 先升级,同时接受 v1 和 v2
envelope.assertSupportedSchema(1, 2);
演进顺序(“consumer first” 协议):
- Consumer 发布新版本,
assertSupportedSchema(1, 2)— 能接受 v1 和 v2 - Producer 把
OrderEventData.SCHEMA_VERSION改为 2,开始发 v2 事件 - Broker 里的 v1 历史消息被消费完之后,consumer 可以把
assertSupportedSchema(2)去掉 v1
按这个顺序演进,可以尽量避免出现“broker 里有消息,但没有 consumer 能处理”的窗口。
Q:事件 DTO 的 JSON Schema 快照和 HTTP contract DTO 的处理方式一样吗?
处理方式基本一样,这是两条路线可以复用同一套门禁的优势。victools 生成 OrderEventData 的 JSON Schema,提交快照,测试时对比:
// shop-contracts-order: OrderEventSchemaTest.java
@Test
void orderEventData_schemaMatchesSnapshot() {
ContractSchemaSnapshot.assertMatchesSnapshot(
OrderEventData.class,
"contract-schemas/OrderEventData.json");
}
快照文件记录了当前事件 DTO 的完整 JSON 结构,包括所有嵌套类型(OrderItemSummary)。任何字段被删除、类型被改变、@JsonIgnoreProperties 被移除,都会导致快照对比失败,开发者需要明确地决定如何处理。
两类 contract 的门禁总结:
| contract 类型 | 二进制层(japicmp) | JSON 层(JSON Schema 快照) | HTTP 协议层(oasdiff) |
|---|---|---|---|
| BFF 对外 API | — | — | ✅ |
| BFF→MS / MS→MS HTTP | ✅ | ✅ | — |
| Kafka 事件 | ✅ | ✅ | — |
japicmp 和 JSON Schema 快照是互补关系:japicmp 先拦截字节码级别的显式 breaking change,JSON Schema 快照补上 Jackson 注解层面的隐式 breaking change。
五、其他兼容性问题(后续文章)#
上面讨论的是interface contract 层面的兼容性——API 和事件的数据结构。微服务里还有几类兼容性问题同样重要,但需要单独讨论:
数据库 schema 的滚动部署兼容。Flyway migration 直接做 RENAME COLUMN 在滚动发布期间是危险的——新旧 Pod 并存时两者读同一张表,一边认旧列名,一边认新列名。正确做法是 expand-contract 三步迁移:先加新列 + 回填,部署新代码,再删旧列。这是 K8s 滚动部署下数据库变更的基本原则。
内部协议 contract(JWT claims → TrustedHeaders)。auth-server 生成 JWT 时写入 principalId、username、roles、portal 等 claim,api-gateway 的 TrustedHeadersFilter 读取这些 claim 并注入 X-Buyer-Id、X-Username 等 trusted header,所有 domain service 从 trusted header 里读取身份信息。这三层之间的命名约定是一个隐式协议,目前没有端到端的 contract testing 守护它。
@ConfigurationProperties 键名兼容。AuthProperties.issuer → Spring 属性 shop.auth.issuer → K8s env var SHOP_AUTH_ISSUER。把 record 字段名从 issuer 改成 tokenIssuer 是 Java 层面的合法重命名,但会导致服务启动时找不到配置而静默失败。这类变更不会触发任何现有的 CI 门禁。
可观测性 contract(metric 名和 tag 名)。DownstreamServiceObservationConvention 引入了 downstream.service tag,Grafana dashboard 的 PromQL 直接引用这个 tag 名。改了 tag 名,dashboard 静默失效,alert 停止触发,没有任何编译错误或测试失败。
小结#
本文的核心结论可以用三句话概括:
-
BFF 对外用 oasdiff:spec 文件是 source of truth,oasdiff 在 CI 里对比 main 分支和当前分支的 YAML,捕获 HTTP 协议层面的 breaking change(japicmp 在这里没有用武之地)。
-
内部 code-first 用 JSON Schema 快照:japicmp 守字节码层,
victools/jsonschema-generator守 Jackson 注解层,两者互补——前者快(mvn test阶段),后者补上@JsonProperty重命名、@NotNull新增这类 japicmp 看不见的 JSON 级别破坏。 -
Kafka 事件和 code-first HTTP 同等对待:
@JsonIgnoreProperties(ignoreUnknown=true)提供 additive change 的容错;JSON Schema 快照检测 producer 侧的 breaking change;per-eventSCHEMA_VERSION配合"consumer first"升级顺序保证 broker 里的历史消息总有人能处理。
参考资料#
OpenAPI breaking change 检测
- oasdiff — OpenAPI Breaking Change Detection
- oasdiff GitHub Action
- Using oasdiff to Detect Breaking Changes in APIs — Nordic APIs
- springdoc-openapi-maven-plugin
JSON Schema 生成
- victools/jsonschema-generator — GitHub
- victools 文档:Jackson Module
- victools 文档:Jakarta Validation Module
Kafka / 事件 schema 演进
- Schema Evolution and Compatibility — Confluent Docs
- Event Versioning Strategies for Event-Driven Architectures — theburningmonk.com (2025-04)
系列相关文章