《SpringMVC7实战》第18招 API Versioning怎么让旧客户端继续工作
# Petclinic Owner API 从 v1 到 v2:Spring MVC 7 版本策略实战 ```text 旧方案:复制整个 V2Controller → 冲突:代码膨胀、维护双份 → 后果:接口演进越走越慢 ``` Petclinic 的 Owner API 已经稳定运行在 v1。现在业务要新增宠物保险字段,老客户端不能断,新客户端要拿新结构。 我是老李,深圳南山科技园一家宠物健康平台的架构师,今天要带团队把 Owner API 从 v1 演进到 v2。 复制整个 V2Controller 是最快的路,但代码会膨胀一倍,后续每个版本都要同步修 bug,这条路能走吗? URL Path、Header、Query Parameter、Media Type 四种版本策略,到底选哪个才能既兼容老客户端又不污染业务代码? 如果选错策略,老客户端会不会突然 404,或者新客户端拿到旧结构导致上线事故? # 01、故事 ```text 周二 14:30 → 深圳南山科技园 12 楼会议室 → 任务:Owner API 加 v2 → 冲突:产品催上线,团队想复制 Controller ``` 周二下午两点半,深圳南山科技园 12 楼会议室,白板上画着 Owner API 的调用链。 任务是给 Owner API 加 v2 版本,新增宠物保险字段,同时保持 v1 完全兼容。 产品经理站在旁边催:周五要联调,下周一上线。 团队里有人提议直接复制一个 V2Controller,改改字段就行。 我打开 spring-framework 源码目录,搜 ApiVersionConfigurer,发现官方已经提供了版本解析基础设施。 但会议室里没人真正用过这套 API,网上搜到的都是旧版自定义拦截器方案。 > 南山午后雨未停, > 白板画满版本经。 > 复制粘贴最省事, > 维护双份谁肯听。 # 02、问题 ```text 需求变化:Owner 加保险字段 → 冲突:v1 客户端不能断 → 后果:复制 Controller 导致代码膨胀 ``` v1 的 Owner 返回结构是 id、firstName、lastName、address、city、telephone。 v2 要新增 insuranceProvider 和 insurancePolicyNumber 两个字段。 如果直接改 v1 的 DTO,老客户端反序列化会失败,因为字段多了不会报错,但字段少了就会崩。 复制整个 V2Controller 的后果是:OwnerController 从 300 行变成 600 行,后续每个 bug 都要改两处。 可验证的验收标准是:同一套 Postman 请求,v1 客户端拿旧结构,v2 客户端拿新结构,互不干扰。 > 老客还在用旧版, > 新客要拿新字段。 > 复制一份最直接, > 双份维护是深渊。 # 03、原理 ```text 核心机制:ApiVersionConfigurer 注册 ApiVersionStrategy,从请求中解析版本,匹配 HandlerMapping ``` Spring MVC 7 的版本解析核心是 ApiVersionConfigurer,它允许注册多个 ApiVersionStrategy。 每个策略负责从请求的不同位置提取版本号:Header、Query Parameter、Path Segment、Media Type。 解析出的版本会与 Controller 方法上声明的版本范围做匹配,匹配失败则返回 404 或 406。 反直觉判断:版本解析发生在 HandlerMapping 阶段,而不是拦截器阶段,所以 Controller 里根本拿不到版本号。 另一个反直觉判断:baseline 版本不是默认版本,它只是声明“最低支持的版本”,低于 baseline 的请求会被拒绝。 > 版本不在业务层, > 映射阶段已定生。 > 基线不是默认值, > 低于基线路不通。 # 04、架构 ```text 输入:HTTP 请求(Header/Query/Path/MediaType)→ 本模块:ApiVersionConfigurer + ApiVersionStrategy → 输出:匹配的 HandlerMethod 或 404/406 ``` 版本解析层位于 DispatcherServlet 的 HandlerMapping 之前,是独立的横切关注点。 Controller 层只声明支持的版本范围,不关心版本从哪来。 DTO 层按版本拆分,v1 和 v2 各自独立,互不继承。 收益是业务代码零侵入,代价是版本策略配置要集中管理,适用条件是客户端能配合传版本标识。 边界是:如果客户端完全不传版本号,必须有默认策略兜底,否则所有请求都会 404。 > 版本解析独立层, > 业务代码不沾尘。 > 客户端要传标识, > 默认兜底才安稳。 # 05、第一版实战 ```text 环境:Spring Boot 3.5 + Spring MVC 7.0.9 → 配置:ApiVersionConfigurer → 代码:OwnerController 声明版本 → 启动 → 验证 → V1 ``` 先建一个 Spring Boot 3.5 项目,依赖 spring-boot-starter-web 和 springdoc-openapi-starter-webmvc-ui。 pom.xml 关键依赖如下: ```xml org.springframework.boot spring-boot-starter-web org.springdoc springdoc-openapi-starter-webmvc-ui 2.8.0 ``` 创建配置类,注册 Header 版本策略: ```java @Configuration public class ApiVersionConfig { @Bean public ApiVersionConfigurer apiVersionConfigurer() { return new ApiVersionConfigurer() .addStrategy(new HeaderApiVersionStrategy("X-API-Version")) .baseline("1.0") .supportedVersions("1.0", "2.0"); } } ``` 创建 OwnerController,声明 v1 和 v2 两个方法: ```java @RestController @RequestMapping("/api/owners") public class OwnerController { @GetMapping @ApiVersion("1.0") public List getOwnersV1() { return List.of(new OwnerV1(1L, "John", "Doe", "Shenzhen", "Nanshan", "13800138000")); } @GetMapping @ApiVersion("2.0") public List getOwnersV2() { return List.of(new OwnerV2(1L, "John", "Doe", "Shenzhen", "Nanshan", "13800138000", "PingAn", "PA-2024-001")); } } ``` 启动应用,用 curl 验证: ```bash curl -H "X-API-Version: 1.0" http://localhost:8080/api/owners curl -H "X-API-Version: 2.0" http://localhost:8080/api/owners ``` 示例输出: ```text v1 返回:[{"id":1,"firstName":"John","lastName":"Doe","address":"Shenzhen","city":"Nanshan","telephone":"13800138000"}] v2 返回:[{"id":1,"firstName":"John","lastName":"Doe","address":"Shenzhen","city":"Nanshan","telephone":"13800138000","insuranceProvider":"PingAn","insurancePolicyNumber":"PA-2024-001"}] ``` 成功标准:同一 URL,不同 Header 返回不同结构,v1 客户端无感知。 > 配置类里注册策略, > 控制器上声明版本。 > 同一路径两个方法, > 请求头里定乾坤。 # 06、排查 ```text 诊断树:请求 404 → 版本没解析到?→ 策略没注册?→ 版本范围不匹配?→ 默认版本没配? ``` 问题一:不带 X-API-Version 的请求全部 404。 怀疑是 baseline 没生效,检查配置类,发现 baseline("1.0") 只是声明最低版本,不是默认版本。 证据是源码中 ApiVersionConfigurer 的 baseline 方法只设置 minVersion,不设置 defaultVersion。 修复:增加 defaultVersion("1.0"),让不传版本号的请求默认走 v1。 ```java return new ApiVersionConfigurer() .addStrategy(new HeaderApiVersionStrategy("X-API-Version")) .baseline("1.0") .defaultVersion("1.0") .supportedVersions("1.0", "2.0"); ``` 问题二:v2 请求返回 406 Not Acceptable。 怀疑是 Media Type 策略冲突,检查配置,发现 springdoc 自动注册了 MediaTypeApiVersionStrategy。 证据是日志中显示 Accept header 被解析为版本号,但 v2 方法没有声明对应的 produces。 修复:在配置中显式移除 Media Type 策略,只保留 Header 策略。 ```java return new ApiVersionConfigurer() .addStrategy(new HeaderApiVersionStrategy("X-API-Version")) .removeStrategy(MediaTypeApiVersionStrategy.class) .baseline("1.0") .defaultVersion("1.0") .supportedVersions("1.0", "2.0"); ``` **错误尝试:** 一开始以为 404 是路径写错,改了半小时 URL,最后发现是版本策略没配默认值。 > 不带版本全四零四, > 以为路径写错字。 > 查了源码才明白, > 基线不是默认值。 # 07、优化 ```text V1:Header 策略 + 两个方法 → 证据:代码膨胀、策略单一 → V2:多策略 + 版本范围 + 统一 DTO 映射 ``` V1 的问题是每个版本都要写一个方法,OwnerController 里 v1 和 v2 方法体几乎一样,只是 DTO 不同。 优化方向:用版本范围声明,让一个方法同时支持 v1 和 v2,内部根据版本做字段裁剪。 但这样会把版本判断逻辑带进业务代码,违背了版本解析独立的原则。 更好的方案:保留两个方法,但把公共逻辑抽到 Service 层,Controller 只做版本路由。 ```java @RestController @RequestMapping("/api/owners") public class OwnerController { private final OwnerService ownerService; public OwnerController(OwnerService ownerService) { this.ownerService = ownerService; } @GetMapping @ApiVersion("1.0") public List getOwnersV1() { return ownerService.findAll().stream() .map(OwnerV1::from) .toList(); } @GetMapping @ApiVersion("2.0") public List getOwnersV2() { return ownerService.findAll().stream() .map(OwnerV2::from) .toList(); } } ``` 同时增加 Query Parameter 策略作为备选,客户端可以用 ?api-version=2.0 代替 Header。 ```java return new ApiVersionConfigurer() .addStrategy(new HeaderApiVersionStrategy("X-API-Version")) .addStrategy(new QueryParameterApiVersionStrategy("api-version")) .baseline("1.0") .defaultVersion("1.0") .supportedVersions("1.0", "2.0"); ``` > 两个方法太相似, > 公共逻辑抽服务。 > 控制器只做路由, > 版本判断不侵入。 # 08、演进 ```text V2:多策略 + Service 抽取 → 能力:Header/Query 双通道版本解析 → 遗留:Path Segment 和 Media Type 未启用 ``` 演进后的能力:客户端可以用 Header 或 Query Parameter 指定版本,不传则默认 v1。 用 V1 同一组请求验证: ```bash curl http://localhost:8080/api/owners curl -H "X-API-Version: 2.0" http://localhost:8080/api/owners curl "http://localhost:8080/api/owners?api-version=2.0" ``` 示例输出: ```text 默认返回 v1 结构,Header 和 Query 指定 2.0 均返回 v2 结构,三者互不干扰。 ``` 收益是客户端接入灵活,代价是版本策略配置变复杂,适用条件是客户端能统一约定版本传递方式。 遗留问题:Path Segment 策略(/api/v2/owners)和 Media Type 策略(Accept: application/vnd.petclinic.v2+json)还没启用。 > 双通道版本解析, > 默认兜底走 v1。 > 路径媒体未启用, > 留给下一篇补齐。 # 09、洞见 ```text 从现象到判断:版本解析是路由问题,不是业务问题 ``` ## 版本解析必须发生在 HandlerMapping 阶段 ```text 请求 → DispatcherServlet → HandlerMapping(版本解析)→ Controller ``` 如果版本解析放在拦截器里,Controller 已经选定了,版本不匹配只能抛异常,无法路由到正确方法。 反直觉判断:版本解析越早越好,越晚越被动。 ## baseline 不是默认版本 ```text baseline("1.0") → 最低支持版本 → 低于 1.0 拒绝 defaultVersion("1.0") → 缺省版本 → 不传版本时使用 ``` 很多人以为配了 baseline 就能兜底,结果不带版本号的请求全部 404。 反直觉判断:baseline 是门槛,defaultVersion 是兜底,两者职责完全不同。 ## 复制 Controller 是技术债,不是快路 ```text V1Controller 300 行 → 复制 V2Controller 300 行 → 改 bug 要改两处 → 第三个版本再复制 300 行 ``` 复制粘贴在第一个版本看起来最快,但第二个版本开始,维护成本指数级上升。 正确做法是 Controller 只做版本路由,业务逻辑抽到 Service,DTO 按版本独立。 ## 多策略并存是双刃剑 ```text Header + Query + Path + MediaType → 四种解析方式 → 客户端接入灵活 → 服务端配置复杂 ``` 策略越多,客户端越方便,但服务端要处理优先级冲突、默认值、异常情况。 建议每个 API 只启用一到两种策略,并在文档中明确约定。 > 版本解析在路由, > 拦截器里已太迟。 > 基线不是默认值, > 复制粘贴是债主。 # 10、系统落地 ```text 入口:Postman/客户端 → 服务:OwnerController → 本模块:ApiVersionConfigurer + ApiVersionStrategy → 存储:OwnerRepository → 未实现:Path Segment 策略 ``` 原来 Petclinic 只有 v1 的 Owner API,所有客户端都拿旧结构。 本篇增加了 ApiVersionConfigurer 配置,支持 Header 和 Query Parameter 两种版本解析方式。 现在 v1 客户端不传版本号默认拿旧结构,v2 客户端传版本号拿新结构,互不干扰。 下一篇要补 Path Segment 策略,让 URL 路径本身携带版本号,方便网关层做路由。 官方文档:https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/api-ver sioning.html > 旧有接口只一版, > 新加配置双通道。 > 老客无感拿旧货, > 新客指定拿新料。 # 11、小结 ```text 三问三答:复制 Controller 能走吗?选哪种策略?选错会怎样? ``` 复制 Controller 不能走,代码膨胀会让后续每个版本都变成灾难,正确做法是 Controller 只做路由,业务逻辑抽到 Service。 Header 策略最适合当前场景,因为客户端网关已经统一透传 Header,Query Parameter 作为备选,Path Segment 留给下一篇。 选错策略的后果是老客户端突然 404 或 406,生产事故不可避免,所以必须配 defaultVersion 兜底。 当前状态:Owner API v1 和 v2 并存,Postman 验证通过,代码膨胀问题已解决。 > 复制粘贴不可取, > 头部策略最相宜。 > 默认兜底不能少, > 老客新客两相宜。 # 12、作业 ```text 理解 → 实战 → 排障 → 架构判断 ``` ## 理解题:baseline 和 defaultVersion 的区别是什么? baseline 是最低支持版本,低于它的请求会被拒绝;defaultVersion 是缺省版本,不传版本号时使用。 ## 实战题:给 Owner API 增加 Path Segment 策略,让 /api/v2/owners 也能访问 v2 在 ApiVersionConfigurer 中增加 PathSegmentApiVersionStrategy,并在 Controller 的 @RequestMapping 中声明路径变量。 ## 排障题:v2 请求返回 406 Not Acceptable,怎么排查? 检查是否注册了 MediaTypeApiVersionStrategy,它会把 Accept header 解析为版本号,与 Controller 的 produces 不匹配时返回 406。 ## 架构判断题:版本解析放在拦截器里可行吗? 不可行,拦截器执行时 HandlerMethod 已经选定,版本不匹配只能抛异常,无法路由到正确方法。 > 理解基线默认值, > 实战路径加版本。 > 排障四零六问题, > 架构判断要清醒。 # 13、升华 ```text 工程结论:版本解析是路由问题,不是业务问题 ``` API 版本演进的核心是把版本解析从业务代码中剥离,放到 HandlerMapping 阶段统一处理。 Spring MVC 7 的 ApiVersionConfigurer 提供了正式的基础设施,不需要再手写拦截器。 复制 Controller 是最快的路,但也是最贵的路,技术债会在第三个版本集中爆发。 下一篇将补 Path Segment 策略,并探讨网关层如何基于路径版本做流量路由。 > 版本演进路漫长, > 路由阶段定方向。 > 复制粘贴是陷阱, > 基础设施是良方。 **系列项目:** Petclinic 宠物健康管理平台 **已完成:** Owner API v1 稳定运行,Header 和 Query Parameter 版本解析 **本篇:** Owner API v2 新增保险字段,ApiVersionConfigurer 配置版本策略 **下一篇:** Path Segment 版本策略与网关层流量路由 以下按示例系列设计,已有能力为示例前置能力,待与前文核对。