SPARROW FILE · 接口文档

OpenAPI 分组策略及相关使用说明

本文记录 Sparrow File 接入 springdoc-openapi 时遇到的两个问题、根因与最终方案, 并给出「多 starter 共存」场景下如何给接口文档分组的通用做法。

目录 CONTENTS
  1. 背景:什么是 OpenAPI / Swagger UI
  2. 遇到的两个问题
  3. 根因分析
  4. 最终方案:分组策略
  5. 完整案例(file + passport 两个 starter)
  6. 使用说明:如何新增一个分组
  7. 验证方法
  8. 注意事项与踩坑清单

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.htmlSwagger UI 可视化界面
/v3/api-docs默认分组的 OpenAPI JSON
/v3/api-docs/{group}某个分组的 OpenAPI JSON,如 /v3/api-docs/file
/v3/api-docs/swagger-configSwagger 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最终方案:分组策略

核心思路只有两条:

✅什么是「分组」

你可以把 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 下拉里,只需三步:

  1. 写一个 XxxOpenApiProperties,用独立前缀(如 sparrow.xxx.openapi),定义 title、group、packagesToScan。
  2. 在自动配置类里注册一个 GroupedOpenApi Bean,用 addOpenApiCustomizer 注入 Info。
  3. 用 @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 里放行)。