最近接了个项目,接口风格嘛,属于那种“看起来很标准”的 RESTful。一眼就能看出来,当年写这套接口的人是认真查过资料、照着最佳实践抠出来的,结构规整、命名规范,一开始确实让人挺放心。
但很快来了个老熟人式需求:要对其中一个接口做升级,同时还得保留旧版本,继续服务老客户端。
这事儿不稀奇,我当时心里也挺淡定:兼容旧版本而已,稳着来就行。
结果我打开控制器代码,看了一眼,差点没把 IDE 关了。
接口版本乱的不堪入目:
1 | 第一版:GET /v1/orders/{orderNo} |
还有更离谱的,比如:
1 | /v2.1/orders/fast |
你能感受到这些命名是“临时起意”且“版本焦虑”的产物。
每个方法看起来都差不多,但你仔细一看,字段不一样、逻辑不一样、校验不一样,甚至返回结构都小修小补。有写代码明显是 copy 上一版之后“微调”的,几百行里只有两个 if 变了,剩下全是“历史残留”。
我还想了想:那我现在这版,命名成这样如何?
1 | GET /v5/orders/{orderNo} |
听起来也挺对路,跟现有套路保持一致,对吧?问问同事,八成也会说:“嗯,我们一直都是这么搞的。”
但问题是:这玩意根本就不 REST 啊!
更重要的是,你正在造一个维护地狱的入口。
还不知道RESTful的朋友点击阅读这里:
- Architectural Styles and the Design of Network-based Software Architectures: Roy Fielding 博士的博士论文,介绍了 REST 的理论基础和设计原则,是 RESTful 架构风格的奠基之作。较学术,但非常权威。
- REST API Tutorial: 简明扼要,覆盖 RESTful 设计各个方面,适合入门和进阶。
另外,HTTP 规范是 REST 的基石,了解 HTTP/1.1 和 HTTP/2 对 RESTful 设计很有帮助:
1. 问题出在哪?
1 |
|
看起来写代码很快对吧?嗯……但这样你得维护两个 Controller(还有测试、文档……)。
**很多教程和遗留系统都默认用 URL 版本管理。如果你 Google 一下 API 版本管理,URL 版本化肯定是最多的方案。**你看,JIRA的开发文档就是这么做的:
上面我们说到它违背了 REST 原则,那么接着说说为什么会违背REST原则。
问题1:为什么 URL 版本化会破坏 REST?
REST 架构的核心原则之一就是:
每个 URI 都代表一个唯一且稳定的资源标识。
当你把版本号直接放进 URL(如 /v1/orders
,/v2/orders
),实际上在告诉客户端“这是两个不同的资源”,虽然它们逻辑上其实是“同一个资源”的不同版本。
这样做会让 URI 失去稳定性,资源身份模糊,破坏了 REST 的一致性原则。
问题2:客户端和服务器耦合度过高
客户端需要写死请求的具体版本 URL,导致以下问题:
- 代码中硬编码版本号,升级时要改代码。
- 版本迭代频繁时,客户端维护成本大大增加。
- 版本切换变得不灵活,破坏了客户端和服务器的解耦。
问题3:版本增长导致路径臃肿和重复代码
随着版本增多,URL 路径不断堆积:
1 | /v1/orders/{order_no} |
当需要维护多个相似的 Controller、路由、文档和测试时,代码往往会出现大量重复,导致维护变得异常困难;与此同时,过时的版本难以及时废弃,长期积累下来无形中拖累了整个系统的稳定性和开发效率。
问题4:破坏缓存和中间件的优化
缓存服务器或代理一般根据 URL 缓存响应。
如果版本号在 URL 中,每个版本都是不同缓存,难以复用缓存策略,降低性能。
问题5:废弃版本导致客户端无预警的故障
当某版本 URL 被废弃或删除时,客户端请求会返回 404 或错误,用户体验差,且客户端无法优雅降级。
问题6:客户端容易404
当 /v1/orders/{orderNo}
废弃时:
- 使用它的移动端开始返回
404
。 - 用户毫无预警,功能突然失效。
2. 怎么做版本控制更合理?
这里推荐使用请求头做版本控制,可以使用自定义媒体类型的版本控制。
我们先来看看怎么做,好让大家有个直观的概念:
客户端版本协商
客户端通过 Accept
头协商版本,比如,如果要调用v1版本,可以这么发起:
1 | curl -X GET --location 'http://localhost:8080/orders/PAY0101' \ |
如果要调用v2版本,可以这么发起:
1 | curl -X GET --location 'http://localhost:8080/orders/PAY0101' \ |
服务端代码
服务端代码如下:
1 |
|
这样做的好处是:
- URI 永远稳定(一直是
/orders/{order_no}
)。 - 向后兼容更简单(一个接口支持多版本)。
- 缓存自然工作(版本信息在请求头里)。
当然,这么做之后,对接起来也需要注意下客户端必须支持并设置请求头,可能部分客户端工具或类库库使用起来不方便。
实现方法就是这样的,接下来我们用专业的术语来介绍下这种版本控制的方法:基于自定义媒体类型的版本控制
3. 基于自定义媒体类型的版本控制
什么是媒体类型?媒体类型通常是 application/json
、text/xml
你可以在HTTP请求头看到的此类内容。而自定义媒体类型就是你自己定义的。
“基于自定义媒体类型的版本控制”就是把 API 的版本号嵌入在 HTTP 的媒体类型(MIME type)里,通过内容协商(Content Negotiation)来决定后端该使用哪一版逻辑,而不是在 URL 或专用请求头里添加版本号。
为了使用自定义媒体类型做版本控制,首先需要定义一个媒体类型。上面例子中的application/vnd.itzhai.v1+json
就是一个自定义媒体类型。
这里有人就会问了,这么随便自定义一个媒体类型,服务端认不认呀?会不会导致一些服务端框架不支持,导致解析请求报错?
其实这个媒体类型是在在 RFC 6838[1] 规范中做了详细的说明,一般遵守这个规范的各种框架都是支持这么玩的。
关于RFC 6838
RFC 6838 定义了在 HTTP、MIME 及其他 Internet 协议中使用的媒体类型(media types)的规范和注册流程,作为 IETF 的一项最佳当前实践。它概述了媒体类型的四大注册树——标准树(standards)、厂商树(vendor)、个人/专用树(personal)和未注册树(x.)——并规定了子类型命名规则及结构化后缀(如
+json
、+xml
)的使用方式[1:1]。在规范中详细列出了不同顶级类型(text、image、audio、video、application、multipart、message)及其命名要求,以及参数、规范化格式、安全性和互换性建议。
媒体类型的格式如下:
1 | <type>/<tree>.<subtype>+<suffix>[;参数…] |
<type>
:顶级类型,如application
、text
等。<tree>
:子类型命名空间,厂商类型用vnd
。<subtype>
:具体资源名称,可包含厂商名和资源标识。- 可选版本号:通常放在 subtype 里,用点号或连字符分隔,如
resource-v2
或resource.v2
。 +<suffix>
:结构化后缀(Structured Syntax Suffix),如+json
、+xml
,指示底层编码格式。- 参数:可选的键值对,如
; charset=utf-8
。
我们再来看看上面的例子:
这里 vnd.itzhai
是厂商定义的前缀,v2
表示第二版,+json
表示载体格式是 JSON。这下是不是清晰多了。
客户端通过 Accept/Content-Type 头指定版本
-
Accept:告诉服务器,客户端能接受哪一版的响应。
1
Accept: application/vnd.itzhai-v2+json
-
Content-Type:在 POST/PUT 时也可以标明自己发送的是哪个版本的请求体。
服务器端做内容协商
服务端解析 Accept 头,根据版本号路由到对应的处理逻辑或序列化器,最终返回符合该版规范的响应。
如果你有需求,利用不同的媒体类型后缀,还可以并行提供预览功能,如 -beta+json
。
这样做的好处就是标准明确,版本协商清晰,并且支持语义化版本号(如 v1.2.3
)。
当然也有代价:需要客户端文档指导。每次调用都要带对的 Accept/Content-Type,使用不当可能导致误路由。
这么做的好处
平滑升级,逐步废弃
服务器可以在收到旧版本请求时,先返回旧版本数据,并在响应里加提醒(比如自定义 Header 或响应字段),告知客户端版本即将废弃。
同时继续支持新版本客户端请求。
逐步提示客户端升级:通过响应头、返回内容等方式告诉客户端升级版本。
当你废弃旧版本时,通过响应头、返回内容等方式告诉客户端升级版本,而不是直接来个404,让用户一位客户端出bug了。
使用请求头,响应内容变化很大时,不是还是需要多个接口吗?
当不同版本之间响应结构差别很大时,即使用请求头来做版本协商,后端代码中本质上还是会有多套实现逻辑,类似“多个接口”那样存在。
不过,用请求头版本协商和用不同 URL 分版本相比,还是有明显优势:
- 统一入口,路由简单:请求都走同一个 URI(如
/orders/{order_no}
),不需要为每个版本写独立路由。版本判断在代码内部做,路由层更简洁,维护更方便。 - 客户端感知统一:客户端调用同一个接口,只需在请求头切换版本,无需关心不同版本的 URL。便于客户端集成和统一管理。
- 代码复用和拆分:服务端可以针对不同版本封装不同的响应构建逻辑,核心业务逻辑可重用。版本差异大的部分作为“策略”或“适配器”模块拆开,方便维护和迭代。
- 灵活的版本兼容和渐进演进:服务器端可以动态根据请求头选择响应结构,支持老版本和新版本并存。便于渐进式迭代和灰度发布。
为什么这些方案优于 URL 版本化?
- URI 稳定:这个就不多说了,很直观的收益。
- 向后兼容:老版本新版本同接口服务。
- 代码更简洁:无重复 Controller 或路由。
- 符合 REST:符合 Roy Fielding 的 REST 约束。
如果不能避免 URL 版本化,建议只用在以下场景使用:
- 内部 API(你能控制的微服务之间调用)。
- 资源身份改变导致破坏性变更(例如 GDPR 影响的数据结构调整)。
但即便如此,也要多问自己以下:“能不能改成用请求头做版本管理?”
4. 其他厂商怎么做?
我们来看看其他厂商是怎么做URL版本设计的。
Stripe 的 API
Stripe 的 API 版本控制是通过请求头而不是 URL 路径来实现的。参考:Set a Stripe API version[2],每个版本都会有一个变更日志,列出了所有有变更的记录。[3]
也可以看看这篇文章:APIs as infrastructure: future-proofing Stripe with versioning[4],介绍了 Stripe API 版本管理的核心理念和实践:坚持向后兼容,避免破坏性变更(一般项目中加/v1 /v2这种一般也是引入了破坏性的变更导致的妥协方案),每个账号有自己的默认 API 版本。新变更默认不会影响老用户,只有在用户主动升级时才生效。API 版本通过 HTTP 头(Stripe-Version
)而非 URL 控制,保证接口路径稳定,简化了客户端集成和升级流程。
接口响应是定为版本的过程
版本变更的编写方式,是为了能够从当前的 API 版本开始,按时间顺序自动向后应用。每个版本变更模块都假设:尽管它之后可能已经出现了更新的改动,但它所接收的数据格式仍然与它当初编写时保持一致。
当 API 生成响应时,它首先会根据当前版本来格式化资源的数据,然后根据以下三种情况之一,确定目标 API 版本:
- 如果请求中包含
Stripe-Version
请求头,则使用该指定版本; - 如果请求是由授权的 OAuth 应用代表用户发起的,则使用该应用注册时的版本;
- 如果前两者都没有,则使用用户首次访问 Stripe 时绑定的“固定版本”。
执行流程如下图:
(图片来源:APIs as infrastructure: future-proofing Stripe with versioning[4:1])
随后,系统会从当前版本回溯,依次应用每一个相关的版本变更模块,直到“走”到目标版本为止。
API 版本控制说明
所有请求在返回响应之前,都会经过这些版本变更模块的处理。
版本变更模块的作用是将旧版本逻辑从核心代码路径中抽离出来。这样一来,开发者在开发新功能时,基本无需去考虑历史版本的兼容问题。
GitHub 的 API
GitHub使用自定义媒体类型来预览功能
在发布正式变更之前,GitHub 常常先以“预览”形式推出新功能或字段。你需要在请求头里增加一个特殊的 Accept
值才能启用这些预览特性:
1 | Accept: application/vnd.github.+json; format=flowdock-preview |
- 自主 opt-in:只有显式加上预览媒体类型,接口才会返回新字段或行为。
- 稳定期:预览通常持续数月到一年,以便社区试用并反馈。
- 退出预览:预览过后,新的字段会并入正式版本,你可以去掉对应的
Accept
值。
字段弃用与删除策略
GitHub的做法还是挺稳妥的,大家可以学习,下面详细介绍下。
当 GitHub 决定要移除或重命名某个字段时,会遵循以下流程:
-
文档与变更日志预告
在“API Changes”页面和对应版本的 Changelog 中,明确列出将要弃用的字段和预计的移除时间。 -
Deprecation Warning
如果你使用了被弃用的字段,API 响应头里会返回类似:1
Deprecation: version=2023-07-01; production; obsolete=true
这样你可以在日志中自动捕获并修复调用。
-
正式下线
在事先公告的时间点之后(通常至少提前三个月),那些字段会被真正移除或改名,此时旧版本下的调用会返回错误提示。
整个流程最大程度地给出充足的通知和迁移窗口,确保开发者可以无缝升级。
请求时通过请求头制定版本
GitHub 的 REST API 是通过 HTTP 请求头来控制版本的。
版本号以发布日期命名
每个 REST API 版本都对应一个发布日期,例如 2022-11-28
就代表在 2022 年 11 月 28 日发布的版本。任何破坏向后兼容的改动(比如删除或重命名接口、参数、返回字段等)都会以新的发布日期版本发出,而旧版本至少继续支持 24 个月。
在请求里加上 X-GitHub-Api-Version
头,就可以锁定你需要的版本,例如:
1 | curl \ |
如果不指定该头,GitHub 会默认使用最新稳定版本(当前也是 2022-11-28
)Api Versions[5]。
预览特性(Preview)
新增功能在正式合并到默认版本前,通常会先以“预览”形式发布,你需要在 Accept
头里加上对应的媒体类型才能访问这部分特性。例如:
1 | Accept: application/vnd.github.nebula-preview+json |
这样既不会影响到默认版本的行为,也给社区留下了充分的测试和反馈时间。
如果你认同以下这三点,那你大概率也能接受用 自定义媒体类型(Content Negotiation) 来做接口版本控制:
- URI 应该是资源的永久标识,别让版本号“污染”它。
- 请求头比路径更灵活,语义也更清晰,更符合 REST 精神。
- 鼓励客户端使用
Accept
头指定版本,是一次值得推动的好习惯。
这可能不是最快上手的方案,但它是更优雅、更可持续的选择。
📣 如果这篇文章对你有启发,欢迎点个“赞”支持下,或者转发给正在版本控制里挣扎的朋友们 👀
⭐️ 关注「Java架构杂谈」,我会持续更新更多实战经验、架构思考、项目吐槽,让我们一起写出更“体面”的代码!
References
RFC 6838. Retrieved from https://datatracker.ietf.org/doc/html/rfc6838 ↩︎ ↩︎
Set a Stripe API version. Retrieved from https://docs.stripe.com/sdks/set-version ↩︎
API 升级. Retrieved from https://docs.stripe.com/upgrades#api-versions ↩︎
APIs as infrastructure: future-proofing Stripe with versioning. Retrieved from https://stripe.com/blog/api-versioning ↩︎ ↩︎
API Versions. Retrieved from https://docs.github.com/en/rest/about-the-rest-api/api-versions ↩︎