小Cの已经记不起来的博客

RESTful API 版本控制的几种实现方式与维护成本

前段时间重构公司内部的一个老 API,本来以为就是改改路由、换换参数的事儿,结果一翻代码发现历史包袱比想象中重:有的接口走 /v1/xxx,有的靠请求头里塞 Accept: application/vnd.xxx.v2+json,还有几个诡异的接口用 ?version=3 区分。更离谱的是,同一个功能三套代码在跑,修个 bug 得改三个地方。那几天我一边改一边想,版本控制这玩意儿,设计的时候觉得随便选一个就行,真到维护阶段,每一种方式的成本差距还是挺大的。

下面就把我实际用过、或者被坑过的几种方式列一下,顺便聊聊维护成本。不是教科书式的对比,就是一些踩坑后的个人感受。

URL 路径版本:/v1/users

这是最常见的一种,也是我个人最愿意用的。实现起来最简单,Spring Boot 里直接:

@RestController
@RequestMapping("/v1/users")
public class UserV1Controller { ... }

或者 Express:

app.use('/v1/users', v1UserRouter);
app.use('/v2/users', v2UserRouter);

网关层也很好配,Nginx 直接把 /v1/ 和 /v2/ 转发到不同的 upstream,甚至可以让两个版本跑在不同的服务实例上。客户端调用也直观,看 URL 就知道用的哪个版本。

但它的维护成本不在于技术实现,而在于代码组织。如果团队不克制,很容易变成 v1、v2、v3 各一套 controller,里面 80% 的代码是复制粘贴。改一个公共逻辑,得挨个版本改。我的做法是尽量让版本只影响路由和 DTO,业务逻辑抽到 service 层共用,但这也需要团队有意识地去推动。另一个成本是 URL 会变长,以及 REST 原教旨主义者会说“URL 里不应该带版本号”,不过说实话,生产环境里能跑、能维护比语义纯洁重要得多。

请求头版本:Accept: application/vnd.myapp.v1+json

这种方式的优点是 URL 干净,版本信息藏在 HTTP 头里,看起来更“RESTful”。Spring Boot 里可以这样:

@GetMapping(value = "/users", produces = "application/vnd.myapp.v1+json")
public List<UserV1> getUsersV1() { ... }

@GetMapping(value = "/users", produces = "application/vnd.myapp.v2+json")
public List<UserV2> getUsersV2() { ... }

理论很美好,实际用起来最烦的是调试。用 curl 测试得加 -H "Accept: application/vnd.myapp.v2+json",浏览器直接打开 URL 看不到效果。前端同学经常跑来问“为什么我调的是 v1 还是 v2”,因为 URL 上看不出来。网关配置也麻烦一些,Kong 或者 Nginx 要根据 header 做路由,规则写不好容易出幺蛾子。维护成本中等偏高:代码上还算干净,但排查问题和沟通成本明显上升。我们后来只在少数对 URL 有洁癖的公开 API 上用,内部服务一律路径版本。

查询参数版本:?version=1

这个方式实现最省事,一个拦截器或者中间件就能搞定:

app.use((req, res, next) => {
  req.apiVersion = req.query.version || '1';
  next();
});

但它是所有方式里最容易被滥用的。问题是缓存和 CDN 不友好,同一个 URL 因为 query 参数不同而返回不同内容,很多缓存层默认不区分 query,或者区分得不彻底。另外版本参数会跟业务参数混在一起,文档里写起来也乱。维护成本看起来低,实际上后期会变成“每个接口都带个 version 参数,但没人记得默认版本是哪个”。我现在的态度是:除非是临时灰度或者内部工具,否则别用。

自定义媒体类型 / Content Negotiation

这个跟请求头版本有点像,但更彻底,通过 Accept 头里的 MIME type 区分版本,比如 application/vnd.myapp.v2+json。好处是版本和资源表示绑定,理论上最符合 HTTP 语义。坏处是客户端支持成本高,很多 HTTP 客户端库对自定义媒体类型支持一般,文档写起来也费劲。维护成本最高的是网关和监控:日志里全是 Accept 头,想统计每个版本的调用量得额外解析,APM 工具也不一定直接支持。我用过一次,后来放弃了,因为团队里没人愿意天天记 MIME type。

子域名版本:api.v1.example.com

这种方式在大型系统里见过,优点隔离彻底,不同版本可以完全独立部署、独立扩容,甚至用不同的技术栈。DNS 和证书管理是额外成本,但用通配符证书也能接受。维护成本主要在运维侧:每加一个版本就多一组域名、证书、网关配置和监控。小团队基本不用考虑,大团队如果版本迭代频繁,反而觉得省心。我待过的一家公司用这个,好处是回滚直接切 DNS,坏处是本地开发得改 hosts,新人上手第一周基本都在配环境。

不版本化,持续演进

还有一种“无版本”方案,靠严格的向后兼容来演进,加字段可以,删字段和改语义不行。GraphQL 社区比较推崇这个,REST 里也有团队这么干。维护成本其实最高,因为要求每次改动都做兼容性评审,测试用例要覆盖旧客户端的行为。但好处是客户端永远不用改 URL。适合那种客户端版本不可控的场景,比如开放平台。我们内部 API 不敢这么玩,因为总有人会直接改字段名。

最后说点实在的

维护成本这东西,不光是代码量,还包括沟通、调试、监控和新人上手。URL 路径版本虽然被一些人嫌弃,但它把版本信息放在最显眼的地方,排查问题的时候真的省心。请求头和媒体类型版本看起来优雅,但把复杂度转移到了工具链和团队习惯上。查询参数版本能不碰就不碰。子域名版本适合有运维资源的团队。无版本演进适合纪律性极强的团队。

反正我现在是能不加版本就不加,实在要加就老老实实走路径版本,并且把版本相关的代码隔离在路由和 DTO 层,业务逻辑尽量共用。毕竟,版本控制的目的不是炫技,是让系统在变化的时候别把自己绊倒。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放