隔壁服务的 API,怎么跑我这儿来了?

某天启动 auth 服务,打开 http://localhost:8021/doc.html ,想看一眼自己刚调好的三个 API 分组——等等,下拉框里怎么还有 message、order 的分组?点过去全是 404,auth 服务上根本就没有这些接口。

flowchart LR
    subgraph USER["👤 开发者"]
        A(["打开 auth 的\ndoc.html"])
    end
    subgraph ACTUAL["期望"]
        B([只显示自己\n3 个分组])
    end
    subgraph REAL["现实"]
        C([显示了 8 个\n不同服务的分组])
    end
    A --> B
    A --> C
    C --> D{other groups\ntap 404}
    D -->|yes| E[「这就很烦了」]

代码是同一套代码,springdoc 和 knife4j 版本都是统一管理的,为什么 auth 的文档页面里会出现其他服务的痕迹?某开发者决定,今天不修好不下班。

第一反应:去 knife4j 找配置

这种"在一个服务里看到另一个服务的 API",第一感觉就是 knife4j 的网关聚合功能 在作祟。毕竟 knife4j 有个专门的 knife4j-aggregation-spring-boot-starter ,专门用来在 gateway 上聚合所有微服务的文档。

二话不说,给每个服务的 application.yml 加上:

knife4j:
  enableAggregation: false

重启,刷新——没变化,其他分组稳如泰山地挂在下拉框里。

又试了 knife4j.cloud.enable: false ——依然纹丝不动。

这时候某开发者已经意识到,方向可能错了。

追到 jar 包里看源码

与其盲猜配置项,不如直接看 knife4j 到底有没有这个开关。

从本地 Maven 仓库里扒出 knife4j-openapi3-jakarta-spring-boot-starter-4.5.0.jar ,翻它的自动配置类:

# 看看自动配置注册了什么
jar tf knife4j-*.jar | grep -i "AutoConfiguration"

输出只有两个:

Knife4jAutoConfiguration
Knife4jInsightAutoConfiguration

反编译 Knife4jAutoConfiguration ,发现它只注册了 OpenApi 自定义器、CORS 过滤器和 BasicAuth 过滤器——没有任何跨服务聚合逻辑

Knife4jInsightAutoConfiguration 呢?它是个 CommandLineRunner ,启动时把本服务的 OpenAPI 信息上报给一个中心化的 Insight 服务器——这只在 knife4j.insight.enable=true 时才会激活,默认是关闭的。

结论:knife4j 基础 starter 本身没有跨服务聚合功能,问题不在 knife4j 身上。

日志露出的狐狸尾巴

既然 knife4j 不是元凶,那看看实际请求了什么。某开发者打开浏览器开发者工具,刷新 doc.html,网络请求一览无余:

/v3/api-docs/swagger-config    → 200 (获取文档配置)
/v3/api-docs/admin             → 200 (auth 自己的分组)
/v3/api-docs/mobile            → 200 (auth 自己的分组)
/api/message/v3/api-docs       → error
/api/order/v3/api-docs         → error

/api/message/v3/api-docs/api/order/v3/api-docs —— 这两个路径的格式非常扎眼:/api/{service-name}/v3/api-docs 。这既不是 auth 服务能处理的路径,也不是 knife4j 的请求格式,而是 SpringDoc 的 swagger-config 端点返回了这些 URL

sequenceDiagram
    actor Dev as 浏览器
    participant Auth as Auth 服务
    participant SpringDoc as SpringDoc swagger-config
    participant Knife4jUI as Knife4j UI

    Dev->>Auth: GET /doc.html
    Auth->>Knife4jUI: 加载页面
    Knife4jUI->>SpringDoc: GET /v3/api-docs/swagger-config
    SpringDoc-->>Knife4jUI: 返回 urls 列表(含外部服务)
    Knife4jUI->>Auth: GET /v3/api-docs/admin
    Auth-->>Knife4jUI: 200 ✅
    Knife4jUI->>Auth: GET /api/message/v3/api-docs
    Auth-->>Knife4jUI: 404 ❌
    Note over Knife4jUI,Auth: 外部服务的路径在本服务上 404

所以关键问题是:SpringDoc 的 swagger-config 端点,从哪拿到这些外部服务的 URL 的?

SpringDoc 的 swagger-config 是怎么组装 URL 的

某开发者找到了 SpringDoc 2.6.0 的源码,追踪 swagger-config 的响应生成链路。

请求 /v3/api-docs/swagger-config 时,实际处理的是 SwaggerWelcomeCommon.openapiJson() 方法:

// SwaggerWelcomeCommon.java
protected Map<String, Object> openapiJson(HttpServletRequest request) {
    buildFromCurrentContextPath(request);
    return swaggerUiConfigParameters.getConfigParameters();
}

buildFromCurrentContextPath 中调用了父类 AbstractSwaggerWelcome.init() ,这个 init() 方法是关键:

// AbstractSwaggerWelcome.java
protected void init() {
    springDocConfigProperties.getGroupConfigs()
        .forEach(groupConfig -> 
            swaggerUiConfigParameters.addGroup(
                groupConfig.getGroup(), 
                groupConfig.getDisplayName()
            )
        );
    calculateUiRootPath();
}

它遍历 springDocConfigProperties.getGroupConfigs() ,对每个 GroupConfig 调用 swaggerUiConfigParameters.addGroup() 。每次 addGroup() 调用,都会在结果集的 urls 列表里增加一个条目。

但是—— GroupedOpenApi 定义的三个分组(mobile、admin、internal)是通过 SpringDocAutoConfiguration 注册的,走的是另一套机制,跟这里的 GroupConfig 无关。

/api/message/v3/api-docs 这种非标准格式的 URL 是谁加的?

flowchart TD
    SWC[SwaggerWelcomeCommon\nopenapiJson] --> BUILD[buildFromCurrentContextPath]
    BUILD --> INIT[AbstractSwaggerWelcome.init]
    INIT --> GROUP{GroupConfigs}
    GROUP --> ADD[swaggerUiConfigParameters.addGroup]
    ADD --> URL[URL added\n/api-docs/group]
    
    BCF[buildConfigUrl] --> CHECK{urls empty?}
    CHECK -->|not empty| SKIP[keep existing urls]
    CHECK -->|empty| FALLBACK[fallback to single URL]
    
    subgraph EXTERNAL[External Config]
        NACOS(Nacos shared-configs)
        PROP(springdoc.swagger-ui.urls)
    end
    
    EXTERNAL -->|bind| URL_SET[SwaggerUiConfigProperties.urls]
    URL_SET --> CP[SwaggerUiConfigParameters]
    CP --> URL_LIST
    
    ADD --> URL_LIST[urls final list]
    URL_LIST --> JSON[openapiJson returns JSON]
    JSON --> UI[Knife4j UI renders dropdown]

分析到这里,某开发者有了一个猜测:这些外部 URL 是从 Nacos 共享配置(common.yaml)中通过 springdoc.swagger-ui.urls 属性注入的

在微服务架构中,所有服务共享一个 Nacos 的 common.yaml 配置。如果这个配置里定义了:

springdoc:
  swagger-ui:
    urls:
      - name: auth
        url: /api/auth/v3/api-docs
      - name: message
        url: /api/message/v3/api-docs
      - name: order
        url: /api/order/v3/api-docs

那么所有服务启动时都会加载这些 URL,在自己的 swagger-config 端点上返回它们。

由于不能登录 Nacos 确认(权限原因),某开发者决定从另一个角度验证——用本地配置覆盖掉任何来自 Nacos 的外部 URL

绕了三个弯的解决方案

第一次尝试:swagger-ui.urls 覆盖

springdoc:
  swagger-ui:
    urls: []

想法很直接:把 urls 设为空列表,覆盖 Nacos 注入的值。Spring Boot 的配置优先级是 application.yml > Nacos config ,这应该生效。

结果:部分生效。本服务的三个分组仍然在,但外部 URL 有所减少,没有完全清除。

第二次尝试:关掉 discovery 自动发现

springdoc:
  api-docs:
    discovery:
      enabled: false

SpringDoc 有一个 springdoc.api-docs.discovery.enabled 属性,默认就是 false ,但某开发者怀疑 Nacos 配置里可能把它改成了 true 。显式设回 false ,强制覆盖。

第三次尝试:限制包扫描范围

springdoc:
  packages-to-scan: cn.net.mall.auth

告诉 SpringDoc:别到处扫描,我 auth 服务只看自己的包。

最终的完整配置

三重覆盖合在一起,才是最终有效的方案:

springdoc:
  api-docs:
    enabled: true
    path: /v3/api-docs
    groups:
      enabled: true
    discovery:
      enabled: false
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    urls: []
    disable-swagger-default-url: true
  packages-to-scan: cn.net.mall.auth
  show-actuator: false
  cache:
    disabled: true

每个服务的 packages-to-scan 改成自己的根包:

  • auth → cn.net.mall.auth
  • product → cn.net.mall.product
  • basic → cn.net.mall.basic
  • 依此类推
flowchart LR
    subgraph BEFORE["覆盖前"]
        NACOS["Nacos 共享配置\n塞了一堆外部分组"] -->|注入| SWC[swagger-config 端点]
        GROUPED["本地 GroupedOpenApi\n3 个自己的分组"] -->|注册| SWC
        SWC -->|一股脑全返回| UI_BEFORE[("下拉框里\nadmin / mobile / internal\n+ message / order …")]
    end

    subgraph AFTER["覆盖后"]
        LOCAL["本地 application.yml\n明确写了 urls: []"] -->|覆盖| SWC2[swagger-config 端点]
        GROUPED2["本地 GroupedOpenApi\n3 个自己的分组"] -->|注册| SWC2
        SWC2 -->|只返自己的| UI_AFTER[("下拉框里\n干干净净\n只有自己的分组")]
    end
    
    style NACOS fill:#2a1147,stroke:#a855f7,color:#ede9fe
    style LOCAL fill:#1e293b,stroke:#0284c7,color:#f8fafc,font-weight:bold
    style UI_AFTER fill:#052e16,stroke:#16a34a,color:#bbf7d0,font-weight:bold
    style UI_BEFORE fill:#450a0a,stroke:#dc2626,color:#fecaca,font-weight:bold

总结与反思

回头看这个问题,某开发者花了不少时间在错误的方向上——一开始总认为是 knife4j 的锅,翻了一圈它的源码才发现人家根本没有这个功能。真正的问题躲在 SpringDoc 的配置链路里,由 Nacos 共享配置静默注入。

尝试结果原因
knife4j.enableAggregation: false这是 gateway 模块的属性,基础 starter 不认识
knife4j.cloud.enable: falseKnife4jProperties 里根本没有这个字段
springdoc.swagger-ui.urls: []🟡 部分有效本地配置优先级需要配合其他属性
完整 springdoc 本地覆盖三层属性联动生效

⚠️ 新手提示:排查这种"灵异现象"时,先打开浏览器开发者工具看实际请求了什么 URL。请求路径的格式能直接告诉你谁在作祟——/api/{service}/v3/api-docs 这种格式 = SpringDoc 配置注入,跟 knife4j 没关系。

某开发者也学到了一个教训:不要对着配置手册盲猜属性名。反编译 jar 包看 Knife4jProperties.class 的字段列表,五分钟就能确认某个属性是否存在,比写十行 yml 猜来猜去都有效率。

最后,所有サービスの application.yml 加上完整的 springdoc 本地配置,从此 doc.html 清清爽爽,只显示自己的分组。下班。