本节目标:把 book-loan 服务的接口变成一份机器可读、可被工具消费的 OpenAPI 文档——讲清 springdoc-openapi 3.x 的依赖坐标、注解与推断的边界、多文档分组,以及生产环境该不该开、怎么收口。
适用版本:Spring Boot 4.1.x(Java 21)
7.1 OpenAPI 与 SpringDoc
第 6 章把 Book、Member、Loan 三个实体的查询从 N+1 的坑里拉了出来,接口能跑、数据也对了。但「能跑」和「能被别人安全地调用」是两件事:前端、测试同学、下游服务都需要知道每个接口收什么、返回什么、失败时是什么形状。靠一份手写的 Wiki 页,三天就会过期。
本节把 book-loan 的 HTTP 接口落成一份 OpenAPI 文档,并让它跟着代码自动更新。这里选 springdoc-openapi,而不是已经停止维护的 SpringFox。
7.1.1 为什么接口文档要「机器可读」
OpenAPI(原 Swagger 规范)用一份 JSON/YAML 描述所有路径、参数、请求体、响应和模型。它和「一篇 Markdown 文档」的区别,不在于好看,而在于它能被程序消费:
- 生成可交互的调试页面(Swagger UI),省掉手写 Postman 集合。
- 生成客户端 SDK(第 7.2 节会用 openapi-generator 做)。
- 在 CI 里做契约校验,改动不兼容时直接让流水线红。
- 给网关、Mock 服务、测试框架当输入。
一句话:OpenAPI 是接口的「源码」,文档页面只是它的一个渲染产物。 后面 7.2、7.3 两节都建立在「这份文件是唯一事实源」这个前提上。
7.1.2 依赖坐标:springdoc-openapi 3.x
Spring Boot 4 把 starter 名字改成了 spring-boot-starter-webmvc(详见官方 4.0 迁移指南与本书 1.2),第三方生态也做了对应调整。springdoc-openapi 与 Spring Boot 4 / Spring Framework 7 对应的版本线是 3.x,本文写作时最新为 3.1.1。
在 book-loan-web 模块里加依赖(spring-boot-starter-parent 4.1.1 只管 Spring 官方依赖,springdoc 要自己写版本):
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.1.1</version>
</dependency>
两个可选的 starter 要分清:
| artifactId | 含 Swagger UI | 适用场景 |
|---|---|---|
springdoc-openapi-starter-webmvc-ui | 是 | 开发/联调环境,需要可交互页面 |
springdoc-openapi-starter-webmvc-api | 否 | 只暴露 /v3/api-docs,页面交给网关或独立文档站 |
注意 artifactId 里的 webmvc:它对应 Spring Boot 4 的 spring-boot-starter-webmvc。若项目里出现 springdoc starter 与旧 starter 混用,先确认 Web 层是 WebMVC 而不是 WebFlux——两者要用不同的 springdoc starter。
7.1.3 最小可用:/v3/api-docs 与 Swagger UI
加完依赖、什么都不配,springdoc 就自动扫描所有 @RestController。启动后两个默认端点可用:
| 端点 | 默认路径 | 内容 |
|---|---|---|
| OpenAPI 文档 | /v3/api-docs | 默认输出 OpenAPI 3.1 的 JSON |
| Swagger UI | /swagger-ui.html | 交互页面(内部重定向到 /swagger-ui/index.html) |
用 curl 拿到的文档是一个大 JSON,摘录 book-loan 的一个接口:
{
"openapi": "3.1.0",
"info": { "title": "Book Loan API", "version": "1.0.0" },
"paths": {
"/api/books/{isbn}": {
"get": {
"tags": ["book-controller"],
"operationId": "getBook",
"parameters": [
{ "name": "isbn", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/BookResponse" }
}
}
}
}
}
}
}
}
上面是示例输出,用于说明文档结构。真实内容取决于你的接口定义,本机未针对 springdoc 单独跑过完整示例。
想改路径与标题,用配置项:
springdoc:
api-docs:
path: /v3/api-docs
version: openapi-3-1
swagger-ui:
path: /swagger-ui.html
display-request-duration: true
spring:
application:
name: book-loan
springdoc.api-docs.version 默认为 openapi-3-1;如果下游工具只认 3.0,把它改成 openapi-3-0。
7.1.4 用注解补齐语义
自动推断能拿到「结构」,拿不到「意图」。book-loan 的接口有三样东西推断不出来:接口的一句话说明、字段的业务含义、错误响应的形状。用 io.swagger.v3.oas.annotations 下的注解补齐。
@RestController
@RequestMapping("/api/books")
@Tag(name = "图书", description = "图书的查询与借阅状态")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Operation(summary = "按 ISBN 查询图书",
description = "返回图书详情;不存在时返回 404。")
@ApiResponse(responseCode = "200", description = "查询成功")
@ApiResponse(responseCode = "404", description = "图书不存在",
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
@GetMapping("/{isbn}")
public BookResponse getBook(
@Parameter(description = "ISBN-13,13 位数字", example = "9787115428028")
@PathVariable String isbn) {
return bookService.findByIsbn(isbn);
}
}
模型侧用 @Schema 描述字段:
@Schema(name = "BookResponse", description = "图书详情")
public record BookResponse(
@Schema(description = "ISBN-13", example = "9787115428028") String isbn,
@Schema(description = "书名", example = "深入理解计算机系统") String title,
@Schema(description = "可借册数", example = "3") int availableCopies) {
}
@Schema 有几个容易忽略但很有用的属性:requiredMode 控制字段是否必填、allowableValues 给枚举列候选值、example 直接决定 Swagger UI 里「Try it out」的预填内容。给 example 不是装饰——它让联调的人第一次点开就知道该填什么。
7.1.5 推断与注解的边界
一个常见争论是「注解要不要写全」。答案是:推断负责结构,注解负责语义,两者不重叠。
| 内容 | 能否自动推断 | 建议 |
|---|---|---|
| 路径、HTTP 方法 | 是(来自 @GetMapping 等) | 不用写 |
| 路径/查询参数 | 是(来自 @PathVariable/@RequestParam) | 不用写 |
| 请求体与响应模型结构 | 是(来自参数/返回类型) | 不用写 |
| 字段业务含义 | 否 | 写 @Schema(description) |
| 示例值 | 否 | 写 @Schema(example) |
| 错误响应(400/404/409) | 部分(全局异常处理推断不到) | 写 @ApiResponse |
| 枚举候选值 | 否 | 写 allowableValues |
| 接口分组归属 | 否 | 写 @Tag 或 GroupedOpenApi |
推断出的模型名默认取简单类名。book-loan 里 BookResponse 若在多处复用,建议用 @Schema(name = ...) 显式命名,避免不同包下同名类在文档里撞名。
7.1.6 分组与多文档
book-loan 有两类调用方:前台借阅页和管理后台。它们不该看到同一份文档。用 GroupedOpenApi 按路径切分:
@Configuration
public class OpenApiGroupsConfig {
@Bean
GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/books/**", "/api/loans/**")
.build();
}
@Bean
GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("admin")
.pathsToMatch("/api/admin/**")
.build();
}
}
每个分组会暴露在 /v3/api-docs/<group> 下,Swagger UI 右上角出现分组下拉。分组也可以纯用配置声明(springdoc.group-configs),但用 GroupedOpenApi bean 更灵活——它支持 packagesToMatch、pathsToExclude,以及给分组单独挂 OpenApiCustomizer。
分组的另一个用途是隔离内部接口。 把 actuator、内部运维接口放到单独分组,前台分组里就永远不会出现它们。
7.1.7 生产环境的开关与访问控制
「文档该不该在生产环境开着」是个安全决策,不是便利决策。文档会暴露所有路径、参数名、错误码,等于给扫描器一份地图。
两种收口方式,按需要选:
方式一:按 profile 关闭。 生产环境直接不暴露:
# application-prod.yml
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
方式二:保留但加鉴权。 文档本身有价值(方便线上排查),只是不对外开放。用 Spring Security 把它挡在管理员之后:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.hasRole("ADMIN")
.anyRequest().authenticated());
return http.build();
}
判据很简单:如果接口本身需要登录才能调,文档就不该匿名可读。 反过来,完全公开的开放 API,文档公开反而是加分项。
7.1.8 常见坑
坑一:把 springdoc 依赖加到了错的模块。 springdoc 要加在有 @RestController 的那个模块(book-loan 里是 book-loan-web)。加到 book-loan-core 上,扫描不到控制器,文档是空的。
坑二:以为 Swagger UI 只有 /swagger-ui.html 一个路径。 /swagger-ui.html 会重定向到 /swagger-ui/index.html;如果用网关做路径白名单,两个前缀都要放行,否则页面加载出来是空白。
坑三:全局异常处理返回的错误体没进文档。 400/404/409 往往由 @RestControllerAdvice 统一返回 ProblemDetail,springdoc 推断不到。必须手动 @ApiResponse,否则调用方只能靠猜。
坑四:给每个 DTO 都堆满注解。 注解的价值在语义,不在覆盖度。结构能推断的就别写,把精力放在 example、错误响应和枚举候选值上。
小结
- OpenAPI 是接口的机器可读源码,Swagger UI 只是它的渲染产物;后面契约先行与版本演进都建立在「这份文件是唯一事实源」之上。
- Spring Boot 4 对应的 springdoc-openapi 是 3.x 线(本文用
3.1.1);-ui含页面、-api只出文档,按是否需要在应用里看页面来选。 - 默认端点:
/v3/api-docs(默认 OpenAPI 3.1)与/swagger-ui.html;标题、路径、OpenAPI 版本都可通过springdoc.*配置。 - 推断负责结构(路径、参数、模型),注解负责语义(
@Operation说明、@Schema字段含义与示例、@ApiResponse错误响应);不要用注解重复结构。 - 多文档用
GroupedOpenApi按路径分组,前台与后台各看各的,内部接口不进公开分组。 - 生产环境要么按 profile 关掉文档,要么用 Spring Security 把它挡在鉴权之后;判据是「接口要不要登录,文档就该怎么保护」。
文档能自动生成之后,下一个问题变成:到底是代码生成文档,还是文档生成代码? 7.2 会把顺序倒过来,让 OpenAPI 文件成为先写的那一份。
阅读导航:上一节:6.3 N+1 与批量查询 · 下一节:7.2 契约先行与代码生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。