本节目标:给 book-loan 的接口定一套版本策略——什么时候才该引入版本、三种版本载体怎么选、Spring Boot 4 内置的 API Versioning 怎么配,以及用一张判定清单守住向后兼容、用 Deprecation/Sunset 头把旧版本体面地下线。
适用版本:Spring Boot 4.1.x(Java 21)
7.3 版本演进与兼容性
7.2 把 OpenAPI 文件变成了唯一事实源。但契约不是刻在石头上的——图书借阅的业务在变,接口迟早要改。问题不是「要不要改」,而是「怎么改才不让已经上线的调用方崩溃」。
本节先讲一个反直觉的结论:版本号是最后手段,不是第一手段。 绝大多数接口变更根本不需要新版本。
7.3.1 什么时候才真的需要版本
先看一个例子。book-loan 的 BookResponse 要加一个「馆藏位置」字段:
{ "isbn": "9787115428028", "title": "深入理解计算机系统", "availableCopies": 3 }
变成:
{ "isbn": "9787115428028", "title": "深入理解计算机系统", "availableCopies": 3, "location": "A-12-3" }
这是纯增量变更:老客户端忽略 location 照样工作,不需要任何版本号。加字段、加可选参数、加新路径,都属于这一类。
什么时候才真的需要版本?
| 信号 | 例子 | 是否必须开新版本 |
|---|---|---|
| 删除或重命名已有字段 | 响应里去掉 availableCopies | 是 |
| 把可选字段改成必填 | 请求必须带 memberId | 是 |
| 改变已有字段的含义 | availableCopies 从「可借」变成「总藏书」 | 是 |
| 收紧校验规则 | 书名 maxLength 从 200 收到 50 | 是 |
| 改变错误码或状态码语义 | 404 改成 400 | 是 |
| 只做增量扩展 | 加字段、加可选参数 | 否,直接改 |
判定原则:老客户端在不改动的情况下继续正常工作,就不需要新版本。 版本号是给「无法兼容」准备的逃生舱,滥用它会让每个版本都变成要长期维护的平行世界。
7.3.2 三种版本载体
确定要引入版本后,第一个决策是「版本号放在哪」。三种主流载体各有代价:
| 载体 | 形态 | 优点 | 代价 |
|---|---|---|---|
| URL 路径 | /api/v1/books/{isbn} | 直观、易路由、易缓存、浏览器直接可测 | URL 会变,v1 长期污染路径;版本与资源绑定过死 |
| 请求头 | API-Version: 1.0 | URL 干净、版本与资源解耦 | 不可见、浏览器不便手测、需在网关透传头 |
| 媒体类型 | Accept: application/vnd.bookloan.v1+json | 最符合 HTTP 语义、天然内容协商 | 写法繁琐、工具与团队接受度低 |
还有第四种「查询参数版本」(?version=1.0),实现最简单,但会把版本混进业务查询串,只适合临时过渡。
book-loan 的选择是请求头:URL 保持干净,前端与移动端都容易加一个统一请求头,网关也方便按头路由。这正是 Spring Boot 4 内置 API Versioning 的默认形态之一。
7.3.3 Spring Boot 4 的 API Versioning:属性与注解
Spring Boot 4.0 为 Spring MVC 与 WebFlux 增加了 API Versioning 的自动配置,属性前缀是 spring.mvc.apiversion.*(WebFlux 是 spring.webflux.apiversion.*)。可用属性如下:
| 属性 | 作用 |
|---|---|
spring.mvc.apiversion.use.header | 用指定请求头取版本 |
spring.mvc.apiversion.use.query-parameter | 用指定查询参数取版本 |
spring.mvc.apiversion.use.media-type-parameter | 用媒体类型参数取版本 |
spring.mvc.apiversion.use.path-segment | 用指定下标路径段取版本 |
spring.mvc.apiversion.default | 未提供版本时使用的默认版本 |
spring.mvc.apiversion.supported | 支持的版本集合 |
spring.mvc.apiversion.required | 是否要求每个请求都带版本 |
spring.mvc.apiversion.detect-supported | 是否从控制器自动探测支持的版本 |
默认的版本解析器是 SemanticApiVersionParser,按语义化版本解析。控制器上用 @RequestMapping 家族注解的 version 属性声明版本:
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Operation(summary = "查询图书(v1)")
@GetMapping(path = "/{isbn}", version = "1.0")
public BookResponseV1 getBookV1(@PathVariable String isbn) {
return BookResponseV1.from(bookService.findByIsbn(isbn));
}
@Operation(summary = "查询图书(v2,含馆藏位置)")
@GetMapping(path = "/{isbn}", version = "2.0")
public BookResponseV2 getBookV2(@PathVariable String isbn) {
return BookResponseV2.from(bookService.findByIsbn(isbn));
}
}
注意两个方法路径完全相同,只靠 version 区分。请求打过来时,框架按配置的载体取出版本,再路由到匹配的方法。
对应的 application.yml:
spring:
mvc:
apiversion:
use:
header: API-Version
default: "1.0"
supported:
- "1.0"
- "2.0"
required: true
行为说明:
- 请求头
API-Version: 2.0会命中getBookV2。 - 请求头缺失且
required: true时,请求被拒(返回 400),不会悄悄用默认版本——这正是「required」的意义:宁可明确报错,也不要静默降级到错误版本。 default只在required: false时生效。
7.3.4 用 ApiVersionConfigurer 做精细控制
属性能覆盖多数场景。需要自定义解析器、自定义废弃处理器时,实现 WebMvcConfigurer 的 configureApiVersioning(ApiVersionConfigurer):
@Configuration
public class ApiVersioningConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer.useRequestHeader("API-Version")
.setVersionRequired(true)
.setDefaultVersion("1.0")
.addSupportedVersions("1.0", "2.0")
.setDeprecationHandler(deprecationHandler());
}
private ApiVersionDeprecationHandler deprecationHandler() {
StandardApiVersionDeprecationHandler handler = new StandardApiVersionDeprecationHandler();
handler.configureVersion("1.0")
.setDeprecationDate(ZonedDateTime.parse("2026-09-01T00:00:00Z"))
.setDeprecationLink(URI.create("https://docs.example.com/api/deprecations"))
.setSunsetDate(ZonedDateTime.parse("2027-03-01T00:00:00Z"))
.setSunsetLink(URI.create("https://docs.example.com/api/sunset"));
return handler;
}
}
ApiVersionConfigurer 的常用方法:
| 方法 | 作用 |
|---|---|
useRequestHeader(String) | 从头取版本 |
useQueryParam(String) | 从查询参数取版本 |
useMediaTypeParameter(MediaType, String) | 从媒体类型参数取版本 |
usePathSegment(int) | 从指定下标路径段取版本 |
useVersionResolver(ApiVersionResolver...) | 挂自定义解析器(如从域名或 JWT 取版本) |
setVersionParser(ApiVersionParser<?>) | 换解析器,例如按日期 2026-09 解析 |
setVersionRequired(boolean) | 是否强制带版本 |
setDefaultVersion(String) | 默认版本 |
addSupportedVersions(String...) | 声明支持的版本 |
detectSupportedVersions(boolean) | 从控制器自动探测支持版本 |
setDeprecationHandler(...) | 挂废弃处理器 |
需要更底层控制时,还可以直接定义 ApiVersionResolver、ApiVersionParser、ApiVersionDeprecationHandler 三种 bean,框架会自动识别。
一个务实的建议:先用属性配到够用,只有需要自定义解析逻辑(比如从 API Key 反查版本)时才写 configureApiVersioning。 配置项能表达的东西,不要用代码重写一遍。
7.3.5 向后兼容的判定清单
这是本节最该背下来的一张表。改契约前逐条核对:
| 变更 | 兼容性 | 说明 |
|---|---|---|
| 新增响应字段 | 兼容 | 前提:客户端忽略未知字段(OpenAPI 客户端默认如此) |
| 新增可选请求字段 | 兼容 | 老客户端不发即可 |
| 新增新路径 | 兼容 | 不影响既有调用 |
放宽校验(如 maxLength 变大) | 兼容 | 老请求仍然合法 |
| 删除响应字段 | 破坏 | 客户端可能正读它 |
| 重命名字段 | 破坏 | 等价于「删旧的 + 加新的」 |
| 新增必填请求字段 | 破坏 | 老客户端缺字段直接 400 |
| 可选字段改为必填 | 破坏 | 同上 |
| 收紧校验规则 | 破坏 | 原本合法的请求被拒 |
| 扩大响应枚举取值范围 | 视客户端 | 客户端若穷举枚举,遇到新值会崩 |
| 缩小请求枚举取值范围 | 破坏 | 老客户端发的值被拒 |
| 改变字段含义(语义漂移) | 破坏 | 最隐蔽,编译不报错、测试可能也不报错 |
「扩大响应枚举」这一条要特别小心。 它常被当成兼容变更,但如果客户端用 switch 穷举了所有枚举值,多出来的一支就会走到默认分支甚至抛异常。判断方法:问客户端「遇到未知枚举值会怎样」,答案不是「忽略」,这条就是破坏性的。
「语义漂移」是最难防的一类:字段名、类型、必填性全都没变,只是含义变了。它能通过所有自动检查。唯一的防线是把语义写进契约描述(description 字段),让评审时有人能看出来。
7.3.6 废弃流程:Deprecation 与 Sunset 头
要下线一个版本,光在文档里写一句「v1 已废弃」没用——调用方不会天天看文档。正确做法是让废弃信息出现在每一个响应里。
StandardApiVersionDeprecationHandler(7.3.4 已挂到配置上)会在命中被废弃版本的请求上自动加响应头:
Deprecation: Mon, 01 Sep 2026 00:00:00 GMT
Sunset: Mon, 01 Mar 2027 00:00:00 GMT
Link: <https://docs.example.com/api/deprecations>; rel="deprecation"; type="text/html"
Link: <https://docs.example.com/api/sunset>; rel="sunset"; type="text/html"
Deprecation:宣告该版本进入废弃状态的日期(RFC 1123 格式)。Sunset:该版本彻底不可用的日期,给调用方一个硬截止。Link:指向迁移文档,rel="deprecation"与rel="sunset"分别对应两个日期。
完整的下线流程分四步:
- 标记废弃:给版本设置
Deprecation日期,开始发头。 - 观察迁移:在监控里统计「仍在用旧版本的调用方数量」,趋势不降就是有人在拖。
- 设定 Sunset:给一个明确的截止日期,并通过头、邮件、变更日志同时公告。
- 下线:到期后旧版本返回
410 Gone(或404),并保留一段时间的重定向或说明。
Sunset 窗口要给够。对外部合作方,业内常见做法是至少 6 个月;纯内部服务可以短一些,但不应少于一个完整的发布周期。
7.3.7 破坏性变更的发布策略
当变更确实无法兼容时,有两种发布方式:
方式一:并行版本(推荐给对外接口)。 v1 与 v2 同时在线,各自独立维护,老客户端留在 v1 直到 Sunset。代价是要同时维护两套契约、两套实现、两套测试——这是真实成本,不要低估。
方式二:扩展-收缩(expand-contract,推荐给能推动调用方升级的场景)。 分三步走:
- 扩展:先加新字段/新路径,旧的照常工作(纯增量,兼容)。
- 迁移:推动调用方切到新形态,监控旧形态的调用量降到零。
- 收缩:确认无人使用后,再删除旧字段/旧路径。
扩展-收缩的关键在于**「删除」和「新增」拆成两次发布**,中间留出迁移窗口。它不需要真正的版本号,适合内部服务;一旦需要给外部调用方保证,还是得回到并行版本。
book-loan 的 availableCopies 要拆成 totalCopies 与 borrowedCopies,就是典型场景:先加两个新字段(扩展),等调用方切完,再删 availableCopies(收缩)。如果调用方不可控,则升级为 v2 并行。
7.3.8 常见坑
坑一:一上来就 v1。 首个版本就带版本号,等于提前承诺了「未来一定有 v2」的维护成本。第一个稳定版本可以不带版本号,等真的需要破坏时才引入。
坑二:required: true 与灰度同时上线。 打开强制版本后,任何还没加版本头的客户端会立刻收到 400。上线前先确认所有调用方(包括健康检查、内部定时任务、监控探针)都带上了头。
坑三:只在 URL 上做版本,忘了契约的其他部分。 版本管的是路由,契约的兼容性仍要逐字段核对 7.3.5 的清单。改了 v1 的响应字段却没升 v2,等于版本形同虚设。
坑四:把「加枚举值」当成永远安全。 见 7.3.5,先确认客户端对未知枚举的处理方式,再决定要不要走版本。
坑五:废弃了却没有监控。 发了 Deprecation 头但不知道谁还在用旧版本,Sunset 到期时只能靠猜。上线废弃流程的同时,就要把「各版本调用量」加进监控面板。
小结
- 版本号是最后手段:只要老客户端不改也能正常工作(加字段、加可选参数),就直接改,不升版本。
- 需要版本的信号:删/改字段、必填性变化、校验收紧、状态码语义变化。
- 三种载体各有代价:URL 直观但污染路径,Header 干净但不可见,媒体类型最符合 HTTP 但繁琐;book-loan 选 Header。
- Spring Boot 4 内置 API Versioning:属性是
spring.mvc.apiversion.*,控制器用@GetMapping(version = "1.0")声明版本,默认按语义化版本解析。 - 需要自定义解析器或废弃处理器时,实现
WebMvcConfigurer#configureApiVersioning(ApiVersionConfigurer);能靠属性表达的就别写代码。 - 向后兼容判定清单:增量变更兼容,删除/改名/加必填/收紧校验是破坏;「扩大响应枚举」和「语义漂移」是最隐蔽的两类。
- 废弃用
StandardApiVersionDeprecationHandler自动发Deprecation与Sunset头,下线分四步、Sunset 窗口给够(对外建议至少 6 个月)。 - 破坏性变更要么并行版本,要么走扩展-收缩三步走,核心是把「新增」与「删除」拆成两次发布。
接口的契约、生成、版本都稳住了,接下来该处理性能。8.1 会从缓存入手,讲清 book-loan 里哪些读多写少的数据该缓存、缓存注解的失效陷阱,以及为什么「加缓存」常常先带来一致性 bug 而不是性能提升。
阅读导航:上一节:7.2 契约先行与代码生成 · 下一节:8.1 Spring Cache 抽象 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。