📦 本文基于的完整项目源码: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 值 旧客户端发送了已删除的值
修改字段类型(stringinteger 旧客户端反序列化失败

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-12
  • jsonschema-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 约束):最好先确认所有消费者已经能处理新结构,再更新快照。步骤:

  1. 消费者先发布一个版本,能兼容新旧两种结构
  2. 生产者更新快照,发布新版本
  3. 消费者再发布,去掉对旧结构的兼容代码

快照文件本身是标准 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” 协议):

  1. Consumer 发布新版本,assertSupportedSchema(1, 2) — 能接受 v1 和 v2
  2. Producer 把 OrderEventData.SCHEMA_VERSION 改为 2,开始发 v2 事件
  3. 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 时写入 principalIdusernamerolesportal 等 claim,api-gatewayTrustedHeadersFilter 读取这些 claim 并注入 X-Buyer-IdX-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 停止触发,没有任何编译错误或测试失败。


小结#

本文的核心结论可以用三句话概括:

  1. BFF 对外用 oasdiff:spec 文件是 source of truth,oasdiff 在 CI 里对比 main 分支和当前分支的 YAML,捕获 HTTP 协议层面的 breaking change(japicmp 在这里没有用武之地)。

  2. 内部 code-first 用 JSON Schema 快照:japicmp 守字节码层,victools/jsonschema-generator 守 Jackson 注解层,两者互补——前者快(mvn test 阶段),后者补上 @JsonProperty 重命名、@NotNull 新增这类 japicmp 看不见的 JSON 级别破坏。

  3. Kafka 事件和 code-first HTTP 同等对待@JsonIgnoreProperties(ignoreUnknown=true) 提供 additive change 的容错;JSON Schema 快照检测 producer 侧的 breaking change;per-event SCHEMA_VERSION 配合"consumer first"升级顺序保证 broker 里的历史消息总有人能处理。


参考资料#

OpenAPI breaking change 检测

JSON Schema 生成

Kafka / 事件 schema 演进

系列相关文章