SpringBoot4系列22 - 使用API版本管理
先讲一段我自己的亲身经历。订单服务跑到第二年,产品要在新版 App 里把金额拆开摆:商品金额、折扣、运费各自一行。我把 /api/orders/{id} 的响应体重构了一遍,customerName 与 customerPhone 聚成 customer 对象,金额拆成 items 数组加 amount 子结构,自测没问题就发了版。上线十分钟,财务的对账系统报警:它按字段平铺解析订单 JSON,customer 对象它不认识,整批对账任务全挂。回滚、发版、写死一套字段映射兼容代码,前前后后折腾了我一周。
这次事故让我认了一个理:毛病出在响应体只有一个版本。老客户端认旧结构,新客户端要新结构,同一个路径上必须同时服务两套契约,由请求方声明自己说的是哪种语言。API 版本管理就是解决这件事的机制:请求带上版本号,服务端按版本把同一个 URL 路由到不同的响应结构,业务与数据层只有一份。这篇文章是我在 Spring Boot 4.1.1 上把这套机制落地的完整记录:Framework 7.0.9 的版本化路由加 Boot 4.1.1 的 spring.mvc.apiversion 属性,v1 与 v2 两套 controller 并存,header 与 query 两条解析路线我都实测过,默认版本、非法版本、版本范围三种边界行为逐个验证,8 个端到端用例全绿,全部数字来自同一次运行,我留了底。

编程模型与事实清单
动笔前我把相关制品全部解包核对过:spring-web 7.0.9、spring-webmvc 7.0.9、spring-boot-webmvc 4.1.1,下面这些事实都来自制品本身和真实运行,不是我猜的。
第一个事实直接决定代码怎么写:Framework 7 里没有 @ApiVersion 注解。spring-web、spring-webmvc、spring-context、spring-core 四个 jar 我全量检索过,ApiVersion.class 命中 0 次。版本声明是映射注解自带的一个属性,javap 反编译 @RequestMapping 可以看到第 9 个属性:
| |
@GetMapping、@PostMapping 这些组合注解都继承这组属性,所以 controller 上直接写 @GetMapping(value = "/orders", version = "1") 就行,不需要另贴注解。version 的值支持两种写法:"2" 表示精确匹配版本 2,"2+" 表示 2 与更高的版本都归这个方法。说实话我第一次看到这个设计还挺意外,版本能力居然藏在每个映射注解里,而不是一个独立的开关,后面你会看到这个选择带来的好处。
第二个事实是版本解析的扩展点位置。相关类型集中在 org.springframework.web.accept 包(spring-web 7.0.9 实证):
| |
路由侧还有一个类:VersionRequestCondition 在 org.springframework.web.servlet.mvc.condition 包,它负责「请求版本与映射版本的比对」。controller 方法参数还可以直接声明 SemanticApiVersionParser.Version 类型拿到解析结果,由 ApiVersionMethodArgumentResolver 注入(org.springframework.web.servlet.mvc.method.annotation 包)。
第三个事实是 Boot 侧的属性装配。spring-boot-webmvc 4.1.1 的 WebMvcProperties 里有个 Apiversion 嵌套类,包名 org.springframework.boot.webmvc.autoconfigure,对应 spring.mvc.apiversion 前缀。全部 8 条属性我逐条核对过 configuration metadata(均无默认值):
| |
装配链路是这样的:WebMvcAutoConfiguration 的内部适配器读取这组属性,逐条调到 ApiVersionConfigurer 上(org.springframework.web.servlet.config.annotation 包,提供 useRequestHeader、useQueryParam、setDefaultVersion、addSupportedVersions 等方法),最终构建出 DefaultApiVersionStrategy。你在工程里声明为 bean 的 ApiVersionResolver、ApiVersionParser、ApiVersionDeprecationHandler 也会被一并收集进去,自定义解析路线与内置属性可以混用。还有一个容易忽略的点:属性清单里没有 enabled 开关,配了 use.* 或显式实现 WebMvcConfigurer 的 configureApiVersioning,版本化路由才会生效。

依赖与配置
依赖零新增,这点我很满意。API 版本能力全部在 spring-boot-starter-webmvc 带入的 spring-web 与 spring-webmvc 里,测试用 webmvc 专属测试 starter:
| |
版本号全部交给 spring-boot-starter-parent 4.1.1 的 BOM 管理,pom 里一个版本都不出现。我对比过第 1 篇 restful 工程的依赖面,两边完全一致,接了版本管理的工程没有多出任何 jar。
application.yml 是本篇的主角,三条配置决定全部行为:
| |
use.header 与 use.query-parameter 同时配置时,框架按顺序尝试两条路线,任一路线取到版本即停。default: 1 声明无版本请求按 v1 处理,required: false 允许版本缺席。这两条必须成对出现,单独配 default 会出什么幺蛾子,我在避坑指南里给你展开。supported: 1, 2, 3 声明白名单,校验规则我在实测小节逐条验证过。
核心代码:同路径两套 controller
订单领域模型与业务服务只有一份。Order 持有全部字段,OrderService 提供查询与建单,两个版本的 controller 都调它:
| |
版本分叉停在这一层的上面。v1 与 v2 的差异全部用 DTO 表达:v1 是完整快照,字段平铺;v2 把客户聚成对象、金额拆成明细。service 与 repository 对版本一无所知,订单的存储、计算、状态流转只有一套逻辑。
v1 controller 用类级 @RequestMapping 声明路径与版本,方法级 @GetMapping 只管子路径:
| |
v2 controller 与 v1 的差别只有两处:version 属性换成 “2”,DTO 换成 OrderResponseV2。路径 /api/orders 完全相同:
| |
到这里你先记住一个结论:版本只影响路由,不影响业务。两个 controller 是两扇门,门后的房间相同。我把版本分叉点停在接口层,理由很实际:业务规则一旦按版本分叉,两个版本会各自演化出独立的缺陷与修复节奏,每加一个功能要写两遍;DTO 层的分叉成本只是多一个映射方法,字段差异被限制在序列化边界上。开头那段财务对账系统的诉求,靠 v1 DTO 原样保留就能满足,对账那边的代码一行不用动。
两个 DTO 的形状对比着看。v2 在 v1 的基础上聚了客户、拆了金额,内部结构三层:
| |
Customer、AmountItem、Amount 三个静态内部类只被 v2 响应体引用,v1 完全不知道它们的存在。明细行的 label(折扣优惠、运费)由 toDto 映射方法组装,客户端不需要自己拿单价乘数量再算折扣,对账口径由服务端统一给出。新版 App 要的「金额构成三行摆开」,就是 items 数组里那三条。

版本范围与版本注入
v2 之后如果还要出 v3、v4,响应结构不变,只是内部实现调整,逐个建新 controller 就没必要了。version 属性支持基线写法,"2+" 表示 2 与更高的版本都路由到这个方法:
| |
方法参数 Version 来自 SemanticApiVersionParser 的内部类,框架把它解析好的版本对象直接注入,Version 实现了 Comparable,major、minor、patch 三个分量可以比较。这个端点我在后面的实测里当探针用:请求版本 2 和 3 都会落进来,回显解析结果。类型全名是 org.springframework.web.accept.SemanticApiVersionParser.Version,想自定义版本语法就实现自己的 ApiVersionParser 替换默认解析器,注入参数的类型跟着自定义解析器走。
一个请求进来,版本的处理分两道闸,这个次序我建议你记牢,排错时全靠它。第一道是准入校验,发生在路由开始前:按配置的路线解析出版本字符串,SemanticApiVersionParser 把它转成 major.minor.patch 三元组,版本 1 按 1.0.0 处理;解析失败抛 InvalidApiVersionException,解析成功再查白名单(supported 集合与 controller 映射探测到的集合),查不到同样抛。第二道是路由匹配,VersionRequestCondition 拿请求版本与映射声明的版本比对:基线写法比较大小,精确写法在命中后还有一次相等确认,请求 3 打映射 2 的接口就是在这里被拦下,报出 7 号场景的 400。两道闸都过,请求才进 controller。理解了这次序,5、6、7 三个场景的区别就清楚了:5 号死在解析,6 号死在白名单,7 号死在路由确认。
完整案例:一次运行的全部行为
验证脚本按 12 个场景发请求,应用起在 18220,结果我逐条实录。先看正常路由:
| |
v1 与 v2 的响应体实录如下,订单号与总金额两侧一致,结构与字段名互斥。v1 的完整快照:
v2 把客户聚成 customer 对象,金额拆成 items 数组与 amount 子结构:
对账系统只认 v1 的平铺结构,新版 App 消费 v2 的明细结构,两条请求打到同一个 URL,落进不同的 controller,取出来的是同一行数据。这就是我想要的那个效果,也是当初折腾一周想一次性解决的问题。

错误路径的实测结果:
| |
三条 400 的响应体都是 RFC 9457 problem+json(problemdetails.enabled 开启的效果)。abc 是解析失败,9 是白名单外的合法语义版本。3 号挺有意思,值得单独说:supported 里明明声明了它,但订单接口只映射了 1 和 2,框架校验发现版本 3 没有任何映射愿意接受,按非法版本处理。我第一眼看到这个结果也愣了一下,反应过来之后就服气了:白名单管准入,映射管路由,两道闸各管各的。
版本范围的探针结果:
| |
10 号结果是反直觉的,单独拎出来讲。"2+" 字面上像是「2 以上全收」,实测 2.4.1 吃了 400。我的判断是:2+ 的匹配范围受白名单约束,supported 里声明的是 1, 2, 3 三个精确值,2.4.1 不在集合内,校验先行拒绝。白名单是精确集合匹配,不是区间。想让 2.4.1 这类版本通过,要么把它列进 supported,要么用 ApiVersionConfigurer 的 setSupportedVersionPredicate 换成区间判断。
12 个场景的行为汇总成路由表:
| 请求版本 | 订单接口(1、2) | echo 探针(2+) |
|---|---|---|
| 无版本 | v1(default 生效) | 404,default 1 不满足 2+ |
| 1 | v1 快照结构 | 404 |
| 2 | v2 拆分结构 | 200,回显 2.0.0 |
| 3 | 400,白名单内无映射 | 200,回显 3.0.0 |
| 2.4.1 | 400,白名单外 | 400,白名单外 |
| abc / 9 | 400,解析失败或白名单外 | 400,同左 |

需要下线时:弃用通知与自定义解析
版本管理落地后马上会遇到的下一个需求是下线 v1:老客户端不会主动迁移,服务端得有办法把「这个版本要废弃了」的信号带给调用方。Framework 7 内置了标准实现,声明一个 bean 就接上,我照官方的做法配了一份:
| |
configureVersion 的返回值是 VersionSpec,弃用日期、迁移文档、下线日期都在它上面链式设置,返回值类型是 ApiVersionDeprecationHandler 的 handler 本体,这一段的收尾要单独 return(链式一路点到底编译不过,VersionSpec 不继承 handler)。这里我提醒一句,我差点就顺着链式写法一路点下去,IDE 没报错是因为方法返回的确实是 handler 本身,真正编译报错的位置在下一行。Boot 的自动配置用 ObjectProvider 收集这个 bean,挂进 DefaultApiVersionStrategy。
实测效果,请求带 X-Api-Version: 1 时响应头多出三行:
| |
三个头的格式来自 RFC 9745(Deprecation header field)与 RFC 8594(Sunset header field),Deprecation 是 Unix 时间戳,Sunset 是 HTTP 日期。同一个请求换成 X-Api-Version: 2,三个头全部消失,弃用信号只发给还在用 v1 的客户端。这个设计我觉得很聪明:监控这类的响应头,可以精确统计 v1 的存量流量,下线窗口的决策依据就有了。
版本插入位置还有一种场景:客户端不方便加请求头时,版本也可以放在路径段或 media type 参数里。use.path-segment 配一个整数下标,/api/1/orders 这种 URL 里的 1 就成了版本;use.media-type-parameter 接一个 Map,Accept: application/json;version=2 也能解析。四条 use.* 路线可以并存,框架按声明顺序逐条尝试。自定义程度更高的需求,比如版本号写在签名里、要从数据库读映射关系,实现 ApiVersionResolver 接口声明成 bean 即可,方法签名只有一个:String resolveVersion(HttpServletRequest),返回 null 表示该路线未取到版本,交给下一条路线。
测试怎么写
8 个用例端到端跑真实 Tomcat,RestTestClient 断言状态码与响应体:
| |
headerRouteSelectsVersion 断言两件事:同一路径上版本 1 落 v1 结构、版本 2 落 v2 结构,两边字段名互斥。另外 7 个用例分别覆盖默认版本回落、query 路线、非法版本 400、白名单外 400、白名单内无映射 400、范围匹配四种组合、两版本列表并存。
mvn test 一次跑完的 surefire 对账:
| |
应用日志 ERROR 0 行。全部数字来自同一次运行,原始记录我留了底,你需要复核随时能对上。
避坑指南
坑一,default 与 required 必须成对出现。 我在这里栽过:只配 spring.mvc.apiversion.default: 1 时,不带版本的请求返回 400 而非落到 v1,required 缺省按必填处理,无版本直接 MissingApiVersionException。要默认版本回落,required: false 加 default: 1 两条一起写。
坑二,required=false 且没有 default 时,无版本请求会命中所有版本映射。 框架对无版本请求直接放行版本条件,v1 与 v2 两个 controller 同时成为候选,实际落点由排序决定,我实测落到 v2。这种配置等于把版本契约交给调度器掷骰子,required=false 必须配 default。
坑三,属性名是 default,不是 default-version。 configuration metadata 里这条属性的名字是 spring.mvc.apiversion.default,写成 default-version 不报错、不生效,无版本请求的行为与没配一样。属性名以 spring-configuration-metadata.json 为准,IDE 补全比记忆可靠。
坑四,白名单是精确匹配,小版本不会自动包含。 supported 声明 1, 2, 3 后,请求 2.4.1 得到 400,请求 3 反而通过校验。语义版本比较只发生在路由择优阶段,准入校验是集合 contains。发小版本前先把 supported 补齐。
坑五,2+ 基线端点同样受白名单约束。 基线写法解决的是「不必为每个版本建 controller」,准入校验仍在白名单层面。新版本上线前,supported 里没有它,请求它就是 400,哪怕基线端点写得再宽。
坑六,白名单内无映射的版本返回 400 而非 404。 实测版本 3 打订单接口(映射只有 1 和 2)得到 400 InvalidApiVersionException,原因在路由次序:校验阶段 3 在白名单内放行,路由阶段精确映射不匹配,按非法版本兜底。排错时看到 400 先查 supported 与映射声明是否一致。
坑七,宿主环境的 SERVER_PORT 会覆盖 yml。 我本机环境注入过 SERVER_PORT 变量,server.port: ${SERVER_PORT:18220} 的写法让它优先于 yml 生效,应用起在别人的端口上,我还纳闷了一阵接口怎么不通。启动前 export SERVER_PORT=18220 显式固定,或者命令行加 –server.port 参数。
坑八,版本分叉别越过接口层。 v1 与 v2 共用 OrderService 与 OrderRepository,业务逻辑一处维护。把版本判断写进 service(if 版本为 2 则走新逻辑),两套演化线会互相缠绕,DTO 映射层多一个类就能解决的问题,不要带进业务层。
小结
版本管理这件事,我的选型建议只有一条主线:把版本当路由问题处理,别当业务问题处理。controller 层声明 version,Boot 侧用 spring.mvc.apiversion 八条属性管解析路线、默认版本和白名单,业务与数据层保持零感知。header 与 query 两条路线按你的客户端形态挑一条即可,存量调用方加不了请求头就换 path-segment,新版本不想建 controller 就用 2+ 基线,都是属性层面的事。唯一要上心的是白名单语义:它是精确集合,发小版本前记得补 supported。
没解决的事也直说:废弃提示只覆盖了「告诉调用方要下线」这一步,v1 真正摘除时的流量切 Ambassador、老客户端版本分布的采集策略,这两块我没在工程里落地。等我把 v1 的存量流量统计跑起来,再补一篇下线实操。
参考链接
- Spring Framework Web MVC 文档:API versioning 与 ApiVersionConfigurer 的权威说明
- Spring Boot Web MVC 特性文档:spring.mvc.apiversion 属性与自动配置说明
- RFC 9457 Problem Details:错误响应体的规范格式
- Spring Boot 4.1.1 API:WebMvcProperties.Apiversion 的 Javadoc
- spring-web 7.0.9(Maven Central):本文版本结论的制品实证来源