2611 字
约 8 分钟
13
S18-X14 · 接口版本与兼容性设计
接口兼容性不是「加个 /v2」就解决的事。真正的分界线只有一条:已发布的调用方在你不通知、不配合的情况下,是否会因为你的改动而失败。会,就是破坏性变更;不会,就不需要版本。绝大多数「必须升版本」的争论,本质上是对这条线没有共识。
1. 什么算破坏性变更
| 变更 | 是否破坏 | 理由 |
|---|---|---|
| 删除字段 | 是 | 读取方解析或取值失败 |
改字段类型(number → string) |
是 | 强类型客户端直接抛异常 |
改字段语义(amount 从元变分) |
是 | 无异常,但数据全错,最危险 |
| 新增必填参数 | 是 | 老调用方请求被拒 |
改默认值(pageSize 10 → 20) |
是 | 分页边界、下游配额与统计口径全变 |
| 改错误码含义 | 是 | 调用方的错误分支走错路径 |
| 新增可选字段 | 否 | 读取方忽略未知字段即可 |
| 新增接口 | 否 | 无调用方依赖 |
放宽取值(string 枚举 → 任意 string) |
否 | 校验变松 |
| 收紧取值 | 是 | 原本能过的请求被拒 |
| 提高响应耗时/降低限流阈值 | 是(事实性) | 契约没变,行为变了 |
最后一行是常被忽略的一类:性能与配额也是契约的一部分。
2. 版本化的三种放法
| 放法 | 示例 | 缓存 | 日志与埋点 | 网关路由 |
|---|---|---|---|---|
| URL 路径 | GET /api/v1/orders |
天然区分,CDN 规则简单 | 一眼可见,统计直接按前缀切 | 前缀路由,最省事 |
| 请求头 | X-Api-Version: 1 |
必须加 Vary,否则缓存串味 |
需显式记录,容易漏 | 按 header 分流 |
| 媒体类型 | Accept: application/vnd.api+json;v=1 |
需正确配置 Vary: Accept |
日志里最不直观 | 配置最复杂 |
结论:内部服务用 URL 前缀,成本最低、可观测性最好;只有对公共开放平台、且语义上要求「同一资源多种表示」时,才值得用媒体类型。
// 路径版本:新版本另起一个 Controller,老版本保留
@RestController
@RequestMapping("/api/v1/orders")
public class OrderV1Controller {
@GetMapping("/{id}")
public OrderV1Response get(@PathVariable Long id) { /* 老契约,冻结 */ }
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderV2Controller {
@GetMapping("/{id}")
public OrderV2Response get(@PathVariable Long id) { /* 新契约 */ }
}
// 请求头版本:在同一映射下按 header 分派,注意显式声明 params/headers 条件
@GetMapping(value = "/orders/{id}", headers = "X-Api-Version=2")
public OrderV2Response getV2(@PathVariable Long id) { return v2Service.detail(id); }
@GetMapping(value = "/orders/{id}", headers = "!X-Api-Version")
public OrderV1Response getV1Default(@PathVariable Long id) { return v1Service.detail(id); }
只在 URL 里加 v2 但代码里仍走同一套 Service,是典型的假版本化:契约变了,实现没隔离,回归范围还是全量。
3. 字段级兼容的做法
| 操作 | 做法 | 落地要求 |
|---|---|---|
| 加字段 | 只加可选字段,给默认值 | 客户端必须忽略未知字段 |
| 删字段 | 先标 @Deprecated 并保留,观察调用量后再删 |
需要字段级使用埋点 |
| 改类型 | 新增新字段并存,老字段继续返回一段时间 | 双写过渡期至少覆盖一个发布周期 |
| 改必填 | 新增参数一律可选,服务端给默认值 | 校验规则放宽不收紧 |
public class OrderDetailResponse {
private String orderNo;
/** @deprecated 自 2026-06 起改用 amountFen,计划 2027-01 移除 */
@Deprecated(since = "2026-06", forRemoval = true)
@JsonProperty("amount")
private BigDecimal amount;
@JsonProperty("amountFen")
private Long amountFen;
}
@JsonInclude(JsonInclude.Include.NON_NULL) 之类的全局配置要谨慎:它会让「字段缺失」和「字段为 null」在报文里变得不可区分,正文会因此产生两套语义。
4. 枚举新增值的兼容风险
服务端给枚举加一个成员是「向后兼容」的,但消费方不一定兼容:
// 有默认分支:新枚举值落到 UNKNOWN,不抛异常
public OrderStatus map(String raw) {
try {
return OrderStatus.valueOf(raw);
} catch (IllegalArgumentException e) {
log.warn("未知订单状态,按待处理处理: {}", raw);
return OrderStatus.UNKNOWN; // 必须存在这个兜底值
}
}
| 消费方式 | 遇到新枚举值 | 结果 |
|---|---|---|
switch 有 default |
走默认分支 | 安全 |
switch 无 default(旧式 Java 或 Kotlin 非穷尽) |
落到末尾或抛异常 | 危险 |
Enum.valueOf() 无 try-catch |
抛 IllegalArgumentException |
危险 |
存库为 ENUM 列 |
插入失败 | 危险 |
结论:新增枚举值必须先确认所有消费方有兜底分支;无法确认时,用字符串常量 + 白名单校验替代强枚举。
5. 分页、排序与错误响应的契约
5.1 分页契约
| 变化 | 兼容性 | 处理 |
|---|---|---|
| 页码从 0 起改 1 起 | 破坏 | 只能在新版本改 |
默认 size 变化 |
破坏 | 视为新增参数 |
响应里加 total |
兼容 | 但会让深分页变慢,需评估成本 |
| 从 offset 分页改游标分页 | 破坏 | 游标参数名与语义都不同,走新版本 |
public record PageResponse<T>(List<T> items, String nextCursor, boolean hasMore) {}
5.2 错误响应契约
public enum ErrorCode {
PARAM_INVALID, // 400
UNAUTHORIZED, // 401
FORBIDDEN, // 403
NOT_FOUND, // 404
CONFLICT, // 409
RATE_LIMITED, // 429
INTERNAL_ERROR; // 500,禁止细分内部原因
}
| 规则 | 说明 |
|---|---|
| 错误码只增不改 | 改了含义等于改了所有调用方的分支 |
| HTTP 状态码与业务错误码职责分离 | 状态码给通用语义,错误码给业务语义 |
| 不暴露内部异常信息 | SQLIntegrityConstraintViolationException 原文进不了公开契约 |
| 错误体字段稳定 | code/message/traceId 三个字段的位置不能变 |
6. 下线旧版本的流程
| 阶段 | 动作 | 通过条件 |
|---|---|---|
| 1 埋点 | 按 path + 版本 + 调用方标识统计调用量 |
有完整的调用方清单 |
| 2 通知 | 公告 + 逐个通知到负责人 | 有回执 |
| 3 双写过渡 | 新老版本并行运行,新功能只上新版 | 老版调用量持续下降 |
| 4 灰度断开 | 按调用方灰度返回 410 Gone |
无新增投诉 |
| 5 删除 | 移除代码、路由、测试与文档 | 保留一个可回滚的发布版本 |
// 阶段 4:老版本返回 410,并在响应头给出迁移信息
@GetMapping("/api/v1/orders/{id}")
public ResponseEntity<Void> gone(@PathVariable Long id) {
return ResponseEntity.status(HttpStatus.GONE)
.header("Deprecation", "true")
.header("Link", "</api/v2/orders/" + id + ">; rel=\"successor-version\"")
.build();
}
埋点不要只记在应用日志里——网关日志(如 nginx 的 $request_uri)通常更完整:
# 按版本前缀统计调用量,用于判断可否下线
awk '{print $7}' access.log | grep -oE '^/api/v[0-9]+' | sort | uniq -c | sort -rn
7. 内部与第三方的差异
| 维度 | 内部调用方 | 第三方调用方 |
|---|---|---|
| 协调成本 | 可一起发版 | 无法协调 |
| 版本策略 | 可短窗口双版本 | 必须长期多版本并存 |
| 破坏性变更 | 可协商一次性切换 | 只能等调用方自然流失 |
| 文档 | 可内部约定 | 必须有公开 changelog 与废弃时间表 |
7.1 OpenAPI 契约与契约测试
# openapi.yaml 片段:显式声明新增字段为可选
components:
schemas:
OrderDetail:
required: [orderNo, status, amountFen]
properties:
amountFen: { type: integer, format: int64 }
refundReason: { type: string, nullable: true }
// 契约测试:用已冻结的旧响应快照校验兼容性
@Test
void response_isBackwardCompatible_with_frozenSchema() throws Exception {
String actual = mvc.perform(get("/api/v2/orders/1")).andReturn().getResponse().getContentAsString();
JsonAssert.assertEquals(readFixture("order-detail-v1-frozen.json"), actual,
JsonAssert.EXACT_MODE ? JSONCompareMode.NON_EXTENSIBLE : JSONCompareMode.LENIENT);
}
用「宽松比较」校验兼容性(允许新增字段、不允许字段消失或类型变化),比人工 review 可靠。
8. 排错:六种典型的不兼容事故
| 现象 | 根因 | 处理 |
|---|---|---|
| 老客户端突然解析失败 | 删除或重命名了字段 | 恢复字段并标废弃,走下线流程 |
| 数据算错但无异常 | 字段语义悄悄改了(元→分) | 新增字段而非改语义,做双写校验 |
| 新版一切正常,第三方全线报错 | 新增了必填参数 | 参数改可选,服务端补默认值 |
| 限流与分页口径不一致 | 改了默认 size |
默认值视为契约,改动走新版本 |
| 缓存里混着 v1/v2 响应 | 用 header 版本但没配 Vary |
补 Vary 或改用路径版本 |
| 同一接口日期时区混用 | 有的字段是 ISO-8601 UTC,有的是本地时间 | 全站统一 UTC + ISO-8601,文档标注 |
9. 边界:不是所有接口都值得版本化
| 场景 | 建议 |
|---|---|
| 内部 Admin 后台、只有自家前端 | 直接改,前端同发版,不必开版本 |
| 大规模第三方开放平台 | 必须版本化 + 长期废弃计划 |
| 只读统计接口 | 变更多为新增字段,通常不需要版本 |
| 灰度实验中的接口 | 用开关(feature flag)而非版本号 |
| 强合规场景 | 版本 + 变更审计 + 冻结 schema |
一句话:版本号的成本不在写代码,而在长期维护多套契约;能靠字段级兼容解决的,不要开新版本。
10. 自测
- 哪些变更看起来兼容、实际上会破坏调用方?
- 用请求头做版本时,缓存层最容易出什么问题?
- 服务端给枚举加一个值,什么情况下会导致消费方失败?
- 下线一个旧版本接口,判断「可以删了」的依据是什么?
- 为什么错误码只增不改,而 HTTP 状态码可以调整映射?
- 内部接口和第三方接口在版本策略上最关键的区别是什么?
S18-X14 · 接口版本与兼容性设计
https://www.clxhxhhr.top/posts/5864/ 评论
0 条
还没有评论,先写一条吧。