简介:springdoc-openapi-demos 汇集了在 Spring Boot 工程中落地 OpenAPI 3 的多组演示,面向已熟悉 Spring 基础、希望把接口文档生成、分组配置与安全描述纳入日常开发流程的 Java 工程师。压缩包共 219 个文件、约 281KB,其中 139 个 Java 源文件承载控制器、配置类与注解示例,23 个 XML 和 21 个 YAML 文件对应构建配置与应用参数,6 个 Markdown 文档、3 个 properties 以及少量 SQL、JSON 等辅助文件补充使用说明和场景数据,目录按演示模块划分,便于按需查阅。已有 967 人学习下载。内容覆盖 @OpenAPIDefinition、@Operation、@ApiResponse 等注解的声明方式,GroupedOpenApi 的路径分组,Spring Security 的认证描述,以及 Swagger UI 的接口调试入口,读者可据此对照实现文档端点、响应模型和安全方案。示例由基础接口逐步扩展到多模块组织,适合用于搭建项目文档骨架、核对注解效果、定位配置与路径匹配问题,对迁移 Swagger 2 或统一团队文档规范的场景也有参考价值。
1. 从“文档又没改”说起:springdoc-openapi-demos 到底解决了什么
前端丢过来一句“这个字段昨天还是user_name,今天怎么变成userName了,文档没同步?”,这种对话我经历过太多次。springdoc-openapi-demos 就是针对这件事的一组可运行样例:用 Spring Boot 承载 Java 服务,把 Controller 上的路径、参数、返回体自动抽取成 OpenAPI 3 规范的 JSON,再挂上 Swagger UI,让人直接在浏览器里点着调。它不是一个包打天下的大框架,而是一堆互相独立、能单独启动的小 demo,每个负责演示一类能力——最基础的注解标注、接口分组、Bearer 鉴权方案、WebFlux 响应式栈、Actuator 端点暴露。适合两类人:正在给存量 Spring Boot 项目补接口文档的后端,以及从旧版 springfox 迁过来、被两套注解体系差异折腾过一轮的开发者。
2. 项目骨架与技术选型:多模块仓库是怎么拆的
2.1 一个 demo 一个可启动应用,而不是一个应用挂多套配置
第一次打开这个仓库会有个错觉:怎么这么多模块,难道不能合成一个工程用 profile 切换?不能。springdoc 的很多行为跟运行栈绑死,Servlet 栈和响应式栈的自动配置类根本不是同一套,塞进一个进程里两套 starter 会互相覆盖。仓库选择的做法是“一个能力一个模块,模块之间不互相依赖”,哪个演示出问题就单独起那个,日志干净、复现路径短。目录骨架大致是这样:
springdoc-openapi-demos/ ├── pom.xml # 父 POM:集中管理 Boot 与 springdoc 的版本 ├── springdoc-openapi-spring-boot-2-webmvc/ # Servlet 栈,最常见的一个演示模块 │ ├── pom.xml │ └── src/main/java/.../SpringDocDemoApplication.java ├── springdoc-openapi-spring-boot-2-webflux/ # 响应式栈演示,路由用 RouterFunction ├── springdoc-openapi-spring-boot-2-webmvc-api/ # 只要 JSON,不带 UI 的最小形态 ├── springdoc-openapi-actuator/ # 把 actuator 端点也纳入文档 └── springdoc-openapi-security/ # 带鉴权的场景模块名会随分支变化,认前缀就行:带webmvc的是 Servlet 栈,带webflux的是响应式栈,带-api的是不带界面的纯描述输出。父 POM 里最关键的是dependencyManagement,它把 Spring Boot 和 springdoc 的版本各固定一处,子模块的pom.xml里写依赖时不用带<version>。好处是升级时只改一行,坏处是子模块里如果自己硬写版本会悄悄覆盖父级,这种不一致最难查。
这里有一条必须先讲清的版本线:Spring Boot 3 配 springdoc 2.x,Spring Boot 2 配 springdoc 1.x。两条线的 artifactId 和包名都变过,2.x 还把部分类挪进了models子包(比如分组用的GroupedOpenApi)。所以照着某个模块抄注解之前,先确认你看的那个模块属于哪条线,抄错分支的代码会一片红。
2.2 依赖只要两个:web starter 加一个 springdoc starter
真正要引的东西很少,一个 Web 容器,一个文档生成器:
<!-- 业务侧照旧,Web 能力来自 Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 文档能力:Servlet 栈 + 自带 Swagger UI 静态资源 --> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> </dependency>第二段依赖里,-ui后缀意味着它已经把 Swagger UI 的静态文件(以 webjar 形式)打包进来了,所以你不需要额外引 swagger-ui 的包,也不需要自己配ResourceHandler去映射静态资源——springdoc 的自动配置会注册好。很多人搜到旧教程往里加springfox-swagger2,那是另一套东西,两套同时在 classpath 上,扫描器会抢同一个RequestMappingHandlerMapping的结果,表现是接口时有时无、definitions里出现重复的模型名。这类问题排查起来很费劲,因为启动日志一切正常。
选型上还有个小决策:-ui还是-api。前者给你一个能点的页面,后者只暴露/v3/api-docs这份 JSON。对外交付、网关聚合、CI 里导出契约,用-api就够,少一堆静态资源;自己开发调试,-ui省事得多。
2.3 Servlet、WebFlux、纯描述三种形态的差别
仓库把三种形态分成不同模块,不是凑数,它们对应真实的三类服务:
| 依赖 | 运行栈 | 是否带 UI | 典型场景 |
|---|---|---|---|
| springdoc-openapi-starter-webmvc-ui | Spring MVC(Servlet,阻塞) | 带 | 后台管理类接口,占大多数 |
| springdoc-openapi-starter-webmvc-api | Spring MVC | 不带,只输出 JSON | 网关聚合、CI 导出契约 |
| springdoc-openapi-starter-webflux-ui | WebFlux(响应式) | 带 | 响应式服务,注解和 RouterFunction 两种写法都支持 |
| springdoc-openapi-starter-common | 两者共用 | — | 被上面几个传递依赖,不用自己引 |
这里有个容易混的点:WebFlux 项目里如果你用的是@RestController注解式写法,springdoc 照样能扫出来,跟你用不用RouterFunction无关;但如果你用的是函数式路由(RouterFunction<ServerResponse>),返回类型是ServerResponse,springdoc 推导不出真实响应体,必须在路由旁边额外声明。仓库里 WebFlux 那个模块两种写法都有,拿它对照着看最省事。
3. 跑起来:从编译到 Swagger UI 出页面
3.1 先把单个模块拉起来,别一上来就全量构建
根目录mvn install会把所有模块跑一遍,第一次拉依赖加编译,几分钟起步,中间某个模块如果对 JDK 版本有要求还会直接失败,很容易劝退。我的做法是先确认环境,再只构建我要看的那个模块:
# JDK 版本:Boot 3 那条线要 17 以上,Boot 2 那条线 8 也能跑 java -version mvn -v # 进仓库根目录,只编译一个模块。 # -pl 指定模块目录名,-am 表示顺带把它依赖的兄弟模块也构建上,-DskipTests 先跳过测试 mvn -pl springdoc-openapi-spring-boot-2-webmvc -am -DskipTests clean install # 启动这个模块;想换端口就加 -Dspring-boot.run.arguments=--server.port=9090 mvn -pl springdoc-openapi-spring-boot-2-webmvc spring-boot:run-pl是“只对这个模块动手”,-am(also make)是“把它的上游依赖也一起构建”。这两个参数组合起来是单模块调试的标配,不做全量构建能省掉大量等待。端口冲突是另一个高频拦截点:demos 仓库里好几个模块默认都写在 8080,同时又跑两个就一定起不来,报的是Port 8080 was already in use,改端口比查日志快。
启动日志里要留意一行,springdoc 在初始化时会打印出它扫描到的包和生成的描述端点路径。如果这行没出现,说明 springdoc 的自动配置压根没生效,八成是依赖没引对或者被exclude掉了。
3.2 三个端点验一遍,先确认“有东西出来”
服务起来之后别急着开浏览器,用命令行先确认描述端点通不通:
# OpenAPI 3 的 JSON 描述,整个文档的源头 curl -s http://localhost:8080/v3/api-docs | jq '.paths | keys' # 有的场景要 YAML,把后缀换掉就行 curl -s http://localhost:8080/v3/api-docs.yaml | head -30 # Swagger UI 的真实入口是 /swagger-ui/index.html, # 访问 /swagger-ui.html 会被 302 过来,curl 不加 -L 只能看到 302 curl -sIL http://localhost:8080/swagger-ui/index.html | head -1paths的 key 数量就是扫到的接口数,这个数字对不上,问题一定在扫描范围而不在 UI。/v3/api-docs返回 401 或者一段登录页 HTML,说明请求被安全过滤器拦了,这时候再怎么刷 UI 都没用,得先去放行路径。返回{"error":"..."}这类非标准结构,通常是全局异常处理器把 404 也接管了,顺手把描述端点变成了错误响应。
3.3 配置项:改哪几个就够用了
application.yml里真正会动的就那么几项,其余默认值基本不用碰:
springdoc: api-docs: path: /v3/api-docs # 描述端点的路径,改了前端/网关要同步改 enabled: true # 生产环境可以按 profile 关掉它 swagger-ui: path: /swagger-ui.html # UI 入口,实际会跳到 /swagger-ui/index.html tags-sorter: alpha # 标签按字母排,接口多了不用翻半天 operations-sorter: method # 接口按请求方法排,比按扫描顺序好读 persist-authorization: true # 刷新页面后 Authorize 里填的 token 还留着 packages-to-scan: com.example.demo # 只扫业务包,别把框架自带的端点扫进来 paths-to-match: /api/** # 只收 /api 开头的接口 default-produces-media-type: application/jsonpackages-to-scan和paths-to-match是最容易配错的两项,两个都填的时候它们会互相限制,结果常常是一个接口都不剩。我一般只留其中一个:包结构清晰的用packages-to-scan,路径前缀统一的用paths-to-match。tags-sorter老版本里直接写alpha就行,新版本要求alpha这种枚举值大小写严格,写成ALPHA会启动报错。
提示:
api-docs.enabled: false只关描述端点,UI 页面还在但会拉不到数据,白屏。要彻底关掉得同时停 UI 和 API 两块。
3.4 写第一个 Controller,看它自动推导出什么
配好之后随便写个接口就能看到效果:
@RestController @RequestMapping("/api/users") // 路径前缀直接成为文档里的 tag 分组来源 @Tag(name = "用户接口", description = "演示用的增删改查") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } // 路径变量、查询参数都会被识别成 parameter,required 由是否有默认值推断 @GetMapping("/{id}") public UserDTO detail(@PathVariable Long id, @RequestParam(defaultValue = "false") boolean withRoles) { return userService.find(id, withRoles); } // 请求体自动成为 requestBody,字段约束来自 Bean Validation 注解 @PostMapping public UserDTO create(@RequestBody @Valid UserCreateCmd cmd) { return userService.create(cmd); } }不用写一行文档注解,springdoc 就能从@RequestMapping推路径、从参数注解推位置和是否必填、从@Valid加字段上的@NotNull/@Size推校验约束、从返回类型推响应结构。这一步是它比手写 Markdown 更可靠的原因——代码改了文档跟着改,不会出现“文档写着必填、实际传空也能过”的错位。
推导不出来的地方才是注解上场的地方:业务含义(这个字段是“订单号”还是“外部单号”)、枚举的可选值、示例值、各种错误码的响应体。这些信息代码里没有,只能人补。
4. 核心拆解:注解、分组、鉴权三条主线
4.1 @Operation 和 @Parameter 把语义补齐
自动推导解决“结构”,注解解决“语义”。仓库里最值得抄的是它对错误响应的标注方式:
@RestController @RequestMapping("/api/orders") @Tag(name = "订单", description = "下单、查询、取消") public class OrderController { @Operation(summary = "按订单号查询", description = "订单不存在时返回 404,不抛 500") @ApiResponses({ @ApiResponse(responseCode = "200", description = "查询成功"), @ApiResponse(responseCode = "404", description = "订单不存在", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @GetMapping("/{orderNo}") public OrderDTO get( @Parameter(description = "业务订单号,不是数据库主键", example = "SO20240101001", required = true) @PathVariable String orderNo) { return orderService.get(orderNo); } }@Operation里的summary会显示成接口列表里那一行标题,写“查询订单”比写方法名get有用得多。@ApiResponse的content是关键:不写它,UI 上 404 那一栏是空的,前端不知道错误会长什么样;写了之后前端可以直接照着ErrorResponse写兜底逻辑。@Parameter的example会预填到 UI 的输入框里,联调时省掉“这个参数该填什么格式”的来回问。
一个细节:required = true对路径变量来说是多余的,路径变量天然必填,写了也不会出错,但对@RequestParam就很有必要,尤其是参数类型是包装类型(Integer而不是int)的时候,推导会判成非必填。
4.2 @Schema 和校验注解:模型层的描述怎么写
模型层的描述有两种写法,用 Java record 或者普通 DTO 都行,注解挂法一致:
@Schema(description = "订单返回体") public record OrderDTO( @Schema(description = "业务订单号", example = "SO20240101001", requiredMode = Schema.RequiredMode.REQUIRED) String orderNo, @Schema(description = "金额,单位分,别当成元用", example = "19900") Long amount, @Schema(description = "订单状态", allowableValues = {"NEW", "PAID", "CANCELLED"}) String status ) {}allowableValues会在 UI 上渲染成下拉框,比自己写一段字符串说明靠谱。requiredMode只在没有校验注解时才需要手动写,如果字段上挂了@NotNull,springdoc 会自动把它标成必填,两者写重复了以后者为准,不冲突但没必要。
金额那块我特意在description里写了单位。这是很多项目文档里最容易被忽略的一行字,前端按元传、后端按分存,联调时能对半天账。文档里写清楚比事后拉群解释成本低得多。
4.3 GroupedOpenApi:接口多了必须分组
一个服务几十个接口,全塞进一个页面,找起来很痛苦。分组用GroupedOpenApi:
@Configuration public class SpringDocConfig { // 后台端接口单独一组,前端只需要看自己那部分 @Bean public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() .group("admin") // 组名,会成为 URL 的一部分 .pathsToMatch("/api/admin/**") // 只收这个前缀下的接口 .addOpenApiCustomizer(openApi -> openApi.info(new Info().title("后台管理接口").version("v1"))) .build(); } // 客户端接口另一组 @Bean public GroupedOpenApi appApi() { return GroupedOpenApi.builder() .group("app") .pathsToMatch("/api/app/**") .build(); } }分完组之后,每组对应一个独立端点/v3/api-docs/admin、/v3/api-docs/app,Swagger UI 右上角会出现一个下拉框切换。默认那个不带组名的/v3/api-docs还在,内容是没被任何组匹配到的剩余接口——这一点常被忽略,有人以为分完组默认端点就空了,结果发现里面还有一堆东西,原因就是那些路径没被任何pathsToMatch覆盖。
pathsToMatch和packagesToScan都在的时候容易互相限制,我一般只用前者,因为路径前缀通常比包结构更贴近“这一组给谁看”这件事。
4.4 鉴权方案:让 UI 上有那个 Authorize 按钮
带 token 的接口,光有文档没用,得能在页面上填 token 试。这需要在OpenAPIbean 里声明安全方案:
@Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info().title("演示服务").version("v1") .description("接口文档由代码自动生成,字段含义以 description 为准")) .components(new Components().addSecuritySchemes("bearer-jwt", new SecurityScheme() .type(SecurityScheme.Type.HTTP) .scheme("bearer") .bearerFormat("JWT"))) // 全局生效;只想对个别接口生效就去掉这行,在方法上单独加 @SecurityRequirement .addSecurityItem(new SecurityRequirement().addList("bearer-jwt")); }写完这段,UI 右上角会多出一个 Authorize 按钮,填进去的 token 会自动拼成Authorization: Bearer xxx加到每个请求头里。但要注意,这个声明只影响文档展示,不参与任何校验。真正决定请求能不能过的是 Spring Security 的配置——/v3/api-docs和/swagger-ui/**这两个路径必须在过滤链里放行,否则页面本身都打不开,会出现“UI 能打开但一直转圈”的现象。
4.5 Actuator 端点:健康检查要不要进文档
健康检查、指标这类端点默认不在文档里,需要显式打开:
management: endpoints: web: exposure: include: health,info,metrics springdoc: show-actuator: true打开之后这些端点会以actuator为 tag 出现在 UI 里。要不要这么做,取决于谁来用这份文档:如果文档是给运维和监控平台看的,打开有价值;如果只给业务前端看,关掉更清爽,毕竟health的返回结构跟业务接口完全不是一回事。我个人在对外交付的文档里关掉,内部调试环境打开。
5. 避坑与排查:五个让我熬过夜的现场
5.1 升级之后 UI 全 404
现象:项目从 Spring Boot 2 升到 3,代码编译通过,服务正常启动,日志里也没报错,但/swagger-ui.html和/v3/api-docs双双 404。
原因:artifactId 换了。旧版是springdoc-openapi-ui,新版是springdoc-openapi-starter-webmvc-ui,包结构也从org.springdoc.core挪到了org.springdoc.core.models等子包。只改版本号不改 artifactId,Maven 会拉到老包,它依赖的javax.*在 Boot 3 的jakarta.*环境下不工作,自动配置类因为条件不满足被静默跳过。
解决:全局搜一遍springdoc,把所有 artifactId 换成带starter的新名字,同时把源码里org.springdoc.core.GroupedOpenApi这类 import 一并换掉。改完后启动日志里应该能看到 springdoc 的初始化信息,没有就是还没配对。
5.2 UI 能打开,但接口列表是空的
现象:页面正常渲染,右上角也没报错,就是一条接口都没有。
原因:扫描范围没覆盖到。要么packages-to-scan写的是父包但 Controller 在另一个包路径下,要么项目启用了server.servlet.context-path,springdoc 拼出来的路径前面多了前缀,而你请求的是不带前缀的地址。
解决:先临时把packages-to-scan和paths-to-match全注释掉,看接口能不能出来。能出来就说明是扫描配置问题,一点点加回去定位;出不来就看启动类所在的包,springdoc 默认从启动类所在包往下扫,启动类如果放在一个孤立的小包里,业务代码在兄弟包下就扫不到。
5.3 泛型返回变成了一个空对象
现象:接口返回Result<List<OrderDTO>>,文档里Result的data字段是空的{},看不到OrderDTO的结构。
原因:Java 泛型运行时擦除,springdoc 拿到的data类型就是一个T,推断不出具体是什么。这是个经典问题,不是 bug。
解决:在包装类上给字段加@Schema(implementation = ...)指定具体类型,或者干脆为每种返回定义一个明确的子类。分页对象同理,Page<T>里的content字段一定要显式标注,否则前端永远不知道列表项长什么样。我的习惯是给所有通用包装类都写死@Schema,宁可多写一行,也别让前端来猜。
5.4 try-it-out 发出去的请求全 404
现象:UI 上点 Execute,请求发出去了,但地址栏里的 URL 少了一段前缀或者多了端口,服务端返回 404。
原因:服务部署在反向代理后面,或者配了context-path,文档里生成的servers还是本地默认的http://localhost:8080。
解决:在OpenAPIbean 里显式设置servers,按 profile 区分开发、测试环境的地址;如果前端是通过网关访问的,网关那层的路径也要算进去。这个问题在本地永远复现不了,只有部署后才暴露,所以别等到上线前才验。
5.5 自定义拦截器把描述端点也拦了
现象:本地正常,加了一个登录校验拦截器之后,/v3/api-docs返回 401 或者一段重定向 HTML。
原因:WebMvcConfigurer.addInterceptors里注册的拦截器默认拦截/**,把 springdoc 注册的端点一并笼罩进去了。如果还重写过addResourceHandlers而没调super,Swagger UI 的静态资源映射也会被覆盖掉。
解决:拦截器的addPathPatterns明确写出业务路径前缀,或者用excludePathPatterns排除/v3/api-docs/**、/swagger-ui/**、/swagger-ui.html、/webjars/**四个。重写addResourceHandlers时保留super.addResourceHandlers(registry),别整个替换掉。这类问题之所以难查,是因为拦截器不报错,只是悄悄把请求转走,日志里一点痕迹都没有。
6. 二次开发:把 demo 的写法沉淀成项目里的文档层
拿这个仓库当参考代码看只是第一步,真正有价值的是把它的写法固化成自己项目的规范,让它变成构建流程的一部分,而不是一个“想起来才打开”的页面。
第一步,统一模型。挑一个公共模块放BaseResponse<T>和ErrorResponse,所有接口的返回都套这两层,然后在这两个类上把@Schema一次性写全。这样几十个接口不用逐个标注,文档质量整体抬一截。我一般还会约定@Tag的命名规则:一个业务域一个 tag,中文名加英文常量,前端按 tag 做菜单,后端按 tag 找接口,双方对得上号。
第二步,按端分组。后台、客户端、开放平台各一个GroupedOpenApi,pathsToMatch用各自的路径前缀。分组之后每个组可以有自己的标题和版本,对外交付时只导出对应那组的 JSON,不会把内部管理接口泄露出去。
第三步,也是最值得做的一步——把描述文件当成构建产物。别只依赖运行时页面,构建期导出一份进版本库,每次提交比对差异:
# 起一个临时实例,把描述导出成文件。/tmp/demo.pid 记下进程号方便收尾 mvn -q -pl springdoc-openapi-spring-boot-2-webmvc spring-boot:run & SPRING_PID=$! sleep 15 # --fail 让 HTTP 非 2xx 直接失败,避免把错误页写进文件 curl -sf http://localhost:8080/v3/api-docs -o openapi.json # 用 jq 做两项基本校验:接口总数、以及每个 POST 是否都有 requestBody jq '.paths | length' openapi.json jq '[.paths[] | select(has("post")) | has("requestBody")] | all' openapi.json kill $SPRING_PID这段脚本放进 CI 里跑,第二行会输出接口总数,接口数量突然少了一批,说明某次改动把扫描范围搞坏了;第三行输出false,说明有 POST 接口没有请求体,要么漏了@RequestBody,要么参数写成了表单。这两项检查不复杂,但能挡住大部分“写完能启动、文档却残缺”的改动。
改造前后的对照大致是这么几条:
| 改造点 | demo 里的做法 | 落到生产项目里怎么改 |
|---|---|---|
| 模型描述 | 每个 DTO 单独标@Schema | 抽公共包装类,注解只写一遍 |
| 接口分组 | 按路径前缀拆成两三个组 | 按调用方拆,组名与前端菜单一一对应 |
| 鉴权展示 | 全局声明 bearer 方案 | 按组声明,内部接口不开 Authorize |
| 描述产出 | 运行时页面访问 | 构建期导出 JSON,进 CI 做差异校验 |
| 环境开关 | 一直开着 | 生产按 profile 关掉 UI,只留 JSON 给网关 |
还有个具体技巧:persist-authorization: true配合分组使用时要留意,token 是按组存的,切组之后需要重新点一次 Authorize,如果发现切完组调不通,先看这里,别急着怀疑后端。另外operations-sorter别设成alpha,方法名首字母排序会把create/delete/get打乱成没有逻辑的顺序,按method排更符合读文档的习惯。
从那以后我每次加完新接口,都会先跑一遍导出脚本、用jq数一遍路径,再打开 UI 点一次 Authorize 走通一个真实请求——这套动作救过我两次上线,都是文档里少了个必填参数、或者分组前缀写错导致某个端整组消失。希望帮到你。
本文还有配套的精品资源,点击获取