2363 字
约 7 分钟
18
S07-A14 · 接口版本化:三种做法与那个不可逆的决定

一个内部 API 要改返回结构:原来返回数组,改成返回带分页信息的对象。团队选了"在 URL 上加 /v2",理由是"清晰"。半年后有了 v3,再半年有人问"v1 还有谁在用"——没人答得出,因为 v1 的调用方从来没被统计过,而它还在被一个两年前没人维护的脚本调用。

这也决定了版本化的本质:它不是技术方案,而是一份承诺的期限。加版本号的那一天起,就默认承诺"这个版本会在一段时间内保持可用",而这个期限没人写下来,于是它变成无限。

版本化的难点不在"怎么加版本号",而在什么时候可以去掉旧版本。而能不能去掉,取决于你知不知道谁在用。


1. 三种常见做法

做法 形态
URL 路径版本 /api/v2/orders
请求头版本 Accept: application/vnd.x.v2+json
查询参数版本 /api/orders?version=2

三种做法还能混用,而混用是最坏的情况:版本既在路径里又在头里,于是"哪个优先"成了每次评审都要重新讨论的问题。选一种,并且在网关层统一强制。

第一种最流行,因为它可见、可缓存、易调试;代价是URL 与版本绑死,任何跨版本的逻辑都要在路由层分流。

第二种最"语义正确",因为版本属于表示的协商;代价是不可见、不易手工调试,且中间层(CDN、网关)可能剥掉自定义头。

第三种最少用,主要问题是版本混在业务参数里,容易被漏传或默认成错的值。

2. 向后兼容的五条纪律

纪律 违反后果
只加字段不删字段 老客户端解析失败
不改字段语义 静默错误
不改字段类型 解析失败或精度丢失
不改枚举含义 逻辑错但无报错
默认值不变 行为悄悄变化

第三项最危险的是"看起来无害"的改动:int 改 long、时间戳从秒改毫秒都会让老客户端算出离谱的值,而服务端一切正常。

第四项更隐蔽:同一个枚举码新增了含义(如 3 从"已取消"变成"待审核"),老客户端会按旧含义处理,不报错,只是做错。

// 兼容性的做法:新增并行字段,而不是改旧字段
// v1: {"status": 3}           3=已取消
// v2: {"status": 3, "statusCode": "PENDING_REVIEW"}
// 迁移期两字段并存;老客户端继续读 status(保持旧语义)
// 新客户端读 statusCode;等旧客户端清零后,再考虑让 status 跟随新语义

3. 那个不可逆的决定

决定 可逆性
加版本 可逆(可以不再增加版本)
弃用某版本 依赖调用方是否可知
修改旧版本行为 不可逆(有客户端已适配)

弃用流程里最容易被跳过的是"通知已知调用方":公告发在文档里,只有主动查看的人才知道。有效的做法是逐一向已知调用方发出带日期的通知,并要求回执——回执本身就能确认"这个调用方还在"。

第三项是不可逆的:一旦某个行为被写进某个客户端,改它就是在改契约。这也解释了为什么"我们悄悄改一下,应该没人注意"总是错的——没人注意只说明没人监控,不说明没人用。

// 弃用流程:三步,每步都有可观察的门槛
deprecation:
  announce: "2026-11-10"            # 响应头 + 文档 + 通知已知调用方
  warning_header: "Sunset: 2027-03-01; Deprecation: true"
  usage_threshold: "30 天 0 次调用"  # 达标才进入下一步
  remove_after: "2027-03-01"

4. 怎么知道谁在用

手段 覆盖
访问日志按 UA/来源统计 已知客户端
调用方注册表 内部系统
响应头加弃用标记 主动发现者
客户端主动上报 需客户端配合

区分调用方有个低成本做法:在 API 网关上强制要求来源标识(应用名或调用方 ID),并在日志里落成独立字段。这比事后从 UA 里猜准确得多,且一次投入长期受益。

// 调用方标识的三个要求
1. 必填:缺失即拒绝,避免"匿名调用"存在
2. 稳定:与部署单元解耦(改名不变)
3. 可追溯:能映射到负责人与仓库
// 有了这三条,"谁在用 v1"才是一个可回答的问题

第一项是最可靠的基础手段,但要落地一个前提:能区分调用方。如果所有内部服务都用同一个 token,日志里就只能看到"有人在调"。

第四项看似最弱,其实最能覆盖未知客户端:把弃用信息放进响应头,让任何持久的客户端都可能在日志里留下痕迹。

// 从访问日志里统计 v1 的调用方分布(今天 / 最近 30 天)
awk '$7 ~ /^\/api\/v1\// {print $1}' access.log | sort | uniq -c | sort -rn | head -20
// 用两个时间窗对比:还在增长的调用方需要单独沟通

5. 版本数量与维护成本

版本数 成本形态
1 无兼容负担
2 双份测试与文档
3+ 每增加一个版本,矩阵式增长

第二项容易被低估:双版本意味着两套集成测试、两套监控基线、两份排障经验。而且旧版本的问题往往由不熟悉它的人来处理。

所以"能兼容就不加版本"是更省的选择:加字段、加可选参数、加并行接口,都比加版本便宜。版本应当是"结构性变化"时才动的工具,而不是"每次想改就用"的开关。

// 加版本 vs 加字段:成本对照
加字段:改一处 DTO,老客户端忽略新字段 → 成本 1x
加并行接口:多一个入口,共享内部逻辑 → 成本 2x
加版本:路由、测试、文档、监控、弃用流程 → 成本 5x+
// 结论:能用前两种解决,就不要开新版本

6. 排错:五种"版本化带来的问题"

现象 成因 处理方向
老客户端悄悄失效 改了字段类型/枚举 恢复旧语义,新建字段
无法判断能否下线 调用方不可知 先建可区分的身份
网关剥掉版本头 中间层配置 改用路径版本
两个版本行为不一致 逻辑分叉后各自演进 共享核心逻辑
文档与实现不符 文档未随版本更新 文档从契约生成

缓解方式是"版本只作用于外壳":路由与 DTO 分版本,业务逻辑共享一套。这样修正业务规则时只改一处,版本间不会出现算法差异。

第四项是长期成本的核心:版本分叉后,同一业务规则会有两份实现,修正一处忘另一处,于是"同一笔订单在两个版本里算法不同"。

7. 边界:什么情况不必版本化

场景 为什么 做法
单一内部客户端、可同步升级 无并存需求 直接改
纯新增字段 向后兼容 不加版本
试点/实验接口 无承诺 标注 beta
已明确弃用且无调用 无并存需求 直接删

最后一条与"承诺"有关的边界:版本号一旦发布,就不要回收再用。把 v2 弃用后让 v2 指向新语义,会让所有缓存、文档与旧客户端的判断同时失效,而这类错误没有任何报错。

判断口径一句话:这个改动会不会让已经在跑的客户端做错事。会,就要兼容或版本化;不会(纯新增、无人调用),直接改更快。


8. 自测

  1. 三种做法的核心代价各是什么?为什么请求头版本"语义正确"却不易调试?
  2. 五条兼容纪律里,哪两项会造成"服务端正常但客户端算错"?
  3. 为什么"修改旧版本行为"是不可逆的?"没人注意"说明了什么?
  4. 判断"能否下线一个版本"的前提是什么?为什么这一步常被跳过?
  5. 加字段、加并行接口、加版本的成本比例大约是多少?为什么版本的成本是矩阵式的?
  6. 判断"是否需要版本化"的那个问题是什么?
S07-A14 · 接口版本化:三种做法与那个不可逆的决定
https://www.clxhxhhr.top/posts/6459/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。