01背景:什么是 OpenAPI / Swagger UI
OpenAPI 是一套描述 REST 接口的标准规范(以前叫 Swagger)。一段 OpenAPI 文档 用 JSON/YAML 描述「有哪些接口、每个接口的路径、参数、返回值」。它本身只是一份数据, 不参与业务逻辑。
springdoc-openapi 是 Spring Boot 生态下最常用的 OpenAPI 实现:它会自动扫描
你的 @RestController / @RequestMapping 等注解,动态生成接口文档,并提供一个
Swagger UI 页面(浏览器里可视化查看、调试接口)。
springdoc 帮你把代码里的 Controller 自动变成网页版接口文档,Swagger UI 是看这个文档的界面。
本项目相关地址(以本地 9999 端口为例):
| 地址 | 作用 |
|---|---|
/swagger-ui/index.html | Swagger UI 可视化界面 |
/v3/api-docs | 默认分组的 OpenAPI JSON |
/v3/api-docs/{group} | 某个分组的 OpenAPI JSON,如 /v3/api-docs/file |
/v3/api-docs/swagger-config | Swagger UI 下拉里有哪些分组 |
02遇到的两个问题
问题一:应用启动直接报错
文件服务同时引入了两个 starter:passport-starter(认证)和 file-starter(文件)。
两个 starter 各自都想给 Swagger UI 提供接口文档。结果应用启动时抛出:
Parameter 0 of method openAPIBuilder in
org.springdoc.core.configuration.SpringDocConfiguration
required a single bean, but 2 were found:
- passportOpenAPI: defined by method 'passportOpenAPI' in
class path resource [com/sparrow/passport/config/PassportAutoConfiguration.class]
- fileOpenAPI: defined by method 'fileOpenAPI' in
class path resource [com/sparrow/file/config/FileAutoConfiguration.class]
翻译过来:springdoc 内部需要一个「全局 OpenAPI」对象,但发现了两个,不知道用哪个,于是启动失败。
问题二:切换分组,接口却一模一样
解决了启动报错后,Swagger UI 里出现了 file 和 passport 两个分组。
但切到 passport 分组时,看到的仍然是 file 的接口,接口没有变化。
03根因分析
根因一:@ConditionalOnMissingBean 跨类失效
两个 starter 的自动配置里都写了这样一段,试图「如果已经有 OpenAPI 我就不注册了」:
@Bean
@ConditionalOnMissingBean(OpenAPI.class)
public OpenAPI xxxOpenAPI(...) { ... }
@ConditionalOnMissingBean 的语义是「容器里还没有这个类型的 Bean 时才注册」。
但它在跨多个 @Configuration 类时存在时序问题:两个配置类在同一轮被解析,
互相都看不到对方已经注册,于是两个 OpenAPI Bean 都注册了,最终冲突。
多个 starter 共存时,不要各自注册全局 OpenAPI Bean。全局的只能有一个, 谁注册都可能和别的 starter 撞车。
根因二:springdoc 全局配置优先级高于分组配置
当时在 application.properties 里写了全局配置:
springdoc.packages-to-scan=com.sparrow.file
springdoc.paths-to-match=/**
而 springdoc 源码 AbstractOpenApiResource 里的逻辑是「全局优先,全局为空才回退用分组的」:
// springdoc 源码逻辑(简化)
List<String> packagesToScan = springDocConfigProperties.getPackagesToScan(); // 先取全局
if (packagesToScan.isEmpty()) { // 只有全局为空
packagesToScan = groupConfig.getPackagesToScan(); // 才用分组的
}
于是全局的 com.sparrow.file 覆盖了 passport 分组自己的 com.sparrow.passport,
导致 所有分组都扫了 file 的包——这就是「切到 passport 还是 file 接口」的原因。
04最终方案:分组策略
核心思路只有两条:
- 不注册全局 OpenAPI Bean,改用
GroupedOpenApi(分组)+addOpenApiCustomizer给每个分组注入自己的标题/描述。 - 不设全局
springdoc.packages-to-scan/springdoc.paths-to-match,让每个分组用自己的packagesToScan扫描自己的包。
你可以把 Swagger UI 右上角的下拉框理解成「分组」:每个分组对应一个
GroupedOpenApi Bean,各自扫描不同的包、显示不同的标题。这样 file 和 passport
就能在同一个 Swagger UI 里互不干扰地共存。
05完整案例(file + passport 两个 starter)
下面以本项目实际代码为例,说明分组策略如何落地。
5.1 配置属性类 FileOpenApiProperties
用独立的 sparrow.file.openapi.* 前缀,避免和 springdoc 官方、passport 的配置冲突:
@ConfigurationProperties(prefix = "sparrow.file.openapi")
public class FileOpenApiProperties {
private boolean enabled = true;
private String title = "Sparrow File";
private String description = "Sparrow File";
private String version = "1.0";
private String group = "file";
private String packagesToScan = "com.sparrow.file";
// ... getter/setter、contact、license 略
}
5.2 自动配置 FileAutoConfiguration(关键)
只注册「分组」,不注册全局 OpenAPI Bean,把标题等信息通过 customizer 挂到分组上:
@Bean
@ConditionalOnMissingBean(name = "fileGroup")
@ConditionalOnProperty(prefix = "sparrow.file.openapi", name = "enabled",
havingValue = "true", matchIfMissing = true)
public GroupedOpenApi fileGroup(FileOpenApiProperties properties) {
return GroupedOpenApi.builder()
.group(properties.getGroup()) // 分组名:file
.packagesToScan(properties.getPackagesToScan()) // 扫描包:com.sparrow.file
.addOpenApiCustomizer(openApi -> openApi.info(buildInfo(properties))) // 注入标题
.build();
}
private Info buildInfo(FileOpenApiProperties properties) {
Info info = new Info()
.title(properties.getTitle())
.description(properties.getDescription())
.version(properties.getVersion());
// contact / license 可选,这里略
return info;
}
passport starter 的 PassportAutoConfiguration 同理,分组名为 passport、扫描 com.sparrow.passport。
5.3 配置文件 application.properties
只保留两个开关,不要写全局 packages-to-scan / paths-to-match:
# 正确写法:只开开关,其余交给分组自己配置
springdoc.api-docs.enabled=true
springdoc.swagger-ui.enabled=true
# 错误写法(会覆盖所有分组的扫描包,导致串包):
# springdoc.packages-to-scan=com.sparrow.file
# springdoc.paths-to-match=/**
06使用说明:如何新增一个分组
假设你新建了一个 xxx-starter,想让它也出现在 Swagger UI 下拉里,只需三步:
- 写一个
XxxOpenApiProperties,用独立前缀(如sparrow.xxx.openapi),定义title、group、packagesToScan。 - 在自动配置类里注册一个
GroupedOpenApiBean,用addOpenApiCustomizer注入 Info。 - 用
@ConditionalOnMissingBean(name = "xxxGroup")防止覆盖宿主的同名分组,用@ConditionalOnProperty支持开关关闭。
分组名(group)建议与 starter 名一致:file、passport、xxx。
访问该分组的 JSON 文档地址就是 /v3/api-docs/{group}。
07验证方法
重启应用后,依次验证:
# 1. 分组列表(应能看到 file、passport 两个 url)
curl http://localhost:9999/v3/api-docs/swagger-config
# 2. file 分组(应只有 file 的接口:upload.json / base64-upload.json 等)
curl http://localhost:9999/v3/api-docs/file
# 3. passport 分组(应只有 passport 的接口:登录 / 注册等)
curl http://localhost:9999/v3/api-docs/passport
浏览器打开 http://localhost:9999/swagger-ui/index.html,右上角下拉切换分组,两个分组的标题和接口应该各自独立。
08注意事项与踩坑清单
| 坑 | 现象 | 正确做法 |
|---|---|---|
多个 starter 各自注册全局 OpenAPI Bean |
启动报错 required a single bean, but 2 were found |
只注册 GroupedOpenApi,用 addOpenApiCustomizer 注入标题 |
设了全局 springdoc.packages-to-scan |
所有分组都显示同一个包的接口 | 删除全局配置,让分组各自 packagesToScan |
跨配置类用 @ConditionalOnMissingBean(类型) |
条件失效,出现重复 Bean | 分组用 @ConditionalOnMissingBean(name = "...")(按名字)更可靠 |
| Swagger UI 显示 "No API definition provided." | spec 接口没返回合法 JSON(常见于被认证拦截或分组为空) | 确认认证排除名单包含 /v3/api-docs、/swagger-ui/** |
如果项目有登录拦截,记得把 /v3/api-docs、/v3/api-docs/**、
/swagger-ui/**、/swagger-ui.html 加到放行名单,否则 Swagger UI 会一直提示
"No API definition provided."(本项目已在 sparrow.authc.exclude-patterns 里放行)。