《Spring Boot 实战》7.1 OpenAPI 与 SpringDoc

讲清 Spring Boot 4 下 springdoc-openapi 3.x 的依赖坐标与版本线,用 @Operation、@Schema、@ApiResponse 补齐接口语义,划清推断与注解的边界,用 GroupedOpenApi 拆分前台与后台文档,并给出生产环境关闭文档或加鉴权的两种收口方式与四类常见坑。

本节目标:把 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 契约先行与代码生成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计