SpringBoot4系列20 - 使用OpenAPI 3.1接口文档
一家公司的订单服务有三种消费方:小程序前端、运营后台、一家做对账的合作方。接口怎么调、字段什么含义,靠群里发消息同步,消息刷走一次,联调就对不上一次。我把字段说明放进共享文档试过一版,代码改了没人记得改文档,三个月之后那份文档没有一行敢信。前端问我「金额是元还是分」,答案明明就在代码里,却不在任何一份我能拿得出手的文档里。
后来我把接口描述写进代码、让工具从代码里生成文档,这事才算理顺。OpenAPI 定义了这份描述的格式,springdoc-openapi 负责在 Spring Boot 里完成生成:启动时扫描注解,产出一份 JSON 描述文件,swagger-ui 把这份 JSON 渲染成可交互的文档页面。文档与代码在同一个提交里,字段改了文档必然跟着变,这就是我要的效果。

这篇我在 Spring Boot 4.1.1 上把系列一直在用的订单接口文档化:springdoc-openapi 3.1.1 扫描 @Tag、@Operation、@Schema 注解生成 OpenAPI 3.1 文档,GroupedOpenApi 把 /api/orders 与 /api/admin 分成两组,swagger-ui 5.32.14 提供带凭据的调试页面,管理接口的 HTTP Basic 认证声明进文档。18 个测试全绿,全部数字来自同一次运行,我留了底。
文档从哪来:集成方案与依赖事实
写代码前我习惯先把依赖的来路确认清楚,下面这些全部来自 Central 制品解包与真实启动实测。
先说与本系列其他篇最大的不同:Boot 官方 BOM 不管理 springdoc。我翻了 spring-boot-dependencies 4.1.1,里面没有任何 org.springdoc 的版本条目,starter 的版本必须显式写进 pom,省掉版本号 Maven 第一步就报错。九成是因为大家习惯了系列里 nimbus-jose-jwt 那种「BOM 管版本、工程不写号」的写法,在这篇不成立。
版本我选 3.1.1,依据有三,全部制品实证:springdoc-openapi 3.1.1 的父 POM 里,parent 就是 spring-boot-starter-parent 4.1.0,与本篇基线同代;最小工程在 Boot 4.1.1 上真实启动,GET /v3/api-docs 返回 200,文档的 openapi 字段是 “3.1.0”;Central 上 springdoc 3.x 线的最新稳定版就是它,2026-09-06 发布。
starter 的组成我解包看过,三层套娃加一个页面 webjar:
| |
swagger-core-jakarta 是注解与对象模型的实现方,@Schema 这类注解的定义和「注解转 schema」的逻辑都在它里面,版本 2.2.55 由 springdoc 的父 POM 管理,工程不用自己操心。

自动配置分布在三个模块里(unzip AutoConfiguration.imports 实证),包名都在 org.springdoc 下:
| |
GroupedOpenApi 类在 org.springdoc.core.models 包,io.swagger.v3.oas.models 包里没有它,import 选错一边就是编译错误。IDE 自动补全两个包都提示得出,眼要睁着。
springdoc.* 前缀的属性共 106 条(spring-configuration-metadata 实证),本篇用到四条,默认值如下:
| |
四条默认值都能满足开头的需求,yml 里一行 springdoc 配置不写,文档照样出来。后面「属性开关」一节我把其中两条的行为实测了一遍。
依赖和配置
工程四个 starter:webmvc 撑接口面,validation 让校验注解生效并顺手进 schema,security 管住 /api/admin,springdoc 那个是主角:
| |
测试侧照系列惯例引两个:webmvc-test 提供 @AutoConfigureMockMvc 本体,security-test 提供 httpBasic() 请求后处理器,缺一个就是注解找不到或者 401 测不了,我试过漏引的滋味。
application.yml 三段,端口 18200 延续系列约定:
| |
Basic 账号来自 spring.security.user.*,roles 配成 ADMIN 对应过滤器链的 hasRole(“ADMIN”)。springdoc 那三行只影响展示:调试时显示每次请求的耗时,标签与操作按字母序排,不写也不影响文档内容。
核心代码
注解:把接口说明贴在代码旁
Controller 上四个注解各管一段,订单列表接口一个方法就齐了:
| |
@Tag 给文档分栏,@Operation 说明一个操作,@Parameter 描述查询参数,@ApiResponse 声明响应。useReturnTypeSchema 让 200 的响应结构直接用返回值的 schema,不用再手写一份。实测最终文档收了 4 个路径条目、5 个操作,tags 两个:订单与订单运维。
错误响应的声明走复用路线,这笔账我觉得稳赚:401、403、404、409 各写一次,存进 components,控制器用 ref 引用:
| |
| |
四类错误响应各声明一次之后,OrderController 与 AdminController 的 @ApiResponses 里只剩 ref,改文案动一处就够,两边永远说一样的话。
DTO 到 schema 的映射

入参 DTO 上的注解决定对接方怎么理解字段,@Schema 负责描述,校验注解负责约束,两类都会出现在文档里:
| |
实测生成的 schema 逐项对得上:
@NotBlank 与 @NotNull 把两个字段送进 required 数组;@Size(max = 32) 变成 maxLength:32,附带一个 minLength:0,挺有意思,这个副作用来自 @Size 的 min 缺省值,不是我写错了;@DecimalMin(“0.01”) 变成 minimum:0.01。@Digits 没有对应的 schema 关键字,两位小数的规则要靠 description 或 example 补充说明。
枚举没有独立 schema,OrderResponse 的 status 字段内联四个状态名:
字段名保持 record 的驼峰原样进 schema,Instant 序列化成 type:string 加 format:date-time。前端再问「金额什么格式、状态有哪几种」,我把这份 JSON 甩过去就行,它与代码同一次提交,不用我另外维护。
分组:两份文档各管各的
一个服务同时伺候前端与运营后台时,全量文档里互相找。GroupedOpenApi 一个 bean 一份分组,pathsToMatch 圈定范围:
| |
实测两组文档各自 200:/v3/api-docs/orders 收 2 个路径条目(90.5ms),/v3/api-docs/admin 收另外 2 个(49.6ms),全量文档 /v3/api-docs 是两组并集(34.8ms)。swagger-ui 页面右上角的下拉框读这份配置:
name 字段取的是 displayName,没配 displayName 时才落回 group 名。group 名负责拼文档 URL,displayName 负责给人看,两个名字各有各的岗位,别混着用。
与 Security 的衔接
管理接口的认证怎么进文档,先讲取舍。18 与 19 篇的过滤器链是 JWT,OpenAPI 侧声明 Bearer securityScheme 之后,调试前还得先取一枚令牌;本篇管理面只有两个接口,HTTP Basic 让声明、调试、实现三件事同时最短,所以我选 basic。真要接 JWT,把 SecurityScheme 的 type 换成 http、scheme 换成 bearer,再补 bearerFormat: JWT,声明路线完全一样。
安全方案声明进 components:
| |
管理侧控制器整体挂上引用,两个操作的 401 与 403 用复用组件声明:
| |
@SecurityRequirement 贴在类上,一次覆盖全部管理操作,文档里每个操作都带 basicAuth 标记。实测文档里 components.securitySchemes.basicAuth 是 {“type”:“http”,“scheme”:“basic”},swagger-ui 的 Authorize 按钮因此有了用武之地:填一次账号密码,页面上调管理接口就自动带 Basic 头。实测带凭据调 GET /api/admin/orders 返回 200(145.4ms),不带凭据返回 401(8.9ms)。
过滤器链这一侧,文档端点放行、管理接口收权:
| |
CSRF 关掉之后,swagger-ui 页面里的 Try it out 才能直接发 POST 与 DELETE,不然每个写操作都会撞上 403。
属性开关:改名与下线
springdoc.* 的行为我用两次重启实测。第一次改路径,加启动参数 –springdoc.api-docs.path=/internal/api-docs:
| |
第二次关开关,加 –springdoc.api-docs.enabled=false:
| |
第二次的结果有点出乎我意料:文档与页面共用这一个总开关,SwaggerConfig 的端点注册条件跟着 api-docs 开关走,想「藏文档但留页面」的组合不存在。内网对内开放、外网网关封禁的组合下,应用侧留这道开关做最后一道闸,够用。
完整案例
一轮实测的场景与结果,验证脚本三启对账(默认配置、开关关闭、路径改名)。先看文档本体,GET /v3/api-docs 返回 200(34.8ms),头部实录:
头部之后是 4 个路径条目、5 个操作,components.schemas 里两个模型:CreateOrderRequest 与 OrderResponse。对接方拿这一份 JSON 就能生成各语言的客户端代码,OpenAPI 生态里这类工具是现成的。

浏览器打开 /swagger-ui.html 会 302 到 /swagger-ui/index.html(实测页面 200,7.6ms),渲染出来就是上图:右上角下拉选分组,OAS 3.1 徽标标明文档版本,Authorize 按钮存 Basic 凭据,每个操作展开后有参数表格和 Try it out。
接口行为一轮跑完:
| |
201 响应体与 400 的 errors 数组实录:

有一处细节我单独说:请求体里写的是 359.00,响应体与文档 example 里都是 359.0。JSON number 没有尾零的概念,这是序列化层的既有行为,跟精度没关系。对接双方按 number 理解金额即可,精度要求写进 description,别指望 example 替你传达。401 那一格的响应体是 Boot 默认 error JSON,与文档声明的 problem+json 对不上,原因见避坑指南坑六。
日志对账:整轮运行应用日志 ERROR 0 行。上面三张面板图来自同一次截图轮,耗时数字来自本轮验证轮,两轮的响应体结构一致,口径分开列在这里。
测试怎么写
六个测试类,18 个用例全绿:
| |
文档契约测试把断言打在 JSON 本体上:
| |
这类测试防的是「注解贴了、文档没生效」:依赖被谁注释掉了、@Size 写错成 @Length,页面照开但约束悄悄消失,一条 jsonPath 就能让它现形。openapi 字段同样盯住,断言 value(“3.1.0”),springdoc 升级改了输出版本,测试第一时间知道,这就是契约的铁证。
开关与改名的测试用 properties 参数覆盖配置,一个测试类一个场景:
| |
行为冒烟里的管理接口用 httpBasic() 后处理器走完整过滤器链,与 swagger-ui 调试时发的请求同构:
| |
两条断言并排放着,凭据的有无对应 204 与 401,与文档里声明的两个响应码一一对应。
避坑指南
坑一,依赖不写版本号直接解析失败。 Boot 的 BOM 不管理 springdoc,pom 里省掉 version 标签,Maven 第一步就报依赖缺版本。版本要自己跟着 springdoc 的发布走,3.x 线对 Boot 4,与本系列基线搭配的是 3.1.1。
坑二,api-docs.enabled=false 连 swagger-ui 页面一起下线。 SwaggerConfig 的端点注册条件跟着 api-docs 开关走,实测关掉之后 /swagger-ui/index.html 也是 404。「藏文档但留页面」这个组合不存在,要么全开要么全关。
坑三,@Size(max = 32) 会带出 minLength:0。 @Size 的 min 缺省是 0,springdoc 如实写进 schema。不想让对接方看到 minLength:0,换用只产生 maxLength 的声明方式,或者接受这个无害的零。
坑四,枚举默认不生成独立 schema。 components.schemas 里没有 OrderStatus,四个状态名内联在每个引用属性上。文档消费者想拿到可复用的枚举模型,要么接受内联(生成的客户端代码语义相同),要么显式改造声明方式并实测产物。
坑五,swagger-config 的 name 不是 group 名。 urls[].name 取 GroupedOpenApi.displayName,测试断言写 group 名(“orders”)会挂。我就为这个闹过一次笑话,断言改回 displayName 才过。group 名拼文档 URL,displayName 给下拉框,两个名字各有用途。
坑六,Security 的 401 响应体不是 problem+json。 Basic 认证失败发生在过滤器链里,@RestControllerAdvice 与 @ExceptionHandler 都够不着,响应体是 Boot 默认 error JSON(timestamp、status、error、path 四个键)。文档里声明的 401 组件描述的是业务语义;要统一响应体,得自定义 AuthenticationEntryPoint,18 与 19 篇就是这么做的。
坑七,GroupedOpenApi 的包名容易写错。 正确的是 org.springdoc.core.models.GroupedOpenApi,io.swagger.v3.oas.models 包里没有这个类,IDE 自动补全选错一边就是编译失败,我测试就栽在这里。
坑八,example 的 359.00 会变成 359.0。 swagger-core 按 JSON number 存 example,尾零在序列化时丢失。对金额敏感的接口,用 description 写明「单位元,两位小数」,精度约定不靠 example 传达。
坑九,分组文档跟着 api-docs.path 改名。 改了 springdoc.api-docs.path 之后,分组文档挂在新前缀下(实测 /internal/api-docs/orders 返回 200),swagger-config 里的 urls 自动更新,但写死在网关或 nginx 配置里的旧路径不会自己改。
坑十,管理操作别忘 @SecurityRequirement。 只声明 securitySchemes 而不在操作上引用,swagger-ui 调试时不会带凭据头,每次 Try it out 都落 401。类级 @SecurityRequirement(name = “basicAuth”) 一次覆盖整个 AdminController。
小结
选型建议:要文档跟着代码走,springdoc-openapi 是当下最省事的一条路,一个 starter 加几个注解就能开工,开销小到可以忽略。消费方多就上 GroupedOpenApi 分组,一个 bean 一份文档;管理接口认证方式简单就直接 Basic,声明、调试、实现三件事一起最短。文档契约值得写进测试,一条 jsonPath 断言就能拦住「注解贴了、文档没生效」这类静默退化。
没解决的事有两件:401 的响应体还是 Boot 默认 error JSON,与文档声明的 problem+json 不一致,统一它要自定义 AuthenticationEntryPoint,本篇没做;枚举想生成可复用的独立 schema,springdoc 默认给不了,改造方案我还没验证,先记在这里。
参考链接
- springdoc-openapi 官方文档:starter 坐标、属性说明与分组用法的权威来源
- OpenAPI Specification 3.1.0:文档格式的规范本体,本文 schema 关键字的依据
- swagger-core Annotations:@Schema、@Operation 等注解的逐项说明
- GroupedOpenApi 源码(springdoc-openapi):分组与 SwaggerConfig 的实现
- Spring Boot Reference - Security:spring.security.user.* 属性与过滤器链
- RFC 9457 - Problem Details:problem+json 响应结构
- springdoc-openapi-starter-webmvc-ui 3.1.1 (Maven Central):本文版本结论的制品实证来源