接手过一批老接口之后,我对 Swagger 多版本 API 这件事有了完全不同的理解。项目跑了一年多,移动端、网页端、合作方都在调同一个接口,某天产品突然说要加新字段,还要求不能破坏现有调用方。那一刻才意识到,多版本 API 不是一个“加分项”,而是每一个长期维护的系统迟早要面对的生存问题。文档层面更是如此,Swagger 如果只给你一锅炖的接口列表,前端、测试、外部对接方全都得分心去猜哪个版本能用、哪个版本该废弃。
这篇文章我想从实际改造经验出发,把 Swagger 多版本支持的完整链路讲清楚。包括版本策略怎么定、Springdoc/Springfox 配置怎么做、多服务场景下的文档聚合、以及几个上线前必须避开的坑。适合正在维护老接口、或者准备在项目中落地多版本 API 的后端开发者阅读。
1. 先厘清需求:多版本 API 到底在解决什么问题
1.1 一个老系统产生的版本冲突
很多项目的接口刚上线时是“裸奔”的,没有版本号。比如 GET /api/orders,一开始返回的数据结构很简单,后来业务复杂了,要加字段、改字段类型、调整关联关系。如果你直接在原接口上改,问题立刻出现:老客户端按旧结构解析,拿到新结构直接崩;合作方服务调用的字段名变了,联调时才发现;就算你只加字段不改旧字段,某些客户端用的是白名单校验,多出来的字段也会触发签名失败。
所以说到底,多版本 API 的本质是在“新需求必须上线”和“旧调用方不能断”之间,给系统留出过渡空间。这个过渡不是说“我再开一个新接口”就够了,得有一整套机制保证新旧结构互不影响,同时让所有协作者清楚知道:现在有哪几个版本、每个版本的契约是什么、什么时候下线。
1.2 版本策略三选一:动手前先做决策
多版本 API 的“承载方式”业界一般有三种,常见到我闭着眼都能背出来:
| 策略 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径版本号 | GET /api/v2/orders |
直观,文档、网关、监控、日志都能天然区分,调用方几乎不会搞错 | 路径前缀需要长期维护,老版本的路径不能随便删 |
| 请求头版本号 | GET /api/orders + X-API-Version: v2 |
URL 保持不变,路径简洁 | 不直观,调试和文档展示都麻烦,调用方容易漏传 header |
| Query 参数版本号 | GET /api/orders?version=v2 |
实现最简单,后端取值方便 | 缓存不友好、日志不友好、参数污染 URL,基本不推荐 |
我个人的建议是:对外部公开接口,能走 URL 路径版本号就走路径;内部服务间调用,可以用 header 做次要版本兼容(比如主版本走路径,小范围的向后兼容字段通过 header 开关控制)。热搜词里出现“restful api接口规范”,说明很多人还在纠结这个问题,路径版本号就是目前最符合 REST 风格直觉的答案。
1.3 为什么路径版本号成为主流
路径版本号能成为主流,不是因为“大家都这么写”,而是它把版本区分从“业务代码”下沉到了“基础设施层”。举个例子,网关做灰度路由时,只需要按前缀匹配把 /api/v1/** 导到老服务,把 /api/v2/** 导到新服务,完全不关心里面业务逻辑怎么变。监控平台统计接口错误率、响应时间时,/api/v1/orders 和 /api/v2/orders 天然是两条曲线,定位问题快得多。调用方排障时,从日志里复制一个 URL 出来,一眼就能看出调的是哪个版本的接口,不用再去翻代码确认要不要带某个 header。
这也是为什么后面我们要把 Swagger 分组跟“路径前缀”绑定在一起,而不是只按包名或注解来分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Springdoc 分组配置:一套代码生成两份独立文档
2.1 选型:为什么我推荐 Springdoc 而不是 Springfox
如果你是新项目,我建议直接用 Springdoc,别犹豫。Springfox 已经很久没有实质更新了,对 OpenAPI 3 的支持一直不算完整,而且和 Spring Boot 2.6+ 的路径匹配策略有兼容性问题(后面我会专门讲这个坑)。Springdoc 基于 OpenAPI 3 规范,同时支持 Swagger UI,社区活跃,Spring Boot 3 和 Spring Boot 2 都有对应版本。
依赖上,Spring Boot 3 项目添加:
xml复制<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
如果你是 Spring Boot 2.x,则使用 springdoc-openapi-ui 1.7.0。这个选型没有太多玄学,就是看项目版本匹配哪个 starter。
2.2 最小可用的分组配置
假设你的项目里有两个版本的接口,包结构先物理隔离:controller.v1 和 controller.v2,路径前缀分别是 /api/v1 和 /api/v2。用 Springdoc 的 GroupedOpenApi 可以生成两个互相独立的文档分组:
java复制@Configuration
public class OpenApiConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("v1-api")
.pathsToMatch("/api/v1/**")
.packagesToScan("com.example.demo.controller.v1")
.build();
}
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("v2-api")
.pathsToMatch("/api/v2/**")
.packagesToScan("com.example.demo.controller.v2")
.build();
}
}
这里的两个条件 pathsToMatch 和 packagesToScan 建议同时写,相当于双保险。只写 packagesToScan 的话,如果某个 Controller 类忘记加对应版本前缀,就可能跑到别人的分组里;只写 pathsToMatch 的话,如果包扫描范围太大,一些内部接口也会混进文档。两个条件叠加,能过滤掉绝大多数边界情况。
每个分组还可以配上独立的 OpenAPI 信息:
java复制@Bean
public OpenAPI apiInfo() {
return new OpenAPI().info(new Info()
.title("订单服务 API")
.description("当前版本:v2,兼容说明: v2 为稳定版本, v1 将于 2025-12-31 下线。")
.version("v2"));
}
这个 OpenAPI Bean 是全局的,分组内如果再需要覆盖,可以在 GroupedOpenApi.builder().addOpenApiCustomizer(...) 里做定制。不过我一般情况下不会覆盖,因为分组的描述放在每个 Controller 的 @Operation 注解里更灵活。
2.3 UI 效果和版本信息怎么区分
配置完成后,启动项目访问 /swagger-ui.html 或 /swagger-ui/index.html,左上角的 “Select a spec” 下拉框里会出现两个选项:v1-api 和 v2-api。切换分组后,左侧接口列表只显示对应版本的接口。
这看着很简单,但要注意几个体验细节。第一个是分组命名:我见过有人把分组叫 接口1、接口2,或者直接用项目代号,结果外部对接方根本分不清。建议命名格式直接体现版本,比如 v1-api、v2-api,配合 OpenApiInfo 里的 description 写清楚版本状态。第二个是每个接口的标记:对于已经废弃但仍保留的 v1 接口,在方法上加 @Deprecated,再配合 @Operation(description = "...") 说明废弃原因和替代接口:
java复制@Deprecated
@Operation(summary = "查询订单列表(v1 废弃,请改用 v2)")
@GetMapping("/api/v1/orders")
public Result<List<OrderVO>> listV1() {
return orderService.listV1();
}
Swagger UI 会把标记了 @Deprecated 的接口置灰显示,前端对接时一眼就能避开。
3. 分组边界:Controller 包结构、路径前缀与共享层的取舍
3.1 包结构物理隔离是第一步
很多人以为配置了 GroupedOpenApi 就万事大吉,其实不是。分组配置只是“文档层面的过滤”,真正让多版本不混乱的是代码结构。我踩过一个特别典型的坑:早期偷懒,把 v1 和 v2 接口写在同一个 Controller 类里,方法上用不同的 @GetMapping 前缀区分。结果某天加需求,改了一个方法的路径,另一个版本的分组里突然出现了本不该出现的接口。
排查了半天,最后发现是包扫描路径写得太宽,把同一个类下的方法全部扫进了分组。所以我现在的要求很明确:v1 和 v2 的 Controller 必须落在不同的包下,最好一个版本对应一个完整的 controller 子包,分组配置里 packagesToScan 直接指定到底层包名。
推荐的项目结构:
text复制com.example.demo
controller
v1
OrderController.java
UserController.java
v2
OrderController.java
UserController.java
service
OrderService.java
vo
v1
OrderVO.java
v2
OrderVO.java
3.2 路径前缀的统一约束
包结构隔离还不够,路径前缀也得形成约定。我见过一个项目,两个版本接口都是同一个 Controller,路径都写在方法上:@GetMapping("/v1/orders")、@GetMapping("/v2/orders")。这本身没毛病,但一旦团队人多,新来的同事很容易在方法上少写一层版本前缀,接口就变成了 /api/orders 这种游离在版本之外的野生接口。
比较好的做法是把版本前缀定义成常量,每个版本 Controller 在类级别统一引用:
java复制@RequestMapping(OrderV1Paths.BASE)
public class OrderControllerV1 {
public static final String BASE = "/api/v1/orders";
}
类上加 @RequestMapping,方法上只写具体操作路径。这样整个类所有方法都在同一版本前缀下,漏写的概率大大降低。虽然 Spring 没有直接在注解里支持“动态拼接”版本前缀,但常量方式已经足够绝大多数项目用了。
至于把版本号挂到 server.servlet.context-path 上(比如 server.servlet.context-path=/api/v1),我不推荐用于多版本共存场景。因为它会把整个应用的所有接口统一加上前缀,只适合“一个应用一个版本”的部署模式。要做真正的多版本共存,还是老老实实在 Controller 层规划路径。
3.3 共享 Service 层时,别让旧契约被悄悄改掉
v1 和 v2 Controller 共用同一个 Service 是很正常的,毕竟业务逻辑大部分是相同的。但有个细节必须注意:v1 和 v2 的入参、出参 DTO 建议分开建包。比如 v1 的 OrderVO 是 {orderId, amount, status},v2 要新增一个 trackingNumber 字段,你把字段加到 v1 的 OrderVO 上,虽然 v1 多返回一个字段一般不会出问题,但某些严格校验的客户端还是会挂。更危险的是后续有人修改了 v1 的字段类型,比如 status 从 String 改成 Enum,反序列化直接失败。
我的做法是:v1 和 v2 的 DTO 完全独立,哪怕字段一模一样也要复制一份。Service 层内部做转换,v1 的 Controller 只返回 v1 的 DTO。这样虽然多写几个转换方法,但版本之间的边界是物理级别的硬隔离,后面不管怎么改都不会互相影响。
4. 多服务场景:用 Swagger 聚合把文档入口收口
4.1 服务多了,文档地址也多了
单应用内多版本分组解决的是“一个服务里的版本问题”。但现实是很多项目拆了微服务,订单服务、用户服务、商品服务各有一套 Swagger 文档,前端和对接方每接一个服务就得开一个 Swagger 地址。如果每个服务都有 v1、v2 分组,那就是成倍增加的信息入口,体验非常差。
这个场景下,我习惯做一个“文档聚合层”。它可以是一个独立的聚合服务,也可以是网关里的一段配置。Springdoc 本身支持在 application.yml 中配置多个 Swagger 文档 URL,指向不同服务的 /v3/api-docs 端点:
yaml复制springdoc:
swagger-ui:
urls:
- name: 用户服务
url: /user-service/v3/api-docs
- name: 订单服务
url: /order-service/v3/api-docs
- name: 商品服务
url: /product-service/v3/api-docs
这样请求 /swagger-ui.html 时,下拉列表里就能看到所有服务。这个模式适合搭一个内部 API 文档中心,配合 Spring Cloud Gateway 或 Nginx 的反代,把所有服务的文档入口统一收口到一个域名下。
4.2 聚合模式的网关反代坑
聚合配置只是第一步,真正的坑在“反代之后的 server 地址”。每个服务生成的 OpenAPI JSON 里,servers 节点默认是服务自身的地址,比如 http://order-service:8080。聚合层通过网关反代后,Swagger UI 页面能正常打开,但你在页面上点击“Try it out”发请求,请求会直接打到 order-service:8080,而浏览器根本解析不了这个内网服务名。
解决思路有两种。一种是从源头改:写一个 OpenAPI 定制器,把每个服务的 servers 地址统一改写成网关对外地址。Springdoc 里可以这样做:
java复制@Bean
public OpenApiCustomizer customizeServerUrl() {
return openApi -> openApi.servers(List.of(new Server().url("https://api.example.com")));
}
另一种是网关改响应体:Nginx 反代时用 sub_filter 把 JSON 里的 "url" 字段替换成对外域名。例如:
nginx复制location ~ ^/order-service/(.*)$ {
proxy_pass http://order-service:8080/$1;
sub_filter '"url":"http://order-service:8080"' '"url":"https://api.example.com/order-service"';
sub_filter_once off;
}
这条经验是我在给多个服务接入统一文档入口时踩出来的。如果不处理,文档页面就是“能看不能调”,对接方最终还是得手动拼地址,聚合的价值就打了个大折扣。
4.3 顺带解决:导出 JSON 导入 Postman
热搜词里“postman导入swagger api文档”和“swagger导出接口文档”出现频率很高,其实就是同一个需求:把 Swagger 文档变成可以实际调试的接口集合。单服务场景下,直接访问 /v3/api-docs 或 /v3/api-docs/{groupName},把返回的 JSON 保存成文件,然后在 Postman 里 Import,选择 OpenAPI 3.0 格式即可;多分组场景记得先选对分组再导出。
导入后有一个常见问题:Postman 会读取 JSON 里的 servers 地址作为请求基础 URL,如果文档里的 server 还是内网服务名,导入后的集合根本调不通。解决办法是在 Postman 里为这个集合定义一个变量,比如 baseUrl,手动覆盖默认地址,之后所有请求都走变量,切换环境时只需要改一个地方。
5. 版本策略设计:Swagger 只是暴露问题,不解决根本
5.1 API 版本的语义,不是随便定个数字
很多人对版本号的理解停留在“v2 比 v1 新”,但 API 版本号的语义和软件版本号有区别。API 的版本应该是“契约版本”,它的变化意味着接口行为的变化。一般约定:
- 主版本号:不兼容变更,比如删除字段、修改字段类型、改变鉴权方式
- 次版本号:向后兼容的变更,比如新增字段、新增可选参数
- 补丁版本号:Bug 修复,不影响契约
注意,API 文档里的 version 字段应该填的是“契约版本”,而不是“应用版本”。有同事习惯把 info.version() 写成项目的 Maven 版本号 1.2.3,结果外部对接方问“这个接口到底哪个版本稳定”,根本没法回答。建议直接写 v1、v2,然后在 description 里补充更细的变更说明。
5.2 新旧版本的并存期与下线节奏
多版本 API 不是“永远维护所有版本”,而是“让旧版本有计划的退出”。我见过最夸张的项目保留了 v1 到 v7 七个版本,维护成本高到没人敢碰。合理的策略是:新接口上线时,给旧版本至少留 3 到 6 个月的并存期,期间观察新版本稳定性、推动调用方迁移;并存期结束后,在文档里把旧版本标记为 deprecated,约定下线时间;真正下线后再把 Controller 和 DTO 一并删除。
这个节奏需要在文档里写得非常清楚。比如 v1 的描述里写“将于 2025-12-31 下线,请迁移至 v2”,调用方会有压力去推进迁移,而不是一直拖着。文档里的版本状态,本质上是和业务方的契约,写清楚了对谁都好。
5.3 调用链中怎么快速确认当前版本
多版本共存时,排障最大的痛点是:线上报错了,你知道接口路径是 /api/orders,但不知道调用方实际触发的是 v1 还是 v2 的逻辑。我在实践中会加一个响应头,让每次请求的版本一目了然。
用一个简单的过滤器,给响应统一增加版本标识:
java复制@Component
public class ApiVersionFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletResponse resp = (HttpServletResponse) response;
HttpServletRequest req = (HttpServletRequest) request;
if (req.getRequestURI().startsWith("/api/v1/")) {
resp.setHeader("Api-Version", "v1");
} else if (req.getRequestURI().startsWith("/api/v2/")) {
resp.setHeader("Api-Version", "v2");
}
chain.doFilter(request, response);
}
}
这样无论是前端还是后端排查问题,打开响应头一看就知道调的是哪个版本的逻辑。这个方法成本极低,但比你在日志里逐行翻快得多。
6. 上线前检查清单与踩坑速查
6.1 Springfox 3.0 和 Spring Boot 2.6 的路径匹配冲突
如果你还在维护老项目,用的是 Springfox 3.0,SSpring Boot 升级到 2.6 之后,项目启动可能会报:
text复制Failed to start bean 'documentationPluginsBootstrapper';
nested exception is java.lang.NullPointerException
根因是 Spring Boot 2.6 默认把 Spring MVC 的路径匹配策略改成了 PathPatternParser,而 Springfox 3.0 还依赖旧的 AntPathMatcher。解决办法是在 application.yml 里明确指定旧的匹配策略:
yaml复制spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
这个配置属于“兼容性补丁”,能解决问题,但不代表 Springfox 本身好用。如果你在这个老项目上还要做多版本分组,Springfox 也能支持(通过定义多个 Docket Bean,每个设置 groupName),但体验明显不如 Springdoc。我的建议是:老项目如果改动不大,继续用 Springfox 加 ant_path_matcher 凑合;如果要大规模重构,不如借机升级到 Springdoc。
6.2 别让 Swagger 文档在生产环境裸奔
热搜词里出现了“swagger未授权访问漏洞”,这个提醒很到位。很多人开发的习惯是本地开启 Swagger,部署到生产环境时忘了关,导致接口结构、字段名、内部路径全部暴露。一个最直接的做法是用 Profile 控制:
java复制@Configuration
@Profile({ "dev", "test" })
public class OpenApiConfig {
// 所有 Swagger/Springdoc 相关 Bean 只注册在 dev、test 环境
}
同时,Spring Security 配置里也要注意,即使开发环境开启了文档,也尽量限制访问范围,比如只允许内网 IP 访问 /swagger-ui/** 和 /v3/api-docs/**。否则外网用户拿你的文档直接扫接口做攻击面分析,安全问题就大了。
6.3 分组配置完成后:验证文档是否“干净”
每次配置完分组,我建议养成一个小习惯:用命令把分组下的接口列表拉出来检查一遍,确认没有串版本。Maven/Gradle 项目启动后,执行:
bash复制curl -s http://localhost:8080/v3/api-docs/v1-api | jq '.paths | keys'
如果返回的路径列表里出现了 /api/v2/** 的路径,说明分组过滤配置有问题,赶紧检查 packagesToScan 和 pathsToMatch 是否都生效。这个检查比打开浏览器肉眼核对快得多,适合写进 CI 或者上线前 check 脚本。
6.4 其他容易忽略的边界情况
多版本 Swagger 还有几个边角问题,列的越早越省事:
- 路径参数版本号:比如
GET /api/payments/{version}/orders,这种把版本号放在 resource 中间的做法,会给 Swagger 分组匹配带来麻烦,pathsToMatch写起来很别扭。建议版本号放在路径最前面。 - 多个 Docket Bean 配置了相同的
pathsToMatch:两个分组会扫描到同一批接口,造成文档重复。解决方法是确保每个分组的包名或路径前缀严格不重叠。 - Swagger UI 的页面缓存:改完分组配置,浏览器里经常还是旧数据,先用无痕窗口或强制刷新验证,别急着怀疑配置问题。
springdoc.api-docs.enabled和springdoc.swagger-ui.enabled这两个开关可以分开控制,只关文档页面但保留 JSON 端点,方便内部自动化工具继续拉取。
多版本 API 的落地,表面上是 Swagger 配置问题,实际上是 API 治理问题。文档分组只是把版本边界可视化,真正决定成败的是包结构是否隔离、版本策略是否清晰、生产环境是否可控。我个人的习惯是:每次上线前,把“Swagger 分组是否干净”和“生产环境是否关闭文档入口”一起写进发布检查单,宁可多花两分钟确认,也不要上线后再被调用方问一句“这个接口是哪个版本的”。
