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 个端到端用例全绿,全部数字来自同一次运行,我留了底。

https://static.xiongneng.me/apiversion-sequence-flow-20260922053205.png

编程模型与事实清单

动笔前我把相关制品全部解包核对过: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 个属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public @interface RequestMapping {
    String name();
    String[] value();
    String[] path();
    RequestMethod[] method();
    String[] params();
    String[] headers();
    String[] consumes();
    String[] produces();
    String version();          // 版本声明在这里
}

@GetMapping、@PostMapping 这些组合注解都继承这组属性,所以 controller 上直接写 @GetMapping(value = "/orders", version = "1") 就行,不需要另贴注解。version 的值支持两种写法:"2" 表示精确匹配版本 2,"2+" 表示 2 与更高的版本都归这个方法。说实话我第一次看到这个设计还挺意外,版本能力居然藏在每个映射注解里,而不是一个独立的开关,后面你会看到这个选择带来的好处。

第二个事实是版本解析的扩展点位置。相关类型集中在 org.springframework.web.accept 包(spring-web 7.0.9 实证):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ApiVersionResolver            接口:String resolveVersion(HttpServletRequest)
HeaderApiVersionResolver      从请求头取版本
QueryApiVersionResolver       从查询参数取版本
MediaTypeParamApiVersionResolver  从 media type 参数取版本
PathApiVersionResolver        从路径段取版本
ApiVersionStrategy            接口:解析、校验、默认版本、废弃提示的总入口
DefaultApiVersionStrategy     唯一内置实现
ApiVersionParser              接口:字符串转版本对象
SemanticApiVersionParser      默认实现,产出 Version(major, minor, patch)
ApiVersionHolder              每个请求的版本载体,挂在 request attribute 上
ApiVersionDeprecationHandler  版本废弃提示的回调接口
MissingApiVersionException    无版本且必填时抛出
InvalidApiVersionException    版本非法时抛出,NotAcceptableApiVersionException 继承它

路由侧还有一个类: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(均无默认值):

1
2
3
4
5
6
7
8
spring.mvc.apiversion.default                    String    无版本请求按这个版本处理
spring.mvc.apiversion.required                   Boolean   每个请求是否必须带版本
spring.mvc.apiversion.supported                  List<String>  受支持的版本白名单
spring.mvc.apiversion.detect-supported           Boolean   是否从 controller 映射探测受支持版本
spring.mvc.apiversion.use.header                 String    用指定名字的请求头取版本
spring.mvc.apiversion.use.query-parameter        String    用指定名字的查询参数取版本
spring.mvc.apiversion.use.media-type-parameter   Map<MediaType, String>  用 media type 参数取版本
spring.mvc.apiversion.use.path-segment           Integer   用指定下标的路径段取版本

装配链路是这样的:WebMvcAutoConfiguration 的内部适配器读取这组属性,逐条调到 ApiVersionConfigurer 上(org.springframework.web.servlet.config.annotation 包,提供 useRequestHeader、useQueryParam、setDefaultVersion、addSupportedVersions 等方法),最终构建出 DefaultApiVersionStrategy。你在工程里声明为 bean 的 ApiVersionResolver、ApiVersionParser、ApiVersionDeprecationHandler 也会被一并收集进去,自定义解析路线与内置属性可以混用。还有一个容易忽略的点:属性清单里没有 enabled 开关,配了 use.* 或显式实现 WebMvcConfigurer 的 configureApiVersioning,版本化路由才会生效。

https://static.xiongneng.me/apiversion-config-wiring-20260922053041.png

依赖与配置

依赖零新增,这点我很满意。API 版本能力全部在 spring-boot-starter-webmvc 带入的 spring-web 与 spring-webmvc 里,测试用 webmvc 专属测试 starter:

1
2
3
4
5
6
7
8
9
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
</dependency>

版本号全部交给 spring-boot-starter-parent 4.1.1 的 BOM 管理,pom 里一个版本都不出现。我对比过第 1 篇 restful 工程的依赖面,两边完全一致,接了版本管理的工程没有多出任何 jar。

application.yml 是本篇的主角,三条配置决定全部行为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
server:
  port: ${SERVER_PORT:18220}

spring:
  application:
    name: springboot4-apiversion
  mvc:
    problemdetails:
      enabled: true
    apiversion:
      default: 1
      required: false
      supported: 1, 2, 3
      use:
        header: X-Api-Version
        query-parameter: api-version

logging:
  level:
    com.xncoding.apiversion: DEBUG

use.header 与 use.query-parameter 同时配置时,框架按顺序尝试两条路线,任一路线取到版本即停。default: 1 声明无版本请求按 v1 处理,required: false 允许版本缺席。这两条必须成对出现,单独配 default 会出什么幺蛾子,我在避坑指南里给你展开。supported: 1, 2, 3 声明白名单,校验规则我在实测小节逐条验证过。

核心代码:同路径两套 controller

订单领域模型与业务服务只有一份。Order 持有全部字段,OrderService 提供查询与建单,两个版本的 controller 都调它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Service
public class OrderService {

    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public Order getById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("订单不存在: " + id));
    }

    public List<Order> findAll() {
        return repository.findAll();
    }
}

版本分叉停在这一层的上面。v1 与 v2 的差异全部用 DTO 表达:v1 是完整快照,字段平铺;v2 把客户聚成对象、金额拆成明细。service 与 repository 对版本一无所知,订单的存储、计算、状态流转只有一套逻辑。

v1 controller 用类级 @RequestMapping 声明路径与版本,方法级 @GetMapping 只管子路径:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping(value = "/api/orders", version = "1")
public class OrderControllerV1 {

    private final OrderService service;

    public OrderControllerV1(OrderService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public OrderResponseV1 detail(@PathVariable Long id) {
        return toDto(service.getById(id));
    }

    static OrderResponseV1 toDto(Order order) {
        return OrderResponseV1.of(order.getId(), order.getOrderNo(),
                order.getCustomerName(), order.getCustomerPhone(),
                order.getUnitPrice(), order.getQuantity(), order.getDiscount(),
                order.getShippingFee(), order.getTotalAmount(),
                order.getStatus(), order.getCreatedAt());
    }
}

v2 controller 与 v1 的差别只有两处:version 属性换成 “2”,DTO 换成 OrderResponseV2。路径 /api/orders 完全相同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RestController
@RequestMapping(value = "/api/orders", version = "2")
public class OrderControllerV2 {

    private final OrderService service;

    public OrderControllerV2(OrderService service) {
        this.service = service;
    }

    @GetMapping("/{id}")
    public OrderResponseV2 detail(@PathVariable Long id) {
        return toDto(service.getById(id));
    }

    static OrderResponseV2 toDto(Order order) {
        return OrderResponseV2.of(order.getId(), order.getOrderNo(),
                order.getCustomerName(), order.getCustomerPhone(),
                order.getUnitPrice(), order.getQuantity(), order.getDiscount(),
                order.getShippingFee(), order.getTotalAmount(),
                order.getStatus(), order.getCreatedAt());
    }
}

到这里你先记住一个结论:版本只影响路由,不影响业务。两个 controller 是两扇门,门后的房间相同。我把版本分叉点停在接口层,理由很实际:业务规则一旦按版本分叉,两个版本会各自演化出独立的缺陷与修复节奏,每加一个功能要写两遍;DTO 层的分叉成本只是多一个映射方法,字段差异被限制在序列化边界上。开头那段财务对账系统的诉求,靠 v1 DTO 原样保留就能满足,对账那边的代码一行不用动。

两个 DTO 的形状对比着看。v2 在 v1 的基础上聚了客户、拆了金额,内部结构三层:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class OrderResponseV2 {

    private Long id;
    private String orderNo;
    private Customer customer;      // 客户聚合:name + phone
    private List<AmountItem> items; // 金额明细:三行 label + value
    private Amount amount;          // 金额汇总:四个分量

    public static class Customer {
        private String name;
        private String phone;
    }

    public static class AmountItem {
        private String label;
        private BigDecimal value;
    }

    public static class Amount {
        private BigDecimal goodsAmount;
        private BigDecimal discountAmount;
        private BigDecimal shippingFee;
        private BigDecimal totalAmount;
    }
}

Customer、AmountItem、Amount 三个静态内部类只被 v2 响应体引用,v1 完全不知道它们的存在。明细行的 label(折扣优惠、运费)由 toDto 映射方法组装,客户端不需要自己拿单价乘数量再算折扣,对账口径由服务端统一给出。新版 App 要的「金额构成三行摆开」,就是 items 数组里那三条。

https://static.xiongneng.me/apiversion-class-diagram-20260922053215.png

版本范围与版本注入

v2 之后如果还要出 v3、v4,响应结构不变,只是内部实现调整,逐个建新 controller 就没必要了。version 属性支持基线写法,"2+" 表示 2 与更高的版本都路由到这个方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@RestController
public class VersionProbeController {

    @GetMapping(value = "/api/version/echo", version = "2+")
    public Map<String, Object> echo(Version version) {
        return Map.of(
                "resolved", version.toString(),
                "major", version.getMajor(),
                "minor", version.getMinor(),
                "patch", version.getPatch()
        );
    }
}

方法参数 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,结果我逐条实录。先看正常路由:

1
2
3
4
1. 不带任何版本        → 200  v1 快照结构(default: 1 生效)
2. X-Api-Version: 1    → 200  v1 快照结构
3. X-Api-Version: 2    → 200  v2 拆分结构
4. ?api-version=2      → 200  v2 拆分结构(与 header 等价)

v1 与 v2 的响应体实录如下,订单号与总金额两侧一致,结构与字段名互斥。v1 的完整快照:

v2 把客户聚成 customer 对象,金额拆成 items 数组与 amount 子结构:

对账系统只认 v1 的平铺结构,新版 App 消费 v2 的明细结构,两条请求打到同一个 URL,落进不同的 controller,取出来的是同一行数据。这就是我想要的那个效果,也是当初折腾一周想一次性解决的问题。

https://static.xiongneng.me/apiversion-response-panels-20260920150453.png

错误路径的实测结果:

1
2
3
5. X-Api-Version: abc       → 400  {"detail":"Invalid API version: 'abc'.", "status":400}
6. X-Api-Version: 9         → 400  {"detail":"Invalid API version: '9.0.0'.", "status":400}
7. X-Api-Version: 3 打订单   → 400  {"detail":"Invalid API version: '3.0.0'.", "status":400}

三条 400 的响应体都是 RFC 9457 problem+json(problemdetails.enabled 开启的效果)。abc 是解析失败,9 是白名单外的合法语义版本。3 号挺有意思,值得单独说:supported 里明明声明了它,但订单接口只映射了 1 和 2,框架校验发现版本 3 没有任何映射愿意接受,按非法版本处理。我第一眼看到这个结果也愣了一下,反应过来之后就服气了:白名单管准入,映射管路由,两道闸各管各的。

版本范围的探针结果:

1
2
3
4
8. X-Api-Version: 3 打 echo(2+)   → 200  {"resolved":"3.0.0","major":3,"minor":0,"patch":0}
9. X-Api-Version: 2 打 echo(2+)   → 200
10. X-Api-Version: 2.4.1 打 echo    → 400  白名单精确匹配,小版本不自动包含
11. X-Api-Version: 1 打 echo(2+)  → 404  不满足基线,且无其他映射

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+
1v1 快照结构404
2v2 拆分结构200,回显 2.0.0
3400,白名单内无映射200,回显 3.0.0
2.4.1400,白名单外400,白名单外
abc / 9400,解析失败或白名单外400,同左

https://static.xiongneng.me/apiversion-dto-split-20260922053045.png

需要下线时:弃用通知与自定义解析

版本管理落地后马上会遇到的下一个需求是下线 v1:老客户端不会主动迁移,服务端得有办法把「这个版本要废弃了」的信号带给调用方。Framework 7 内置了标准实现,声明一个 bean 就接上,我照官方的做法配了一份:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Configuration
public class ApiVersionDeprecationConfig {

    @Bean
    public ApiVersionDeprecationHandler apiVersionDeprecationHandler() {
        StandardApiVersionDeprecationHandler handler =
                new StandardApiVersionDeprecationHandler();
        handler.configureVersion("1")
                .setDeprecationDate(ZonedDateTime.of(2026, 10, 1, 0, 0, 0, 0,
                        ZoneId.of("Asia/Shanghai")))
                .setDeprecationLink(URI.create(
                        "https://example.com/docs/api-v2-migration"))
                .setSunsetDate(ZonedDateTime.of(2027, 4, 1, 0, 0, 0, 0,
                        ZoneId.of("Asia/Shanghai")));
        return handler;
    }
}

configureVersion 的返回值是 VersionSpec,弃用日期、迁移文档、下线日期都在它上面链式设置,返回值类型是 ApiVersionDeprecationHandler 的 handler 本体,这一段的收尾要单独 return(链式一路点到底编译不过,VersionSpec 不继承 handler)。这里我提醒一句,我差点就顺着链式写法一路点下去,IDE 没报错是因为方法返回的确实是 handler 本身,真正编译报错的位置在下一行。Boot 的自动配置用 ObjectProvider 收集这个 bean,挂进 DefaultApiVersionStrategy。

实测效果,请求带 X-Api-Version: 1 时响应头多出三行:

1
2
3
4
HTTP/1.1 200
Deprecation: @1790784000
Link: <https://example.com/docs/api-v2-migration>; rel="deprecation"; type="text/html"
Sunset: Thu, 1 Apr 2027 00:00:00 +0800

三个头的格式来自 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 断言状态码与响应体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ApiVersionEndToEndTest {

    @LocalServerPort
    private int port;

    private RestTestClient client;

    @BeforeEach
    void setUp() {
        client = RestTestClient.bindToServer()
                .baseUrl("http://localhost:" + port)
                .build();
    }

    @Test
    void headerRouteSelectsVersion() {
        String v1 = client.get().uri("/api/orders/1")
                .header("X-Api-Version", "1")
                .exchange()
                .expectStatus().isEqualTo(HttpStatusCode.valueOf(200))
                .expectBody(String.class)
                .returnResult().getResponseBody();
        assertThat(v1).contains("customerPhone").contains("unitPrice");

        String v2 = client.get().uri("/api/orders/1")
                .header("X-Api-Version", "2")
                .exchange()
                .expectStatus().isEqualTo(HttpStatusCode.valueOf(200))
                .expectBody(String.class)
                .returnResult().getResponseBody();
        assertThat(v2).contains("\"customer\":{\"name\"").contains("\"items\"");
    }
}

headerRouteSelectsVersion 断言两件事:同一路径上版本 1 落 v1 结构、版本 2 落 v2 结构,两边字段名互斥。另外 7 个用例分别覆盖默认版本回落、query 路线、非法版本 400、白名单外 400、白名单内无映射 400、范围匹配四种组合、两版本列表并存。

mvn test 一次跑完的 surefire 对账:

1
2
ApiVersionEndToEndTest    8    4.062s    默认版本 / 双路线路由 / 白名单 / 范围 / 并存
合计                      8    BUILD SUCCESS

应用日志 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 的存量流量统计跑起来,再补一篇下线实操。

参考链接