《Spring Boot 实战》7.3 版本演进与兼容性

对比 URL、Header、媒体类型三种版本载体的取舍,讲清 Spring Boot 4 内置 API Versioning 的 spring.mvc.apiversion.* 属性、@GetMapping(version=...) 注解与 ApiVersionConfigurer 精细控制,给出向后兼容判定清单、Deprecation/Sunset 废弃流程与破坏性变更的并行发布策略。

本节目标:给 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.0URL 干净、版本与资源解耦不可见、浏览器不便手测、需在网关透传头
媒体类型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" 分别对应两个日期。

完整的下线流程分四步:

  1. 标记废弃:给版本设置 Deprecation 日期,开始发头。
  2. 观察迁移:在监控里统计「仍在用旧版本的调用方数量」,趋势不降就是有人在拖。
  3. 设定 Sunset:给一个明确的截止日期,并通过头、邮件、变更日志同时公告。
  4. 下线:到期后旧版本返回 410 Gone(或 404),并保留一段时间的重定向或说明。

Sunset 窗口要给够。对外部合作方,业内常见做法是至少 6 个月;纯内部服务可以短一些,但不应少于一个完整的发布周期。

7.3.7 破坏性变更的发布策略

当变更确实无法兼容时,有两种发布方式:

方式一:并行版本(推荐给对外接口)。 v1 与 v2 同时在线,各自独立维护,老客户端留在 v1 直到 Sunset。代价是要同时维护两套契约、两套实现、两套测试——这是真实成本,不要低估。

方式二:扩展-收缩(expand-contract,推荐给能推动调用方升级的场景)。 分三步走:

  1. 扩展:先加新字段/新路径,旧的照常工作(纯增量,兼容)。
  2. 迁移:推动调用方切到新形态,监控旧形态的调用量降到零。
  3. 收缩:确认无人使用后,再删除旧字段/旧路径。

扩展-收缩的关键在于**「删除」和「新增」拆成两次发布**,中间留出迁移窗口。它不需要真正的版本号,适合内部服务;一旦需要给外部调用方保证,还是得回到并行版本。

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 抽象 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计