Spring Boot 4 里给 API 版本控制设默认版本,配置键是 spring.mvc.apiversion.default,写成 spring.mvc.apiversion.default-version 不报错也不生效。这是我在 Boot 4.1.1(Framework 7.0.9)上实测的结果,6 个测试全部通过,curl 的对比输出放在后文。

Spring Framework 7 把 API 版本控制做进了 Spring MVC。在 @GetMapping 上写 version,再用 spring.mvc.apiversion.* 属性选好从请求头、查询参数还是路径段取版本,同一个接口的两个版本就能并存。下面把三种取法、缺版本号和版本号不支持的响应、RestClient 和 RestTestClient 怎么带版本都过一遍。

之前那篇介绍 Spring Boot 4.0 API 版本功能的文章写在快照阶段,讲的是 ApiVersionConfigurer 的几种写法。这篇换成 GA 版,看实际请求和响应。

接口写两个版本

@RequestMapping 及各个变体都多了一个 version 属性。同一路径写两个方法,各管一个版本:

@RestController
@Profile("!path")
public class BookController {

    @GetMapping(path = "/books/{id}", version = "1.0")
    BookV1 bookV1(@PathVariable String id) {
        return new BookV1(id, "Spring in Practice");
    }

    @GetMapping(path = "/books/{id}", version = "2.0")
    BookV2 bookV2(@PathVariable String id) {
        return new BookV2(id, "Spring in Practice", List.of("Alice", "Bob"));
    }
}

v1 只返回 id 和书名,v2 多一个作者列表。@Profile 是为了在一个项目里切换几种取版本的方式,路径段那种要换一套映射,后面会说。

用属性选取版本的方式

Boot 4.1.1 的 WebMvcProperties 里有个 Apiversion 内部类,从 jar 里的配置元数据能列出全部键:

spring.mvc.apiversion.default
spring.mvc.apiversion.detect-supported
spring.mvc.apiversion.required
spring.mvc.apiversion.supported
spring.mvc.apiversion.use.header
spring.mvc.apiversion.use.media-type-parameter
spring.mvc.apiversion.use.path-segment
spring.mvc.apiversion.use.query-parameter

三种方式各用一个 profile 配置文件:

# application-header.yaml
spring:
  mvc:
    apiversion:
      use:
        header: X-API-Version
# application-query.yaml
spring:
  mvc:
    apiversion:
      use:
        query-parameter: version
      default: "1.0"
# application-path.yaml
spring:
  mvc:
    apiversion:
      use:
        path-segment: 0

查询参数那套顺便配了默认版本。另外 application.yaml 里开了 spring.web.error.include-message: always,下面的错误响应里才看得到 message。Boot 4 默认不写这一项,旧的 server.error.include-message 在 Boot 4 里已经不生效,RestTestClient 那篇里实测过。

路径段方式要求版本是路径的一部分,所以映射得把这一段留出来。索引从 0 开始,/v1/books/42 的第 0 段就是 v1:

@RestController
@Profile("path")
public class PathBookController {

    @GetMapping(path = "/{version}/books/{id}", version = "1.0")
    BookV1 bookV1(@PathVariable String id) {
        return new BookV1(id, "Spring in Practice");
    }

    @GetMapping(path = "/{version}/books/{id}", version = "2.0")
    BookV2 bookV2(@PathVariable String id) {
        return new BookV2(id, "Spring in Practice", List.of("Alice", "Bob"));
    }
}

不想写属性的话,Java 配置也行,效果和 header 那套加默认版本一样:

@Configuration
@Profile("configurer")
public class ConfigurerVersioning implements WebMvcConfigurer {

    @Override
    public void configureApiVersioning(ApiVersionConfigurer configurer) {
        configurer.useRequestHeader("X-API-Version")
                .setDefaultVersion("1.0");
    }
}

curl 实测

打成 jar,用不同 profile 和端口同时起了五个实例。8081 是请求头,8082 是查询参数加默认版本,8083 是路径段,8084 和 8085 用来对比两个默认版本的写法:

J=target/api-versioning-demo-0.0.1-SNAPSHOT.jar
java -jar $J --spring.profiles.active=header --server.port=8081 > logs/app-header.log 2>&1 &
java -jar $J --spring.profiles.active=query  --server.port=8082 > logs/app-query.log 2>&1 &
java -jar $J --spring.profiles.active=path   --server.port=8083 > logs/app-path.log 2>&1 &
java -jar $J --spring.profiles.active=header --server.port=8084 --spring.mvc.apiversion.default-version=1.0 > logs/app-header-default-version.log 2>&1 &
java -jar $J --spring.profiles.active=header --server.port=8085 --spring.mvc.apiversion.default=1.0 > logs/app-header-default.log 2>&1 &

先看请求头方式:

$ curl -i -H 'X-API-Version: 1.0' http://localhost:8081/books/42
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice"}
$ curl -i -H 'X-API-Version: 2' http://localhost:8081/books/42
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice","authors":["Alice","Bob"]}

请求头里只写 2 也能命中 2.0。框架默认用语义化版本解析,次版本号和修订号没写就当 0。

不带版本号,和带一个没声明过的版本号,都是 400:

$ curl -i http://localhost:8081/books/42
HTTP/1.1 400 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.407Z","status":400,"error":"Bad Request","message":"API version is required.","path":"/books/42"}
$ curl -i -H 'X-API-Version: 3.0' http://localhost:8081/books/42
HTTP/1.1 400 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.417Z","status":400,"error":"Bad Request","message":"Invalid API version: '3.0.0'.","path":"/books/42"}

报错里的版本号是解析后的 3.0.0。支持哪些版本,默认从控制器上声明的 version 收集,这里只有 1.0 和 2.0。

查询参数这套配了默认版本 1.0,不带参数就落到 v1。1.5 没有声明过,照样 400:

$ curl -i 'http://localhost:8082/books/42?version=2.0'
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice","authors":["Alice","Bob"]}
$ curl -i http://localhost:8082/books/42
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice"}
$ curl -i 'http://localhost:8082/books/42?version=1.5'
HTTP/1.1 400 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.483Z","status":400,"error":"Bad Request","message":"Invalid API version: '1.5.0'.","path":"/books/42"}

路径段方式里,v1、v2 这种带 v 前缀的写法能直接解析。少了版本段的路径匹配不上任何映射,最后落到静态资源处理,返回 404:

$ curl -i http://localhost:8083/v2/books/42
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice","authors":["Alice","Bob"]}
$ curl -i http://localhost:8083/v3/books/42
HTTP/1.1 400 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.540Z","status":400,"error":"Bad Request","message":"Invalid API version: '3.0.0'.","path":"/v3/books/42"}
$ curl -i http://localhost:8083/books/42
HTTP/1.1 404 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.550Z","status":404,"error":"Not Found","message":"No static resource books/42.","path":"/books/42"}

默认版本的键叫 default

Java 字段叫 defaultVersion,配置方法叫 setDefaultVersion,很自然会写成 spring.mvc.apiversion.default-version。但源码里这个字段上标了 @Name("default"),真正的键是 spring.mvc.apiversion.default。写错的键不会报错,启动日志里也没有提示,就是不生效:

### header + spring.mvc.apiversion.default-version=1.0 (8084)
$ curl -i http://localhost:8084/books/42
HTTP/1.1 400 
Content-Type: application/json

{"timestamp":"2026-10-13T02:01:06.560Z","status":400,"error":"Bad Request","message":"API version is required.","path":"/books/42"}
### header + spring.mvc.apiversion.default=1.0 (8085)
$ curl -i http://localhost:8085/books/42
HTTP/1.1 200 
Content-Type: application/json

{"id":"42","title":"Spring in Practice"}

客户端怎么带版本

Framework 7 的 RestClient 有个 ApiVersionInserter,决定版本放进请求的哪里,和服务端的三种取法一一对应。defaultApiVersion 设默认值,单个请求用 apiVersion(...) 覆盖:

RestClient client = RestClient.builder()
        .baseUrl("http://localhost:" + port)
        .apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version"))
        .defaultApiVersion("1.0")
        .build();

BookV1 v1 = client.get().uri("/books/42").retrieve().body(BookV1.class);
BookV2 v2 = client.get().uri("/books/42").apiVersion("2.0").retrieve().body(BookV2.class);

路径段用 usePathSegment(0),请求写 /books/42,客户端会把版本插到第 0 段:

RestClient client = RestClient.builder()
        .baseUrl("http://localhost:" + port)
        .apiVersionInserter(ApiVersionInserter.usePathSegment(0))
        .build();

var response = client.get().uri("/books/42").apiVersion("v2").retrieve().toEntity(BookV2.class);

测试里的 RestTestClient 也是同一套 API。Boot 注入的那个实例没配 inserter,用 mutate() 加上就行:

RestTestClient client = restTestClient.mutate()
        .apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version"))
        .build();

client.get().uri("/books/42").apiVersion("2.0")
        .exchange()
        .expectStatus().isOk()
        .expectBody().jsonPath("$.authors[0]").isEqualTo("Alice");

缺版本和版本不支持的两种 400 也写进了测试,断言的就是上面 curl 看到的 message:

@Test
void missingAndUnsupportedVersionsAreBadRequests() {
    restTestClient.get().uri("/books/42")
            .exchange()
            .expectStatus().isBadRequest()
            .expectBody().jsonPath("$.message").isEqualTo("API version is required.");

    RestTestClient client = restTestClient.mutate()
            .apiVersionInserter(ApiVersionInserter.useHeader("X-API-Version"))
            .build();
    client.get().uri("/books/42").apiVersion("3.0")
            .exchange()
            .expectStatus().isBadRequest()
            .expectBody().jsonPath("$.message").isEqualTo("Invalid API version: '3.0.0'.");
}

mvn test 的输出,header、path、query 三个测试类连真端口,configurer 那个跑在 MockMvc 上:

RestClient header v1=BookV1[id=42, title=Spring in Practice] v2=BookV2[id=42, title=Spring in Practice, authors=[Alice, Bob]]
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.088 s -- in com.example.apiver.HeaderVersionTest
RestClient path v2=BookV2[id=42, title=Spring in Practice, authors=[Alice, Bob]]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.161 s -- in com.example.apiver.PathVersionTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.187 s -- in com.example.apiver.ConfigurerVersionTest
RestClient query v2=BookV2[id=42, title=Spring in Practice, authors=[Alice, Bob]] no-version=BookV1[id=42, title=Spring in Practice]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.133 s -- in com.example.apiver.QueryVersionTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

环境是 Debian 13、OpenJDK 21.0.12.1、Maven 3.9.9、curl 8.14.1,Spring Boot 4.1.1,Spring Framework 7.0.9。

官方文档:Spring Framework API Versioning、Spring Boot API Versioning。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。