上个月跟一个外部团队做支付回调联调,对方甩给我一份三个月前手写的接口文档,字段名跟我线上实际返回的对不上,两个人在群里对着截图扯了两个小时,最后还是靠抓包才把问题定位出來。这件事之后我重新捡起了 Swagger 这套东西,把项目里所有对外接口的文档改成从代码自动生成。Spring Boot 整合 Swagger 这件事看起来只是加两个依赖、写几行配置,真正做下来你会发现版本兼容、注解语义、生产环境开关、泛型解析这些地方,每一步都有能让人卡半天的地方。这篇就按我自己实际落地的顺序,把这套东西从前因后果到踩坑细节完整写一遍,适合手上有 Spring Boot 项目、正准备把接口文档自动化、或者被 springfox 的启动报错劝退过的同学看,我会尽量把“为什么这么选”讲明白,而不只是甩代码。
1. 手写接口文档的死穴,以及 Swagger 到底治好了哪一条
我先说清楚我为什么要折腾这件事。刚工作那几年我一直觉得接口文档是“额外工作”,写不写无所谓,反正代码就是真相。后来带过几个跨端协作的项目才发现,代码是真相这句话只有在所有人都愿意读代码的时候才成立,而现实是前端、测试、外部对接方没人愿意翻你的 Controller。
1.1 手写文档崩掉的三个时间点
第一个崩盘点在需求变更。接口加了个字段,代码五分钟改完,文档大概要等下一次“有空”才补,而那个“有空”一般是上线之后。第二个崩盘点在人员流动,写文档的人走了,剩下的人只敢照着代码看,文档变成了一份没人敢维护的历史遗迹。第三个崩盘点在联调,当文档和实际返回不一致时,双方都会下意识相信自己那一份,于是排查方向从一开始就是错的。
我踩过最离谱的一次,是文档里写的分页参数叫pageNum,代码里实际接收的是current,前端按文档传了半天一直拿到第一页数据,还以为是后端分页逻辑有 bug,两个人在会议室里查了一下午。这三个崩盘点有一个共同点:文档不是“写”得不好,而是它和代码是两份独立的、会各自漂移的东西。只要两者之间存在人工同步的环节,漂移就是必然的。
1.2 Swagger 真正解决的是同步问题,不是排版问题
很多人把 Swagger 理解成一个“好看的接口页面”,这个理解偏差会导致后面所有决策都做歪。Swagger 的核心价值是让文档从代码里长出来:Controller 上的注解、方法签名、DTO 的字段定义,在应用启动时被扫描、反射、组装成一份符合 OpenAPI 规范的 JSON,UI 只是这份 JSON 的渲染层。
这里有个关键的认知:那份 JSON(路径一般是/v3/api-docs)才是产物,/swagger-ui/index.html那个网页只是消费者。想明白这一点,后面很多操作就有依据了——比如你想做文档的版本对比、想给自动化测试提供接口清单、想在网关层聚合多个服务的文档,全都是围绕这份 JSON 做的,而不是去抓那个网页。同样地,我把 Swagger 定位成“开发期和联调期的工具”,而不是“对外交付物”,对外仍然要有一份经过评审的正式文档,只是那份文档可以从 JSON 导出来,不用手敲。
顺带说一下它治不好什么。Swagger 自动生成的是“结构”,不是“语义”。字段叫status、类型是int,这是结构;status=3表示已取消、并且只有status=2时才允许取消,这是语义。前者能自动出来,后者必须靠人写描述。如果你只加依赖不写描述,生成的文档就是一份“有类型但没含义”的字段列表,对接方依然要猜。这一点我在第 4 章会展开讲怎么写才有用。
2. springfox 还是 springdoc:一个 Spring Boot 版本号决定的选择题
这是整个整合过程中最需要先想清楚的事,因为选错了后面全是白工。这两套东西名字都带 Swagger,但完全是两个独立项目:springfox 是老牌实现,基于 Swagger 2 规范;springdoc-openapi 是后起的,基于 OpenAPI 3 规范,也是目前 Spring Boot 官方生态里更推荐的那一个。
2.1 Spring Boot 2.6 之后 springfox 为什么会直接挂掉
我在一个 Spring Boot 2.7 的项目里用 springfox 3.0.0 起服务,启动直接报空指针,堆栈指向PatternsRequestCondition.getPatterns()返回 null。第一次遇到会很懵,因为依赖能下载、类能加载,偏偏在扫描接口的时候炸了。
根因在路径匹配策略上。Spring Framework 5.3 引入了PathPatternParser,Spring Boot 2.6 把默认的spring.mvc.pathmatch.matching-strategy从ant_path_matcher改成了path_pattern_parser。而 springfox 内部的WebMvcRequestHandlerProvider在收集接口信息时,依赖的是AntPathMatcher那一套PatternsRequestCondition,在PathPatternParser模式下这个条件对象里的 patterns 是空的,于是取的时候就 NPE。
临时的救火方案是把策略改回去:
spring: mvc: pathmatch: matching-strategy: ant_path_matcher这行配置能让服务起来,但我不推荐长期这么干。PathPatternParser是为性能设计的,官方换默认值是有原因的,为了一个已经停止维护的文档库把整个应用的路径匹配降级回旧实现,属于用架构换便利。而且 springfox 最后一次正式发布停在 2020 年,对 Spring Boot 3、Jakarta EE 那套包名迁移完全没有跟进,你的项目迟早要升,到时候这笔账还是要还。所以我现在的做法是:新项目一律 springdoc,老项目如果 springfox 能跑就暂时不动,等升级 Spring Boot 大版本时一起换掉。
2.2 两套实现的版本对照与迁移成本
选型上最实用的信息是版本对应关系,这个搞错了一样起不来。下面这张表是我自己整理并在几个项目里验证过的:
| Spring Boot 版本 | 推荐实现 | 依赖坐标 | 版本 |
|---|---|---|---|
| 2.1 ~ 2.5 | springfox 或 springdoc | springfox-boot-starter/springdoc-openapi-ui | 3.0.0 / 1.6.x |
| 2.6 ~ 2.7 | springdoc | springdoc-openapi-ui | 1.7.0 |
| 3.0 及以上 | springdoc | springdoc-openapi-starter-webmvc-ui | 2.x |
注意 Spring Boot 3 的坐标是不一样的,多了starter前缀,包名也从javax换成了jakarta。有次我在 Boot 3 项目里惯性写了老坐标,依赖拉下来了但 UI 页面死活 404,查了半天才发现是引错了 artifact。
注解层面的迁移其实不难,一一对应,写个脚本批量替换就完事:
| springfox (Swagger 2) | springdoc (OpenAPI 3) | 用途 |
|---|---|---|
@Api | @Tag | 标记 Controller 分组 |
@ApiOperation | @Operation | 描述单个接口 |
@ApiParam | @Parameter | 描述单个参数 |
@ApiModel | @Schema | 描述 DTO |
@ApiModelProperty | @Schema | 描述 DTO 字段 |
@ApiIgnore | @Hidden/@Parameter(hidden = true) | 隐藏接口或字段 |
@ApiImplicitParam | @Parameter | 隐式参数声明 |
真正有迁移成本的是Docket配置类,springfox 里那一套DocumentationType.SWAGGER_2的链式构造在 springdoc 里变成了GroupedOpenApi和OpenAPIBean,写法完全不同,等于重写。如果你的项目里 Docket 配得很复杂(比如按包、按注解、按路径做了多层分组),这块要预留半天时间。
3. 从零跑通:依赖、配置与第一个能打开的页面
我按 Spring Boot 3 + springdoc 2.x 这套组合讲,这是目前新项目的常态,Boot 2.7 的话把坐标换成springdoc-openapi-ui1.7.0,其余写法基本一致。
3.1 依赖引入与版本对齐
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.5.0</version> </dependency>这个 starter 已经把springdoc-openapi-starter-webmvc-api和 Swagger UI 的静态资源都带进来了,不需要再单独引 UI 包。我见过有人在引了 starter 之后又手动加一个swagger-ui的依赖,结果静态资源版本冲突,页面能打开但样式全丢,排查起来很浪费时间。
版本对齐这块有个细节:springdoc 2.x 内部依赖 swagger-core 2.2.x,如果你项目里因为其他原因引入了不同版本的 swagger 相关包(比如某些老的工具库会传递依赖进来),可能出现NoSuchMethodError。排查方式是mvn dependency:tree -Dincludes=io.swagger*看一眼,把冲突的传递依赖排掉。
3.2 最小可用配置
严格来说,加完依赖什么都不配就能跑。启动应用后访问两个地址:
http://localhost:8080/swagger-ui/index.html:UI 页面http://localhost:8080/v3/api-docs:OpenAPI JSON
这里有个小坑值得提一句:老教程里写的访问路径是/swagger-ui.html,那是 springfox 的路径。springdoc 的 UI 入口是/swagger-ui/index.html,虽然它也做了/swagger-ui.html到新地址的重定向,但如果你在网关或者 Nginx 里做了精确路径匹配,重定向可能被拦掉,表现为访问 404。稳妥的做法是直接配一个自定义路径:
springdoc: swagger-ui: path: /doc.html api-docs: path: /v3/api-docs配了path之后访问/doc.html就会直接跳到 UI,前端同事记起来也方便。另外 UI 的静态资源默认从/swagger-ui/**加载,如果你项目里有拦截器或者安全框架,记得把这个前缀和/v3/api-docs一起放行,不然会出现“页面能打开但一直转圈”的现象——本质是请求被鉴权拦了,UI 拿不到 JSON。
3.3 启动异常与页面异常的逐条排查
我把这几类问题按现象分了一下,遇到的时候直接对照查:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
启动报PatternsRequestConditionNPE | springfox 与 Boot 2.6+ 路径匹配冲突 | 换 springdoc,或临时改matching-strategy |
| 页面 404 | 引错 artifact(Boot 3 用了老坐标) | 检查是否为springdoc-openapi-starter-webmvc-ui |
| 页面打开但无接口 | 接口被@Hidden、或被packages-to-scan限死 | 检查扫描包配置与 Controller 所在包 |
JSON 里paths为空 | Controller 未被组件扫描到,或类上没有@RestController | 检查包路径与注解 |
| 请求 JSON 时报 401/403 | 安全框架拦截了/v3/api-docs | 放行文档相关路径 |
报NoSuchMethodError | swagger-core 版本冲突 | dependency:tree排传递依赖 |
这里唯一需要展开说的是“无接口”这一类。springdoc 默认扫描整个应用,但如果你配了springdoc.packages-to-scan: com.xxx.order.controller,那就只会扫这一个包,多模块项目里很容易漏掉别的模块。我一般不用这个配置,而是用分组来控制展示范围(第 5 章讲),扫描交给默认行为,出问题的时候少一个变量。
4. 注解不是贴标签:让自动生成的文档带上业务语义
依赖跑通之后,你会得到一个能打开的页面,以及一堆只有方法名和参数类型的接口条目。这份东西给对接方看,基本等于“你自己猜吧”。真正决定文档质量的是注解怎么写,而这里最容易犯的错是把它当成任务,每条接口贴一行@Operation(summary = "查询订单")就交差。
4.1 DTO 上的字段描述才是一份文档的主体
对接方看得最多的是请求体和响应体的字段表,所以@Schema的投入产出比最高。我的习惯是每个字段至少写三件事:中文含义、取值范围或枚举、是否必填。
@Schema(description = "订单查询请求") public class OrderQueryReq { @Schema(description = "订单状态,1-待支付 2-已支付 3-已取消", allowableValues = {"1", "2", "3"}, requiredMode = Schema.RequiredMode.REQUIRED) private Integer status; @Schema(description = "下单时间起,格式 yyyy-MM-dd HH:mm:ss", example = "2024-03-01 00:00:00") private String startTime; @Schema(description = "页码,从 1 开始", defaultValue = "1", minimum = "1") private Integer pageNo; }几个经验点。allowableValues对枚举型字段特别有用,UI 上会直接列出可选值,对接方不用再翻你的代码。example我一般都会填,因为 Swagger UI 点“试用”的时候会拿它当默认值,填一个合法值能省掉很多“参数校验失败”的来回。requiredMode在 springdoc 2.x 里替代了老的required = true,不过如果你在字段上加了@NotNull这类 Bean Validation 注解,springdoc 会自动识别成必填,不用重复写,这一点很省事。
还有一个容易忽略的点:@Schema的description不要照抄字段名。orderStatus写成“订单状态”是废话,写成“订单当前状态,支付超时 30 分钟后由定时任务置为已取消”才叫描述。我评审文档的时候基本只看这一栏,写不出来的说明这块业务逻辑自己都没想清楚。
4.2 接口层的注解要回答“什么时候调、失败是什么样”
Controller 方法的描述有两个层次,summary是一句话标题,description是详情。我现在固定这么写:
@Operation( summary = "取消订单", description = "仅待支付状态可取消;已支付订单需走退款流程。取消成功后库存异步回滚,预计 5 秒内生效。", responses = { @ApiResponse(responseCode = "200", description = "取消成功"), @ApiResponse(responseCode = "409", description = "订单状态不允许取消") } ) @PostMapping("/order/{orderId}/cancel") public ApiResult<Void> cancel( @Parameter(description = "订单号,18 位", required = true) @PathVariable String orderId) { // ... }那段description里塞的信息量,比十个字段注解加起来都值钱:调用前置条件、副作用、生效延迟。这些是代码里读不出来的东西,也是对接方最容易踩的地方。responses里列出错误码同样重要,不然对方只知道“失败了”,不知道是参数错还是状态错,只能来问你。
参数注解有个坑要说一下。@Parameter放在@PathVariable、@RequestParam上没问题,但如果是@RequestBody的对象,别在外面再套一层@Parameter,那会导致请求体 schema 显示异常。还有个更隐蔽的,GET请求用对象接收 query 参数时,springdoc 默认会把它当成一个整体的 object 渲染,UI 上没法逐个填。解决办法是在参数上加@ParameterObject:
@GetMapping("/order/page") public ApiResult<PageResult<OrderVO>> page(@ParameterObject OrderQueryReq req) { // ... }加上之后,UI 会把对象里的字段平铺成一个个独立输入框,体验完全不一样。
4.3 从注解到页面渲染,中间发生了什么
理解这条链路能帮你快速定位“注解写了但没生效”的问题。启动阶段,springdoc 的OpenApiResource会遍历 Spring MVC 的RequestMappingHandlerMapping,拿到每个 handler 的方法签名和参数元数据;同时读取方法、类、DTO 上的 swagger 注解;两者合并后生成Operation对象,再组装成Paths,最后序列化成 JSON。UI 那边只是请求这个 JSON 然后渲染。
这条链路解释了几个常见困惑。第一,注解写在接口(interface)上但实现类没继承注解时可能丢失,特别是用了 MyBatis Mapper 那种代理或者 Feign 声明式接口的场景,注解要写在真正被扫描到的那个类上。第二,如果你用了 AOP 动态代理,方法签名可能变成代理类的,泛型信息会丢,这时候文档里会出现Object或者$Proxy之类的类型。第三,@ControllerAdvice里的全局异常处理不会自动反映到文档的错误响应里,因为那层逻辑不在 handler 方法的签名上,需要手动在responses里补。这三点我都踩过,尤其第三点,文档里写着 200,实际返回 400 的字段校验错误,对接方一脸茫然。
5. 分组、鉴权与生产环境开关:让这套东西能上生产
开发环境跑通只是第一步。真正落地时还有三个问题必须回答:接口太多怎么分组、需要登录的接口怎么在 UI 上调试、生产环境要不要暴露。
5.1 按业务域拆分组,而不是按包名
项目一大,一个页面里塞两百个接口,找起来不比读代码快。GroupedOpenApi就是干这个的,但我建议按业务域分而不是按包路径机械地分,因为包结构经常重构,业务域相对稳定:
@Configuration public class OpenApiGroupConfig { @Bean public GroupedOpenApi orderGroup() { return GroupedOpenApi.builder() .group("01-订单") .pathsToMatch("/api/order/**", "/api/refund/**") .build(); } @Bean public GroupedOpenApi userGroup() { return GroupedOpenApi.builder() .group("02-用户") .pathsToMatch("/api/user/**", "/api/auth/**") .build(); } @Bean public GroupedOpenApi internalGroup() { return GroupedOpenApi.builder() .group("99-内部") .pathsToMatch("/internal/**") .build(); } }分组名前面加序号是我自己的小习惯,UI 右上角的下拉框是按名字排序的,加序号能保证顺序稳定,不然每次加新分组顺序都会变,找起来很烦。另外要注意pathsToMatch用的是 Ant 风格匹配,/api/order/**能匹配多级路径,但/api/order/*只匹配一级,写错了会出现“分组里空空如也”的情况。
还有一个细节:一旦你定义了GroupedOpenApi,默认的全局分组还在,会出现“全部接口”和各个分组并列的情况。如果不想要全局视图,可以把springdoc.api-docs.enabled相关的默认组关掉,或者在分组里用packagesToScan配合排除。我一般是保留全局组,因为有些跨模块的联调场景还是需要一个全量视图。
5.2 让 Swagger UI 能带着 Token 调试
这一步做完,UI 的价值会翻倍——它能直接当调试工具用,不用再开 Postman 复制粘贴。做法是声明一个 security scheme:
@Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("订单服务 API") .version("v1.0.0") .description("订单创建、查询与状态流转")) .components(new Components().addSecuritySchemes("bearerAuth", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT") .description("请填入登录接口返回的 accessToken,无需加 Bearer 前缀"))); }然后在需要鉴权的 Controller 或方法上加@SecurityRequirement(name = "bearerAuth")。SecurityScheme.Type.HTTP+scheme = "bearer"这个组合,UI 会渲染成一个“Authorize”按钮,点开是个输入框,填进去的 token 会自动加到后续请求的Authorization头里。
这里的坑在于 token 格式。如果你后端用的是Authorization: Bearer xxx这种标准格式,UI 会自动帮你补Bearer前缀,输入框里只填 token 本身就行。但有些项目的拦截器是自己解析的,比如要求Authorization: xxx不带前缀,或者放在自定义 headerX-Token里。这种情况下bearerFormat那套就不适用了,要改用SchemeType.APIKEY,配in = HEADER和name = "X-Token"。我见过有人因为这个问题以为 UI 调试功能坏了,其实是后端没认出来 header 格式。
5.3 生产环境的开关必须显式关掉
这件事我态度很明确:生产环境不暴露文档。原因不是信息泄露那么玄乎,主要是两个实际考虑,一是外部扫描器会盯上这个路径,日志里一堆探测请求;二是文档本身暴露了内部接口结构,尤其是有/internal/**这类路径的时候。
关闭方式用配置就能做到,不需要改代码:
springdoc: api-docs: enabled: false swagger-ui: enabled: false更好的做法是按 profile 隔离,让配置跟着环境走:
--- spring: config: activate: on-profile: prod springdoc: api-docs: enabled: false swagger-ui: enabled: false注意spring.config.activate.on-profile这个写法是 Spring Boot 2.4 之后才支持的,老版本用spring.profiles。还有人问能不能只关 UI 保留 JSON,答案是可以,把swagger-ui.enabled关掉、api-docs.enabled留着就行,适合那种 JSON 要给内部工具消费、但不想让人打开页面的场景。不过我一般还是两个一起关,需要 JSON 的时候在测试环境导出静态文件,走离线分发。
6. 那些把我按在工位上的坑:泛型、上传与路径错位
前面四章是“正常路径”,这一章是真实项目里让我加过班的几个问题。每个我都写清楚排查过程,因为直接给答案的话,换个场景你还是不会。
6.1 泛型包装类在文档里变成一坨 Object
我司后端统一返回ApiResult<T>,分页再套一层PageResult<T>,所以接口签名长这样:ApiResult<PageResult<OrderVO>>。整合完 springdoc 之后,文档里这个响应体的 schema 显示成了ApiResult加一堆Object,展开后看不到OrderVO的任何字段。
排查思路是这样的:先说结论,泛型嵌套太深时,反射拿不到运行时类型,springdoc 会退化成Object。验证方法是换成一个具体的返回类型,比如直接返回ApiResult<OrderVO>,看 schema 是不是正常了。如果是,说明是嵌套深度问题。
处理方式我试过三种,按推荐度排:第一种是在方法上显式声明响应 schema:
@Operation(summary = "订单分页查询", responses = @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = OrderPageResult.class))))其中OrderPageResult extends PageResult<OrderVO>是个具体类,这样泛型参数就固化了。第二种是干脆为常用组合定义具体的包装类,比如OrderPageResult、UserPageResult,代码上多几个类,但文档清晰度提升明显。第三种是用@Schema在泛型类上做name去重,这只解决命重复问题,不解决字段缺失,治标不治本。
我最后选的是第二种,理由很实在:ApiResult<PageResult<OrderVO>>这种嵌套在文档里就算渲染出来了,对接方看着也累,展开三层才能找到一个字段。用具体类之后,UI 上一眼就能看到分页信息加订单字段,联调效率完全不一样。
6.2 文件上传接口在 UI 上点不动
现象是接口出现在文档里了,但 UI 上没有文件选择框,试运行直接报参数错误。原因是 springdoc 对MultipartFile的识别依赖请求的 content-type 声明,如果你的接口同时接收文件和其他表单字段,它会默认按 JSON 处理。
正确写法是这样的:
@Operation(summary = "上传合同附件") @PostMapping(value = "/order/{orderId}/attachment", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResult<String> upload( @PathVariable String orderId, @Parameter(description = "文件,单文件不超过 10MB,仅支持 pdf/jpg") @RequestPart("file") MultipartFile file, @RequestPart("remark") String remark) { // ... }三个关键点:consumes要显式声明成 multipart;文件参数用@RequestPart而不是@RequestParam(后者在某些版本下会被当成普通字符串);其他表单字段也要用@RequestPart,不然会被当成 query 参数渲染。改完之后 UI 上会出现文件选择控件,可以直接选本地文件试。
顺带一个经验:@RequestPart接收的String参数,前端传的时候必须放在 form-data 里,不能放 query。这个在文档上看不出来,但对接的时候会来回扯,所以我在description里会写一句“所有字段走 form-data”。
6.3 context-path 配了之后 UI 请求打到别处去
这个问题排查起来最费劲,因为页面是能打开的,只是点试运行的时候请求 404,或者 JSON 请求返回一堆 HTML。根因是应用配了server.servlet.context-path: /order,但 springdoc 生成的 UI 里有些资源路径用的是相对路径,再加上前面挂了 Nginx 做路径转发,浏览器实际请求的地址和预期对不上。
我的排查习惯是先看浏览器 Network 面板:请求/v3/api-docs的时候实际打到了哪个地址。如果是/v3/api-docs而没有带 context-path,说明 UI 那边的 basePath 没拿到。处理方式有两种,一种是在application.yml里显式配置:
server: servlet: context-path: /order springdoc: swagger-ui: config-url: /order/v3/api-docs/swagger-config url: /order/v3/api-docs另一种更省心的做法是直接配springdoc.swagger-ui.path到一个绝对路径,然后让网关统一转发。但要注意,如果你的应用前面还有一层网关做了路径重写(比如把/order-api/**重写成/order/**),那url里要把网关前缀也带上,否则浏览器请求会走错门。这个坑的排查顺序我总结成一句话:先看 Network 实际请求地址,再看应用的 context-path,最后看网关重写规则,从后往前倒推,比瞎猜快得多。
6.4 注解堆太多,文档反而看不下去了
这个是“用力过猛”的坑。有一版我给每个接口都写了summary、description、每个参数写了@Parameter、每个响应写了@ApiResponse,结果 UI 上每一条接口展开都是一大屏文字,关键信息被淹没,前端同事直接说“你不如给我个 word”。
反思之后我定了几个取舍规则。summary必写,而且要短,控制在 12 个字以内,作用相当于列表里的目录项。description只在有前置条件、副作用、特殊约束的接口上写,纯 CRUD 的查询接口写一句就够。DTO 字段的description必写,但去掉那些重复字段名的废话。example只在格式要求严格的地方写(时间、枚举、金额这种)。@ApiResponse只列非常规状态码,200 那种不写。
这套规则执行下来,文档篇幅大概降了一半,可读性反而上去了。我的判断标准是:一个没接触过这个项目的开发,能不能在 30 秒内找到他要调的那个接口,并且搞清楚必填哪些参数。如果做不到,说明注解是堆给自己看的,不是给人用的。
7. 把文档导出成静态文件,接进 CI 做结构比对
最后一个我觉得值得做、但很多团队没做的环节:把 OpenAPI JSON 纳入版本管理,在 CI 里做 diff。这件事的价值在于,它把“接口有没有发生破坏性变更”从一个需要人肉 review 的问题,变成了一个可以自动拦截的检查。
具体做法是加一个 Maven 插件,在集成测试阶段把 JSON 导出成文件:
<plugin> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-maven-plugin</artifactId> <version>1.4</version> <executions> <execution> <id>integration-test</id> <goals> <goal>generate</goal> </goals> </execution> </executions> <configuration> <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl> <outputFileName>openapi.json</outputFileName> <outputDir>${project.build.directory}</outputDir> </configuration> </plugin>插件会去请求运行中的应用拉取 JSON,所以要在集成测试阶段应用已经起来的情况下执行。产出物在target/openapi.json,把它提交到仓库的一个固定目录里,每次 CI 构建后和上一版做 diff,输出差异报告。
差异怎么看,我关注三类:删掉的路径、删掉的字段、从非必填变成必填。这三类都是会对下游造成破坏的变更,尤其是“字段变成必填”这一条最阴险,代码上只是加了个@NotNull,编译没问题,测试也能过,但所有老调用方会立刻开始报错。如果你们的服务是对外提供 API 的,这种拦截能省掉大量事后协调。
这套机制的成本其实很低,插件配置一次就好,剩下的就是看 CI 报告。需要留意的是,如果项目对启动速度敏感,或者测试环境依赖外部中间件导致起不来,那这个插件跑起来会比较别扭,这时候退而求其次,用一个@SpringBootTest加MockMvc请求/v3/api-docs,把结果写到文件里,效果一样,只是多写几十行代码。
前面几个项目我都是用这套流程,一开始只是为了给自己留个底,后来发现它顺带解决了另一个问题:文档和代码的一致性有了一个机械化的保障手段,不用再靠“记得更新”这种自觉。我个人的体会是,工具这件事,能靠流程卡住的就别靠人的记性,Swagger 整合本身只是把文档生成自动化了,真正让文档不烂掉的,是后面这套导出和比对的闭环。如果你现在只做到第 3 章就跑通了,那也没问题,先把自动生成用起来,等接口开始被别人依赖、变更开始变多的时候,再回来补第七章这部分。