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. 自测

  1. 哪些变更看起来兼容、实际上会破坏调用方?
  2. 用请求头做版本时,缓存层最容易出什么问题?
  3. 服务端给枚举加一个值,什么情况下会导致消费方失败?
  4. 下线一个旧版本接口,判断「可以删了」的依据是什么?
  5. 为什么错误码只增不改,而 HTTP 状态码可以调整映射?
  6. 内部接口和第三方接口在版本策略上最关键的区别是什么?
S18-X14 · 接口版本与兼容性设计
https://www.clxhxhhr.top/posts/5864/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。