Spring Framework 7:声明式 API 版本,别再只靠手写 /v1
Boot 4 / Framework 7 用 ApiVersionConfigurer + mapping version 属性统一解析与匹配,替代散落的路径前缀。
一、痛点:版本散落在路径里,弃用与匹配全靠约定
对外 REST 一多版本,最常见的做法是控制器上再叠一层/v1、/v2。短期能跑,长期会出现三类摩擦:
- 解析不统一:有人用路径,有人用 Header,有人用
Accept参数,过滤器与网关各写一套; - 匹配规则靠人脑:基线版本(「1.2 及以上走新实现」)只能手写
if,容易和文档对不上; - 弃用信号缺失:Deprecation / Sunset 头要自己拼,客户端很难按 RFC 做平滑迁移。
更麻烦的是「同一资源、多种兼容面」:老客户端仍打无版本 URL,新客户端要带版本,灰度客户端要试基线版本。全靠字符串前缀的话,控制器类数量和路径常量会跟着版本数线性膨胀。Code Review 时也很难一眼看出「请求 1.3 究竟落到哪个方法」。
Spring Framework7.0(配合 Spring Boot4)在 MVC 配置里补上了声明式 API 版本:用ApiVersionConfigurer决定「版本从哪来」,用@RequestMapping/@GetMapping的version属性决定「落到哪个处理器」。下面按官方 Web MVC 文档和ApiVersionConfigurerJavadoc 来讲,只谈四件事:服务端启用、映射语义、弃用头、客户端单独配置。先说清边界:不假设 Boot 3.x 已有这套 mapping 版本属性,也不去发明未核实的spring.mvc.api-version.*开关名,启用方式以 Java 配置为准。
二、启用:WebMvcConfigurer + ApiVersionConfigurer
启用入口是WebMvcConfigurer.configureApiVersioning。至少挂一个解析器后,框架才会建立ApiVersionStrategy并参与请求映射:
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override public void configureApiVersioning(ApiVersionConfigurer configurer) { configurer.useRequestHeader("API-Version") .setDefaultVersion("1.0") .addSupportedVersions("1.0", "1.1", "1.2"); }}
内置解析方式(可组合,也可用自定义ApiVersionResolver):
| 方式 | 配置方法 | 典型场景 |
|---|---|---|
| Request Header | useRequestHeader("API-Version") | 对内服务、网关转发,URL 保持稳定 |
| Query Param | useQueryParam("version") | 调试或兼容旧网关 |
| Path Segment | usePathSegment(index) | 对外公开 API;路径里要有 URI 变量如/{version} |
| Media Type 参数 | useMediaTypeParameter(mediaType, param) | 内容协商风格 |
路径段解析要注意索引:"/{version}/..."用0,"/api/{version}/..."用1。官方还提醒:若版本总在路径最前,可配合 Path Matching 的公共前缀配置,避免每个控制器重复声明。usePathSegment还有一个带Predicate的重载usePathSegment(int, Predicate),可对「这条路径是否带版本」做更细判断。
补充几个配置旋钮(以 Javadoc 为准):
setDefaultVersion:请求未带版本时赋默认值;设置默认后,「版本是否必填」会自动变为可不带;setVersionRequired:强制请求必须带版本时,缺省会走MissingApiVersionException(400);addSupportedVersions/detectSupportedVersions:默认会从映射上的 version 自动探测支持列表;若只想认显式列表,把探测关掉再addSupportedVersions;setDeprecationHandler:挂ApiVersionDeprecationHandler(标准实现可发 Deprecation / Sunset / Link,对应 RFC 9745 / RFC 8594);setVersionParser:默认语义版本解析(SemanticApiVersionParser),特殊编号体系可换自定义解析器。
不支持的版本会触发InvalidApiVersionException,最终400。这比「默默落到错误实现」更安全:客户端能立刻发现版本协商失败,不会带着半对半错的响应继续往下走。
三、映射:固定版本、基线版本、空版本优先级
启用后,在映射上写version:
@RestController
@RequestMapping(“/account/{id}”)
public class AccountController {
private final AccountService accounts; public AccountController(AccountService accounts) { this.accounts = accounts; } @GetMapping public Account getAny() { return accounts.legacy(); } @GetMapping(version = "1.1") public Account getV11() { return accounts.v11(); } @GetMapping(version = "1.2+") public Account getFrom12() { return accounts.from12(); } @GetMapping(version = "1.5") public Account getV15() { return accounts.v15(); }}
语义(官方 Request Mapping「API Version」一节):
- 固定
"1.2":只匹配该版本; - 基线
"1.2+":匹配该版本及更高的已支持版本; - 空版本:匹配任意版本,但优先级最低,用来兜底「版本化之前的老客户端」。
多个方法都「够得着」请求版本时,选最高且最接近请求版本的那一个。文档用请求"1.3"举例:空版本方法能匹配,但会被"1.2+"盖过;固定"1.5"更高却匹配不上,于是最终落到"1.2+"。反过来,请求"1.5"时固定"1.5"胜出。请求"1.6"时,空版本方法和"1.2+"都能匹配,但会被更高的固定"1.5"盖过,而"1.5"只接受严格相等,于是没有方法可选,结果是NotAcceptableApiVersionException(400)。另外,请求版本既不在映射里、也没配置为支持版本时,会直接被判为无效版本(InvalidApiVersionException,同样 400)。所以写映射时,务必对照支持列表把「请求版本 × 方法」推演一遍,别只看「有没有一个能模糊对上」。
实践建议:
- 老接口先留空版本方法,保证未升级客户端仍可调用;
- 破坏性变更用固定版本,避免被基线误伤;
- 演进式兼容用基线
x.y+,减少「每个小版本复制一个方法」; - 关键里程碑版本(例如首次对外的
1.0)若暂时还没写在任何 mapping 上,记得addSupportedVersions补进支持列表。
四、决策:何时仍写 /v1,何时上声明式 version
主建议很简单:新项目优先内置 version 属性 + 统一 Resolver,别把/v1当唯一手段。路径前缀并非禁止,usePathSegment本身就是一等公民。差别在于:「版本」写在映射条件里,不再散落在每个@RequestMapping的字符串常量里。手写前缀适合这类存量:对外文档已经把/v1写进永久契约,短期改不了 URL。可一旦还要 Header、弃用头、基线匹配,继续只靠前缀会把复杂度推回过滤器层。
怎么选解析位置:
- Header:URL 稳定、适合 BFF / 内网;客户端与网关约定
API-Version即可; - Path:对外文档友好、可缓存、浏览器地址栏可见;记得路径段索引与 URI 变量;
- Query / MediaType:兼容层或内容协商存量系统。
同一应用也可以组合解析器,但要约定优先级与「谁说了算」,避免网关改了 Header、客户端又改了 Query,两侧各执一词。更稳的做法:对内统一 Header,对外若必须可见再开 Path,并在网关层做一次归一,别让每个微服务自己猜。
客户端要单独配。服务端 MVC 的configureApiVersioning不会自动套到RestClient/WebClient。官方 REST Clients 文档示例:
RestClient client = RestClient.builder()
.baseUrl(“https://api.example.com”)
.defaultVersion(“1.2”)
.apiVersionInserter(ApiVersionInserter.fromHeader(“API-Version”).build())
.build();
单次请求还可再覆写版本。评审时把「服务端解析」与「客户端插入」拆成两项打勾:一边开了版本,另一边忘了ApiVersionInserter,联调时最容易出现「服务端老报缺版本 / 不支持版本,客户端坚持自己没传错」的拉锯。
五、迁移节奏:存量 /v1 怎么收
若线上已经有一大片/api/v1/...,不必第一天就删路径。更稳的节奏是:
- 双轨一期:保留旧路径控制器,同时启用
configureApiVersioning,新接口只在映射上写version,对外文档开始宣传 Header(或 Path Segment)约定; - 网关归一:在边缘把旧
/v1改写成统一的版本信号(例如补API-Version: 1.0),让下游只认一种解析方式,避免微服务各自兼容; - 弃用公告:对旧固定版本挂上标准弃用头,给出 Sunset 时间窗口,监控仍打旧版本的流量占比;
- 再拆前缀:待流量降下去,再删除仅用于版本区分的路径段,把「版本」彻底交还给映射条件。
这样迁,业务方法可以逐步合并到「基线版本 + 少量固定版本」模型,不必再维持「每个大版本一个 Controller 包」。评审时把「解析方式」「支持列表」「弃用头」「客户端 Inserter」四项写成同一检查表,比只改 URL 更不容易漏。
跨团队协作时,再补一条约定:版本号表示「契约世代」,不跟着发版号走。内部每周发版不必涨 API 版本;只有响应字段语义、错误码或鉴权方式出现不兼容时才升。声明式版本把「升不升」变成显式的映射决策,不用再悄悄新开一个/v3文件夹。
六、落地清单与常见坑
上线或把存量 API 迁到声明式版本时,按下面勾一遍:
- 版本基线:确认运行在 Framework 7 / Boot 4;不要把这里的 API 回写到 Boot 3.x 教程里。
- 先 Resolver 再映射:没配置
configureApiVersioning就写version="1.0",映射条件不会按预期生效。 - 支持列表:改映射后看一眼自动探测结果;关键版本若未出现在任何 mapping 上,用
addSupportedVersions补上。 - 默认版本与必填:对外严格契约可要求必带版本;对内渐进迁移可设
setDefaultVersion("1.0"),但要在文档写清默认语义。 - 弃用路径:下线前挂标准弃用处理器,让客户端先收到 RFC 级信号,别突然给 400。
- 客户端对称:RestClient / WebClient Builder 显式
apiVersionInserter;别指望「服务端开了版本,客户端自动带上」。 - 容器边界:Boot 4 已移除 Undertow 支持,部署与压测按官方仍支持的容器选型,勿把旧 Undertow 调优笔记直接搬过来。
- 测试矩阵:至少覆盖「无版本 / 默认版本 / 固定命中 / 基线命中 / 不支持版本 400 / 弃用头是否出现」六类用例,比只测快乐路径更有用。
收个尾:版本属于映射条件,别让它沦为路径字符串的副作用。用ApiVersionConfigurer统一解析,用version/version+表达兼容面,用标准弃用头管理生命周期;手写/v1只留在确实需要「永久可见路径」的窄场景。