版本、向后兼容、SDK 与契约测试:变更怎样被自动阻断
把 OpenAPI 中的 operationId: getOrder 改成 fetchOrder,HTTP 路径、请求参数和响应字段可以全部保持原样。重新生成 Java SDK 后,调用 getOrder() 的业务代码却编译失败。反过来,SDK 编译成功时,服务端也可能已经停止返回消费者依赖的订单 ID。
协议、客户端代码和业务行为分别有自己的兼容要求。发布一次接口变更,需要同时看仍在使用的客户端、准备上线的服务端,以及后续可能恢复的旧版本。
哪些变化会影响已有调用方
把兼容性拆到具体调用关系
同步调用至少包含请求和响应两个方向:客户端产生请求,服务端读取请求;服务端产生响应,客户端读取响应。某项变更在一个方向上放宽限制,在另一个方向上可能增加要求。
| 变更 | 需要检查的已有调用方 |
|---|---|
| 给请求增加必填字段 | 旧客户端未发送该字段,候选服务端能否继续接受 |
| 给请求增加可选字段 | 候选客户端发送后,仍在线的旧服务端如何处理未知字段 |
| 给响应增加字段 | 旧反序列化器是否忽略未知字段;是否有严格对象校验 |
| 从响应移除字段 | 消费者是否读取、解引用或根据它执行业务判断 |
| 扩大响应枚举取值 | 旧 SDK 的枚举映射与 switch 是否能处理新值 |
| 把金额单位从元改成分 | 类型检查可能全通过,实际结算金额仍会改变 |
| 改变列表默认排序 | 翻页、去重和增量扫描是否依赖原排序 |
讨论“向后兼容”时,最好紧接着写明对象,例如“候选服务能接受旧客户端请求”或“新消费者能读取历史事件”。异步事件中的读取方向、保留期和历史数据处理见AsyncAPI 与事件 Schema。
另一个常用划分是变更在哪一层暴露:
源代码兼容:旧业务源码能否用新 SDK 重新编译
二进制兼容:旧业务 class 能否与替换后的 SDK JAR 一起运行
传输兼容:字节、字段、状态和媒体类型能否被另一端理解
行为兼容:相同业务输入是否仍得到约定的业务结果Java 的方法移除或签名改变可能破坏源码或二进制兼容;新增响应字段可能只影响运行时解析;更改默认排序则通常在实际数据处理时出现差异。生成代码之后仍需编译消费者,并用它发出真实请求。
版本号分别标识什么
一个项目可能同时出现 /v1/orders、OpenAPI 的 info.version、SDK 2.3.0 和服务镜像标签。它们描述不同对象,不必采用同一串数字。
| 名称 | 标识对象 | 常见使用方式 |
|---|---|---|
| API 主版本 | 对外提供的一组协议与行为 | 路径、媒体类型,或新 RPC/事件身份 |
| 规范版本 | 某份 OpenAPI、Proto 或 AsyncAPI 文件的修订 | 与发布的源文件及摘要关联 |
| SDK 版本 | 某种语言的客户端制品 | 由包管理器解析依赖和升级 |
| 服务版本 | 当前运行的服务实现 | 关联代码、配置和部署记录 |
使用 Semantic Versioning 的 SDK 需要先定义它的公开 API,再按兼容性区分主版本、次版本和修订版本。已发布版本的内容应保持不变;修复生成器或模板后产生不同内容,应发布新制品。
API 主版本通常用于承接无法维持原语义的变更。增加一个字段未必需要复制整套 /v2 服务;而把“创建成功”改为“仅受理、稍后确认”,即使 JSON 没变,也需要重新安排调用方的处理。
SDK 还包含运行策略
生成器会选择方法名、类型映射、序列化库和 HTTP 实现。超时、重试、连接管理、代理、TLS、错误解析与日志输出,常常还需要应用配置或封装层补充。
更新 SDK 时应检查这些运行策略:读取超时有没有改变;创建订单是否新增自动重试;大文件是否被一次性读进内存;调试日志是否打印凭据;未知枚举是否导致异常。相关参数随语言与生成器不同,Java 客户端的选项可查 OpenAPI Generator Java 文档。
从契约文件生成可测试的客户端
固定生成输入
同一份规范配上不同的生成器、模板和配置,可能生成不同的公开方法与依赖。构建记录至少需要能重新找到以下输入:
契约源文件及所有 $ref
+ 解析器与代码生成器版本
+ 生成配置、模板、补丁
+ 编译器与运行依赖
→ 生成源码 → 编译后的 SDK → 消费者测试结果外部引用可以在发布前收集进固定制品,或锁定为不可变内容;不要让每次构建无条件读取一个会变化的远程文件。摘要可用于比较实际字节,规范化摘要则还需固定规范化算法。SDK 包版本便于选择依赖,内容摘要便于核对下载结果,两者一起保存更容易复现问题。
生成源码可以提交,也可以由构建产生。前一种方式便于审查代码变化,后一种方式减少仓库内重复产物;无论选哪种,都应防止旧生成文件残留。实验使用 clean 清理构建目录后重新生成,不手改 target/generated-sources。
每种测试回答不同问题
| 检查 | 能发现的典型问题 | 还需要补充什么 |
|---|---|---|
| 解析规范与引用 | 语法错误、无效引用、部分规范约束 | 实际工具链是否支持该规范版本 |
| 比较已发布与候选规范 | 路径删除、类型变化、必填项增加 | 业务默认值与隐含排序等行为 |
| 生成并编译 SDK | 模板错误、类型冲突、生成源码不合法 | 旧消费者是否还能编译 |
| 编译真实消费者源码 | 方法改名、参数变化、返回类型变化 | 请求与响应的实际兼容性 |
| 运行消费者调用 | 解析失败、字段依赖、状态与错误分支 | 未覆盖的消费者及业务数据 |
| 集成与回放测试 | 多系统协作、历史载荷和数据迁移问题 | 部署顺序、真实流量和容量 |
契约测试可以围绕消费者真正使用的交互组织。例如订单列表只依赖 ID 和备注,应明确这两个字段的要求,而不把每次随机生成的追踪 ID 固定成必须相等的字符串。
Pact 的消费者测试通过其测试替身记录交互契约,再由生产者验证器向实际生产者发起相应请求。生产者状态用于准备“订单已存在”等前提。只有消费者测试通过、未执行生产者验证时,还不知道真实服务是否满足契约。数据库事务、第三方权限与完整业务流程仍需其他测试覆盖。
保留正在使用的版本组合
候选服务发布前,重点检查旧客户端与候选服务的组合;新客户端发布前,检查它能否调用滚动升级中仍在线的旧服务。移动端、合作方和低频定时任务通常无法与服务端同步升级。
旧服务 候选服务
仍受支持的旧客户端 已部署 发布前验证
准备发布的新客户端 灰度期验证 联合验证矩阵中的每项结果应关联具体消费者和生产者版本。在使用 Pact Broker 时,can-i-deploy 会根据保存的版本关系和验证结果判断目标环境的组合;缺少对应版本的结果不能当作通过。平台只能评价已提交给它的组合,未登记的合作方和离线客户端仍需另行管理。Can I Deploy
实验:同一个接口怎样通过生成又被消费者拒绝
环境与工程
使用 Linux Bash、Docker、unzip,以及有 Docker 权限的普通用户。下载 HTTP 契约工程,解压到新的实验目录。编译和测试都在 Maven 容器内执行,无需在宿主机安装 Java。
LAB=$(mktemp -d -t contract-version.XXXXXX)
unzip contract-http-lab.zip -d "$LAB"
cd "$LAB/contract-http"
docker version
bash run-docker.sh构建镜像固定 maven:3.9.12-eclipse-temurin-17,使用宿主 UID/GID 运行,缓存放在当前工程的 .m2。OpenAPI Generator 固定 7.15.0,Java 客户端使用 native 库。生成客户端、测试服务和消费者测试在同一隔离容器中运行,服务监听随机端口,没有向宿主发布端口。
工程共 13 项测试,其中 ConsumerTest 的两项使用生成的 SDK 发起真实 HTTP 调用。预期 Maven 汇总为:
Tests run: 13, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS依赖下载失败时,先检查镜像仓库、Maven 仓库和已批准的代理配置;编译失败时查看最早一处编译诊断。尚未完成生成和编译,就没有进入消费者运行验证。
新增字段与移除字段
contracts/orders.yaml 声明固定路径 GET /orders/o-1,响应包含 id、note 和 version。测试中的服务有三种响应:
{"id":"o-1","note":"first","version":1}{"id":"o-1","note":"first","version":1,"displayName":"Order one"}{"note":"first","version":1}前两种均通过消费者检查。这里的生成配置允许忽略额外响应字段,所以增加 displayName 没有影响读取 ID 和备注。其他客户端若启用了严格未知字段检查,结果可能不同,应使用该客户端自己的配置复测。
第三种响应缺少消费者需要的 ID。客户端仍能完成 HTTP 解析,但 assertEquals("o-1", order.getId()) 失败。外层测试明确捕获这个断言错误,再恢复完整响应并重新调用,确认 ID 读取恢复正常。这是负例按预期被识别后的测试成功,不代表缺字段的响应可以发布。
改 operationId,观察消费者编译失败
工程另有 contracts/orders-renamed.yaml,只将操作名从 getOrder 改为 fetchOrder。执行:
bash verify-sdk-change.sh脚本先用 renamed-contract Maven profile 重新生成 SDK,并编译原消费者。此阶段应非零退出,renamed.log 中包含真实 Java 编译器诊断:
cannot find symbol
symbol: method getOrder()
location: class example.generated.api.OrdersApi脚本检查失败类型后,恢复原规范并执行完整测试。最终应看到 13 项通过,以及:
operationId rename rejected by real consumer compilation; original SDK restored如果第一阶段生成器下载失败,脚本不会将它当作“成功发现兼容性问题”;日志还必须包含目标方法的编译错误。如果负例没有失败,检查消费者是否真的重新编译、是否仍依赖旧的生成目录,以及候选规范是否被该 profile 选中。
更换 Java 运行环境时,可在同一工程执行:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25 bash run-docker.sh
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25 bash verify-sdk-change.sh编译目标仍为 Java 17。测试要求生成源码、消费者和真实调用均通过;JDK 或依赖打印的运行时警告应按具体库处理,不能只因退出码为零就忽略未来升级风险。
将检查接入项目构建
实际仓库可以先解析和比较规范,再生成客户端、编译代表性消费者,最后启动候选服务运行交互测试。对响应新增字段这类依赖消费者配置的变更,应保存实际反序列化结果;对字段删除等明确破坏,应要求新的协议身份或经过确认的迁移安排。
测试用例要包含错误响应、分页、空值、未知枚举,以及创建类请求的重试策略。共享 HTTP 工程中的条件请求、Schema 和分页实验,分别见REST 契约、OpenAPI 与 JSON Schema和数据类型、分页与错误。
怎样迁移、弃用和恢复
分阶段替换旧字段
假设订单响应从 customerName 改为结构化 customer,可以按以下过程实施:
扩展服务:继续返回 customerName,同时提供 customer
↓
迁移客户端:支持 customer,必要时保留旧字段读取回退
↓
观察使用:确认受支持客户端、低频任务和合作方均完成迁移
↓
停止旧依赖:通知弃用,阻止新客户端继续接入旧字段
↓
收缩服务:在约定条件满足后删除旧字段与兼容代码两个字段在并存期间应来自同一业务数据,明确空值与转换规则。若数据库也在变更,先让新旧服务都能读写兼容结构,再安排数据回填。不要在旧实例尚未退出时删除它必须读取的列。
客户端版本统计可以帮助定位迁移进度,但来自请求头的版本信息可能缺失或不可信。授权仍应依据认证身份;低频任务还需要联系维护者或执行验证,不能仅因短时间未出现流量就认定停用。
弃用通知与停止服务
HTTP 的 Deprecation 响应字段通知资源已弃用或何时开始弃用,配合 Link 的 deprecation 关系可指向迁移说明。它采用 Structured Field Date,值为 @ 后接 Unix 秒数;旧的任意布尔值写法不能直接当作当前标准格式。RFC 9745
Sunset 则表达资源预计停止响应的时间,采用 HTTP-date 格式,与 Deprecation 的编码不同。两者同时提供时,停止时间不应早于弃用时间。发布通知之后,仍需保留替代入口、支持窗口和具体迁移步骤。RFC 8594
路径主版本适合让两套不兼容的资源协议并行运行;媒体类型版本依赖内容协商,还要正确配置缓存;RPC 可使用新服务或新方法;事件可采用新事件类型。选择哪一种,应与网关、客户端、文档入口和运行工具的实际能力一致。
失败发生在哪一步,就从哪一层恢复
| 现象 | 先检查 | 恢复动作 |
|---|---|---|
| SDK 自身无法编译 | 生成器、模板、类型映射、运行依赖 | 恢复已知可用的生成输入,修正后发布新制品 |
| 旧消费者编译失败 | 方法名、签名、包名与可见类型 | 恢复兼容方法,或按主版本迁移消费者 |
| 调用成功但业务字段为空 | 原始响应、反序列化配置、消费者断言 | 恢复字段或增加明确转换,验证业务结果 |
| 灰度时偶发未知字段或路径错误 | 请求实际命中的服务版本 | 调整部署顺序,保持新客户端可调用旧实例 |
| 回滚后无法读取数据或事件 | 已写入的新数据和已发布事件 | 保留读取兼容与转换能力,必要时前向修复 |
恢复旧服务镜像之前,需要确认它仍能读取当前数据。新字段已经写入、旧列已经删除,或者新事件已经进入长期归档时,单独替换二进制可能再次失败。
保留旧 SDK、规范、生成配置和服务制品,可以重现旧组合;保留数据读取兼容和历史事件处理,才能让恢复后的程序继续工作。迁移收尾时再删除兼容分支,避免每个版本留下永久并行的实现。
权威资料与规范地址
版本编号、代码生成、消费者验证与 HTTP 弃用通知可分别查阅:
版本编号与接口弃用
- Semantic Versioning:https://semver.org/
- Deprecation HTTP 字段:https://www.rfc-editor.org/rfc/rfc9745.html
- Sunset HTTP 字段:https://www.rfc-editor.org/rfc/rfc8594.html
客户端生成与消费者契约验证
- OpenAPI Generator Java 客户端:https://openapi-generator.tech/docs/generators/java/
- Pact 的工作方式:https://docs.pact.io/getting_started/how_pact_works
- Pact Broker Can I Deploy:https://docs.pact.io/pact_broker/can_i_deploy
