RESTful API版本控制最佳实践:用请求头替代URL版本化

发布于 2025-07-15 | 更新于 2025-07-15

最近接了个项目,接口风格嘛,属于那种“看起来很标准”的 RESTful。一眼就能看出来,当年写这套接口的人是认真查过资料、照着最佳实践抠出来的,结构规整、命名规范,一开始确实让人挺放心。

但很快来了个老熟人式需求:要对其中一个接口做升级,同时还得保留旧版本,继续服务老客户端。
这事儿不稀奇,我当时心里也挺淡定:兼容旧版本而已,稳着来就行。

结果我打开控制器代码,看了一眼,差点没把 IDE 关了。

接口版本乱的不堪入目:

1
2
3
4
第一版:GET /v1/orders/{orderNo}
第二版:GET /v2/orders/{orderNo}
第三版:GET /orders/{orderNo}/v3
第四版:GET /orders/{orderNo}/new

还有更离谱的,比如:

1
2
/v2.1/orders/fast
/v3.5.6/orders/beta

你能感受到这些命名是“临时起意”且“版本焦虑”的产物。

每个方法看起来都差不多,但你仔细一看,字段不一样、逻辑不一样、校验不一样,甚至返回结构都小修小补。有写代码明显是 copy 上一版之后“微调”的,几百行里只有两个 if 变了,剩下全是“历史残留”。

我还想了想:那我现在这版,命名成这样如何?

1
GET /v5/orders/{orderNo}

听起来也挺对路,跟现有套路保持一致,对吧?问问同事,八成也会说:“嗯,我们一直都是这么搞的。”

但问题是:这玩意根本就不 REST 啊!

更重要的是,你正在造一个维护地狱的入口。

还不知道RESTful的朋友点击阅读这里:

另外,HTTP 规范是 REST 的基石,了解 HTTP/1.1 和 HTTP/2 对 RESTful 设计很有帮助:

1. 问题出在哪?

1
2
3
4
5
6
7
@RestController  
@GetMapping("/v1/orders/{order_no}")
public class OrderControllerV1 { ... }

@RestController
@RequestMapping("/v5/orders/{order_no}")
public class OrderControllerV2 { ... }

看起来写代码很快对吧?嗯……但这样你得维护两个 Controller(还有测试、文档……)。

**很多教程和遗留系统都默认用 URL 版本管理。如果你 Google 一下 API 版本管理,URL 版本化肯定是最多的方案。**你看,JIRA的开发文档就是这么做的:

image-20250708080038350

image-20250708080009648

上面我们说到它违背了 REST 原则,那么接着说说为什么会违背REST原则。

问题1:为什么 URL 版本化会破坏 REST?

image-20250709194948013

REST 架构的核心原则之一就是:

每个 URI 都代表一个唯一且稳定的资源标识。

当你把版本号直接放进 URL(如 /v1/orders/v2/orders),实际上在告诉客户端“这是两个不同的资源”,虽然它们逻辑上其实是“同一个资源”的不同版本。

这样做会让 URI 失去稳定性,资源身份模糊,破坏了 REST 的一致性原则

问题2:客户端和服务器耦合度过高

客户端需要写死请求的具体版本 URL,导致以下问题:

  • 代码中硬编码版本号,升级时要改代码。
  • 版本迭代频繁时,客户端维护成本大大增加。
  • 版本切换变得不灵活,破坏了客户端和服务器的解耦。

问题3:版本增长导致路径臃肿和重复代码

随着版本增多,URL 路径不断堆积:

1
2
3
4
/v1/orders/{order_no}
/v2/orders/{order_no}
/v3/orders/{order_no}
...

当需要维护多个相似的 Controller、路由、文档和测试时,代码往往会出现大量重复,导致维护变得异常困难;与此同时,过时的版本难以及时废弃,长期积累下来无形中拖累了整个系统的稳定性和开发效率。

问题4:破坏缓存和中间件的优化

缓存服务器或代理一般根据 URL 缓存响应。
如果版本号在 URL 中,每个版本都是不同缓存,难以复用缓存策略,降低性能。

问题5:废弃版本导致客户端无预警的故障

当某版本 URL 被废弃或删除时,客户端请求会返回 404 或错误,用户体验差,且客户端无法优雅降级。

问题6:客户端容易404

/v1/orders/{orderNo} 废弃时:

  • 使用它的移动端开始返回 404
  • 用户毫无预警,功能突然失效。

2. 怎么做版本控制更合理?

这里推荐使用请求头做版本控制,可以使用自定义媒体类型的版本控制。

我们先来看看怎么做,好让大家有个直观的概念:

客户端版本协商

客户端通过 Accept 头协商版本,比如,如果要调用v1版本,可以这么发起:

1
2
curl -X GET --location 'http://localhost:8080/orders/PAY0101' \
--header 'accept: application/vnd.itzhai.v1+json'

如果要调用v2版本,可以这么发起:

1
2
curl -X GET --location 'http://localhost:8080/orders/PAY0101' \
--header 'accept: application/vnd.itzhai.v2+json'

服务端代码

服务端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
@GetMapping(value = "/orders/{order_no}", produces = {
"application/vnd.itzhai.v1+json",
"application/vnd.itzhai.v2+json"
})
public ResponseEntity<OrderInfoRespVo> getOrderInfo(@PathVariable("order_no") String orderNo,
@RequestHeader(value = "Accept", defaultValue = "application/vnd.itzhai.v1+json") String acceptHeader) {

Version version = VersionResolver.determineVersion(acceptHeader);
// 根据不同的version,做不通的事情
// ...
return ResponseEntity.ok(orderInfo);
}

这样做的好处是:

  • URI 永远稳定(一直是 /orders/{order_no})。
  • 向后兼容更简单(一个接口支持多版本)。
  • 缓存自然工作(版本信息在请求头里)。

当然,这么做之后,对接起来也需要注意下客户端必须支持并设置请求头,可能部分客户端工具或类库库使用起来不方便。

实现方法就是这样的,接下来我们用专业的术语来介绍下这种版本控制的方法:基于自定义媒体类型的版本控制

3. 基于自定义媒体类型的版本控制

什么是媒体类型?媒体类型通常是 application/jsontext/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)及其命名要求,以及参数、规范化格式、安全性和互换性建议。

媒体类型的格式如下:

image-20250713094039124

1
<type>/<tree>.<subtype>+<suffix>[;参数…]
  • <type>:顶级类型,如 applicationtext 等。
  • <tree>:子类型命名空间,厂商类型用 vnd
  • <subtype>:具体资源名称,可包含厂商名和资源标识。
  • 可选版本号:通常放在 subtype 里,用点号或连字符分隔,如 resource-v2resource.v2
  • +<suffix>:结构化后缀(Structured Syntax Suffix),如 +json+xml,指示底层编码格式。
  • 参数:可选的键值对,如 ; charset=utf-8

我们再来看看上面的例子:

image-20250713094058145

这里 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 版本化?

  1. URI 稳定:这个就不多说了,很直观的收益。
  2. 向后兼容:老版本新版本同接口服务。
  3. 代码更简洁:无重复 Controller 或路由。
  4. 符合 REST:符合 Roy Fielding 的 REST 约束。

如果不能避免 URL 版本化,建议只用在以下场景使用:

  1. 内部 API(你能控制的微服务之间调用)。
  2. 资源身份改变导致破坏性变更(例如 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 时绑定的“固定版本”。

执行流程如下图:

image-20250710083808170

(图片来源: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 决定要移除或重命名某个字段时,会遵循以下流程:

  1. 文档与变更日志预告
    在“API Changes”页面和对应版本的 Changelog 中,明确列出将要弃用的字段和预计的移除时间。

  2. Deprecation Warning
    如果你使用了被弃用的字段,API 响应头里会返回类似:

    1
    Deprecation: version=2023-07-01; production; obsolete=true

    这样你可以在日志中自动捕获并修复调用。

  3. 正式下线
    在事先公告的时间点之后(通常至少提前三个月),那些字段会被真正移除或改名,此时旧版本下的调用会返回错误提示。

整个流程最大程度地给出充足的通知和迁移窗口,确保开发者可以无缝升级。

请求时通过请求头制定版本

GitHub 的 REST API 是通过 HTTP 请求头来控制版本的。

版本号以发布日期命名
每个 REST API 版本都对应一个发布日期,例如 2022-11-28 就代表在 2022 年 11 月 28 日发布的版本。任何破坏向后兼容的改动(比如删除或重命名接口、参数、返回字段等)都会以新的发布日期版本发出,而旧版本至少继续支持 24 个月。

在请求里加上 X-GitHub-Api-Version 头,就可以锁定你需要的版本,例如:

1
2
3
4
curl \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/octocat/Hello-World

如果不指定该头,GitHub 会默认使用最新稳定版本(当前也是 2022-11-28Api Versions[5]

预览特性(Preview)
新增功能在正式合并到默认版本前,通常会先以“预览”形式发布,你需要在 Accept 头里加上对应的媒体类型才能访问这部分特性。例如:

1
Accept: application/vnd.github.nebula-preview+json

这样既不会影响到默认版本的行为,也给社区留下了充分的测试和反馈时间。


如果你认同以下这三点,那你大概率也能接受用 自定义媒体类型(Content Negotiation) 来做接口版本控制:

  1. URI 应该是资源的永久标识,别让版本号“污染”它。
  2. 请求头比路径更灵活,语义也更清晰,更符合 REST 精神。
  3. 鼓励客户端使用 Accept 头指定版本,是一次值得推动的好习惯。

这可能不是最快上手的方案,但它是更优雅、更可持续的选择。


📣 如果这篇文章对你有启发,欢迎点个“赞”支持下,或者转发给正在版本控制里挣扎的朋友们 👀
⭐️ 关注「Java架构杂谈」,我会持续更新更多实战经验、架构思考、项目吐槽,让我们一起写出更“体面”的代码!

References


  1. RFC 6838. Retrieved from https://datatracker.ietf.org/doc/html/rfc6838 ↩︎ ↩︎

  2. Set a Stripe API version. Retrieved from https://docs.stripe.com/sdks/set-version ↩︎

  3. API 升级. Retrieved from https://docs.stripe.com/upgrades#api-versions ↩︎

  4. APIs as infrastructure: future-proofing Stripe with versioning. Retrieved from https://stripe.com/blog/api-versioning ↩︎ ↩︎

  5. API Versions. Retrieved from https://docs.github.com/en/rest/about-the-rest-api/api-versions ↩︎

本文作者: arthinking

本文链接: https://www.itzhai.com/api/best-practices-for-restful-api-versioning-using-headers-instead-of-url.html

版权声明: 版权归作者所有,未经许可不得转载,侵权必究!联系作者请订阅本站。

×
帅旋DevShow

订阅及时获取网站内容更新。

充电

当前电量:100%

帅旋DevShow

订阅我,及时获取网站内容更新。