文章目录

Spring Cloud 学习与实践(14):集成 Knife4j,实现 JWT 调试与 Gateway 文档聚合

本章严格按照实际演练顺序推进:先让单个服务生成接口文档,再补充 OpenAPI 3 注解,随后扩展到多个微服务,最后通过 Gateway 聚合文档并完成 JWT 调试。

本章不会一开始就给出最终配置,而是保留实际过程中遇到的请求地址错误、跨域、Authorize 不生效、旧版诊断地址混用和网关前缀重复等问题。


1、本章要解决什么问题

经过前面章节的演进,当前项目已经包含:

cloud-gateway
cloud-auth
cloud-user
cloud-product
cloud-order

接口数量越来越多,仅靠代码或手工记录会出现几个明显问题:

接口分散在不同服务
    ↓
开发人员需要记住多个端口和路径
    ↓
请求参数、响应结构和状态含义不够直观
    ↓
受保护接口还要手工拼接 JWT 请求头
    ↓
联调成本逐渐增加

本章希望实现:

Controller
    ↓
springdoc-openapi 解析
    ↓
生成 OpenAPI 3 文档
    ↓
Knife4j 展示与调试
    ↓
Gateway 聚合多个服务
    ↓
统一使用 JWT 调试业务接口

最终开发入口为:

http://localhost:9000/doc.html

2、OpenAPI、springdoc 和 Knife4j 的关系

2.1 三者分别负责什么

名称 本质 当前项目中的职责
OpenAPI 接口描述规范 定义接口、参数、模型、安全方案等文档结构
springdoc-openapi Spring 接口解析工具 扫描 Controller 和注解,生成 /v3/api-docs
Swagger UI OpenAPI 基础展示页面 提供基础文档展示和调试
Knife4j OpenAPI 增强 UI 与聚合组件 提供中文界面、增强调试和 Gateway 聚合

调用关系可以理解为:

Spring MVC Controller
    ↓
springdoc-openapi
    ↓
OpenAPI JSON
    ↓
Knife4j
    ↓
文档展示与接口调试

Knife4j 并不是替代 OpenAPI,也不是直接扫描 Controller 的核心工具。


2.2 为什么不用以前的 Knife4j 2.x

以前常见的组合是:

knife4j-spring-boot-starter 2.x
    +
Springfox
    +
Swagger 2

常见注解:

@Api
@ApiOperation
@ApiParam
@ApiModel
@ApiModelProperty

当前项目采用:

knife4j-openapi3-spring-boot-starter 4.5.0
    +
springdoc-openapi
    +
OpenAPI 3

对应注解:

@Tag
@Operation
@Parameter
@Schema

新旧注解对应关系:

Swagger 2 / Springfox OpenAPI 3 / springdoc
@Api @Tag
@ApiOperation @Operation
@ApiParam @Parameter
@ApiModel @Schema
@ApiModelProperty @Schema
Docket OpenAPIGroupedOpenApi、YAML 分组

当前项目统一使用:

io.swagger.v3.oas.annotations.*

两套注解不要混用。


3、阶段一:只给 cloud-user 接入 Knife4j

3.1 本阶段目标

先建立最小闭环:

cloud-user 引入依赖
    ↓
springdoc 扫描 Controller
    ↓
生成 /v3/api-docs
    ↓
Knife4j 打开 /doc.html

此时暂不处理:

接口中文说明
JWT
Gateway
多服务聚合

这样可以先观察:不写 OpenAPI 注解时,springdoc 能自动生成多少内容。


3.2 父工程统一管理版本

父工程增加版本:

<knife4j.version>4.5.0</knife4j.version>

dependencyManagement 中增加:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
    <version>${knife4j.version}</version>
</dependency>

当前项目是 Spring Boot 2.7.18,因此使用:

knife4j-openapi3-spring-boot-starter

不要使用 Spring Boot 3 对应的 Jakarta Starter。


3.3 cloud-user 引入依赖

cloud-user/pom.xml 增加:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
</dependency>

父工程已经统一管理版本,子模块不再重复填写。


3.4 增加最小配置

cloud-user-dev.yaml 中增加两个顶级配置块:

springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
  group-configs:
    - group: default
      paths-to-match: /**
      packages-to-scan: com.example.cloud.user.controller

knife4j:
  enable: true
  setting:
    language: zh_cn

在这里插入图片描述


3.5 重启与验证

本阶段只重启:

cloud-user

依次访问:

http://localhost:9200/v3/api-docs
http://localhost:9200/swagger-ui.html
http://localhost:9200/doc.html

在这里插入图片描述

三者职责:

地址 作用
/v3/api-docs 原始 OpenAPI 3 JSON
/swagger-ui.html springdoc 自带页面
/doc.html Knife4j 增强页面

本阶段实际结果:

  • 三个地址都能正常访问;
  • Knife4j 能自动识别现有用户接口;
  • 接口名称和字段说明仍然比较粗糙。

3.6 阶段一结论

不写 OpenAPI 注解
    ↓
文档仍然可以生成

但是
    ↓
接口名称、参数含义和模型说明不够清楚

这正是阶段二要解决的问题。


4、阶段二:补充 OpenAPI 3 注解

4.1 本阶段目标

本阶段不修改用户业务逻辑,只改善文档可读性:

设置服务标题
    ↓
给 Controller 分组
    ↓
给接口添加中文名称
    ↓
给参数增加说明
    ↓
给实体字段增加示例

4.2 新增 OpenApiConfig

创建:

cloud-user
└── com.example.cloud.user.config
    └── OpenApiConfig.java

最初只设置文档信息:

@Bean
public OpenAPI userOpenApi() {
    return new OpenAPI()
            .info(
                    new Info()
                            .title("cloud-user 用户服务接口文档")
                            .description(
                                    "提供用户查询、分页、新增、修改和删除接口"
                            )
                            .version("v1.0")
            );
}

添加后,文档标题不再是:

OpenAPI definition

而是:

cloud-user 用户服务接口文档

4.3 给 User 增加 @Schema

本阶段以 User 实体作为模型注解示例:

package com.example.cloud.user.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 用户实体。
 */
@Data
@TableName("t_user")
@Schema(description = "用户信息")
public class User {

    @TableId(type = IdType.AUTO)
    @Schema(
            description = "用户 ID",
            example = "1"
    )
    private Long id;

    @Schema(
            description = "登录用户名",
            example = "zhangsan"
    )
    private String username;

    @Schema(
            description = "用户昵称",
            example = "张三"
    )
    private String nickname;

    @Schema(
            description = "手机号码",
            example = "13800000001"
    )
    private String phone;

    @Schema(
            description = "用户状态:1-启用,0-禁用",
            example = "1",
            allowableValues = {"0", "1"}
    )
    private Integer status;

    @Schema(
            description = "创建时间",
            example = "2026-06-01T10:00:00"
    )
    private LocalDateTime createTime;
}

本项目的 User 同时承担:

数据库实体
新增请求体
修改请求体
查询响应

因此没有盲目把字段全部标成必填。

这也暴露出接口契约设计中的一个问题:

同一个实体同时承担多种职责
    ↓
不同场景的字段约束难以准确表达

更规范的生产设计通常会拆分为:

CreateUserRequest
UpdateUserRequest
UserResponse

本章只记录该边界,不为了文档展示而突然重构已有业务代码。


4.4 给 UserController 增加注解

Controller 使用:

@Tag(
        name = "用户管理",
        description = "用户查询、分页、新增、修改和删除接口"
)

接口使用:

@Operation(
        summary = "根据 ID 查询用户",
        description = "根据用户 ID 查询单个用户"
)

参数使用:

@Parameter(
        description = "用户 ID",
        required = true,
        example = "1"
)

请求体同时存在两个同名注解:

org.springframework.web.bind.annotation.RequestBody

负责真正读取 HTTP 请求体。

io.swagger.v3.oas.annotations.parameters.RequestBody

只负责文档说明。

为了避免 import 冲突,文档注解可以使用完整类名。
完整代码:

package com.example.cloud.user.controller;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.user.entity.User;
import com.example.cloud.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 用户管理接口。
 */
@Tag(
        name = "用户管理",
        description = "用户查询、分页、新增、修改和删除接口"
)
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    /**
     * 查询全部用户。
     */
    @Operation(
            summary = "查询全部用户",
            description = "返回用户表中的全部用户。"
                    + "数据量较大时应优先使用分页查询接口。"
    )
    @GetMapping
    public Result<List<User>> list() {
        List<User> users = userService.list();
        return Result.success(users);
    }

    /**
     * 根据 ID 查询用户。
     */
    @Operation(
            summary = "根据 ID 查询用户",
            description = "根据用户 ID 查询单个用户;"
                    + "用户不存在时返回资源不存在错误。"
    )
    @GetMapping("/{id}")
    public Result<User> getById(
            @Parameter(
                    description = "用户 ID",
                    required = true,
                    example = "1"
            )
            @PathVariable Long id
    ) {
        User user = userService.getById(id);

        if (user == null) {
            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "用户不存在"
            );
        }

        return Result.success(user);
    }

    /**
     * 新增用户。
     */
    @Operation(
            summary = "新增用户",
            description = "新增一条用户记录。"
                    + "用户 ID 和创建时间通常由数据库生成,"
                    + "调用方无需主动传入。"
    )
    @PostMapping
    public Result<Void> create(
            @io.swagger.v3.oas.annotations.parameters.RequestBody(
                    description = "新增用户信息",
                    required = true
            )
            @RequestBody User user
    ) {
        boolean success = userService.save(user);

        if (!success) {
            return Result.fail(
                    ErrorCode.BIZ_ERROR,
                    "新增用户失败"
            );
        }

        return Result.success();
    }

    /**
     * 修改用户。
     */
    @Operation(
            summary = "修改用户",
            description = "根据请求体中的用户 ID 修改用户信息;"
                    + "用户 ID 不能为空。"
    )
    @PutMapping
    public Result<Void> update(
            @io.swagger.v3.oas.annotations.parameters.RequestBody(
                    description = "需要修改的用户信息,必须包含用户 ID",
                    required = true
            )
            @RequestBody User user
    ) {
        if (user.getId() == null) {
            return Result.fail(
                    ErrorCode.PARAM_ERROR,
                    "用户 ID 不能为空"
            );
        }

        boolean success = userService.updateById(user);

        if (!success) {
            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "用户不存在或修改失败"
            );
        }

        return Result.success();
    }

    /**
     * 删除用户。
     */
    @Operation(
            summary = "根据 ID 删除用户",
            description = "根据用户 ID 删除一条用户记录。"
    )
    @DeleteMapping("/{id}")
    public Result<Void> delete(
            @Parameter(
                    description = "需要删除的用户 ID",
                    required = true,
                    example = "6"
            )
            @PathVariable Long id
    ) {
        boolean success = userService.removeById(id);

        if (!success) {
            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "用户不存在或删除失败"
            );
        }

        return Result.success();
    }

    /**
     * 分页查询用户。
     */
    @Operation(
            summary = "分页查询用户",
            description = "按照当前页和每页数量分页查询用户。"
    )
    @GetMapping("/page")
    public Result<Page<User>> page(
            @Parameter(
                    description = "当前页,从 1 开始",
                    example = "1"
            )
            @RequestParam(defaultValue = "1") long current,

            @Parameter(
                    description = "每页记录数",
                    example = "10"
            )
            @RequestParam(defaultValue = "10") long size
    ) {
        Page<User> page = userService.page(
                new Page<>(current, size)
        );

        return Result.success(page);
    }
}

4.5 验证结果

重启 cloud-user 后:

/v3/api-docs

中可以看到:

用户管理
查询全部用户
根据 ID 查询用户
分页查询用户

Knife4j 页面中也能看到:

  • 中文接口分组;
  • 中文接口名称;
  • 路径参数说明;
  • 分页参数示例;
  • User 字段描述;
  • Result<T> 自动解析出的 codemessagedata

在这里插入图片描述

4.6 为什么只完整注解用户服务

用户服务已经覆盖:

GET
POST
PUT
DELETE
路径参数
查询参数
请求体
分页
泛型响应

并演示了:

@Tag
@Operation
@Parameter
@RequestBody
@Schema

继续给认证、商品和订单接口重复添加同类注解,主要是复制工作,新的学习收益很低。

因此本章明确采用:

服务 文档策略
cloud-user 完整 OpenAPI 注解示例
cloud-auth springdoc 自动生成基础文档
cloud-product springdoc 自动生成基础文档
cloud-order springdoc 自动生成基础文档

这不是遗漏,而是主动控制演练范围。


5、阶段三:扩展到其他微服务

5.1 本阶段目标

让下面三个服务也能独立生成文档:

cloud-auth
cloud-product
cloud-order

本阶段暂不做 Gateway 聚合。


5.2 三个服务增加依赖

分别在三个模块中增加:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
</dependency>

5.3 分别增加 springdoc 配置

认证服务:

springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
  group-configs:
    - group: default
      paths-to-match: /**
      packages-to-scan: com.example.cloud.auth.controller

knife4j:
  enable: true
  setting:
    language: zh_cn

商品服务和订单服务只需要修改扫描包路径。
在这里插入图片描述


5.4 分别增加文档标题

认证服务:

.title("cloud-auth 认证服务接口文档")

商品服务:

.title("cloud-product 商品服务接口文档")

订单服务:

.title("cloud-order 订单服务接口文档")

以认证服务为例:

package com.example.cloud.auth.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI authOpenApi() {
        return new OpenAPI()
                .info(
                        new Info()
                                .title("cloud-auth 认证服务接口文档")
                                .description("提供用户登录和认证相关接口")
                                .version("v1.0")
                );
    }
}

在这里插入图片描述


5.5 验证四个独立入口

服务 文档地址
认证 http://localhost:9100/doc.html
用户 http://localhost:9200/doc.html
商品 http://localhost:9300/doc.html
订单 http://localhost:9400/doc.html

四个页面均可正常打开。
在这里插入图片描述

此时也出现新的问题:

文档已经生成
    ↓
但是开发人员仍要记住四个地址

后面的 Gateway 聚合将解决这个问题。


6、阶段四:让文档调试真正经过 Gateway

6.1 为什么不能直接调用业务端口

用户文档页面默认发出:

http://localhost:9200/users/1

这会绕过 Gateway。

但当前项目真实鉴权链路是:

客户端
    ↓
Gateway 校验 JWT
    ↓
Gateway 提取 userId
    ↓
写入 X-User-Id
    ↓
下游 UserContext

所以文档调试应该走:

http://localhost:9000/api/user/users/1

6.2 调整认证服务OpenApiConfig

修改:

cloud-auth
└── src/main/java
    └── com.example.cloud.auth.config
        └── OpenApiConfig.java

将 Bean 调整为:

@Bean
public OpenAPI authOpenApi() {
    return new OpenAPI()
            .info(
                    new Info()
                            .title("cloud-auth 认证服务接口文档")
                            .description("提供用户登录和认证相关接口")
                            .version("v1.0")
            )
            .addServersItem(
                    new Server()
                            .url("http://localhost:9000/api")
                            .description("Gateway 统一入口")
            );
}

6.3 调整用户服务 OpenApiConfig:

.package com.example.cloud.user.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * 用户服务 OpenAPI 文档配置。
 */
@Configuration
public class OpenApiConfig {

    /**
     * OpenAPI 安全方案名称。
     *
     * Components 中注册的名称,
     * 必须和 SecurityRequirement 引用的名称一致。
     */
    private static final String SECURITY_SCHEME_NAME = HttpHeaders.AUTHORIZATION;

    @Bean
    public OpenAPI userOpenApi() {
        return new OpenAPI()
                .info(
                        new Info()
                                .title(
                                        "cloud-user 用户服务接口文档"
                                )
                                .description(
                                        "提供用户查询、分页、新增、"
                                                + "修改和删除接口"
                                )
                                .version("v1.0")
                )
                /*
                 * Knife4j 页面虽然由 9200 端口提供,
                 * 但调试接口时统一请求 Gateway。
                 */
                .addServersItem(
                        new Server()
                                .url(
                                        "http://localhost:9000"
                                                + "/api/user"
                                )
                                .description("Gateway 统一入口")
                )
                /*
                 * 声明 Bearer JWT 安全方案。
                 */
                .components(
                        new Components()
                                .addSecuritySchemes(
                                        SECURITY_SCHEME_NAME,
                                        new SecurityScheme()
                                                .type(
                                                        SecurityScheme
                                                                .Type
                                                                .HTTP
                                                )
                                                .scheme("bearer")
                                                .bearerFormat("JWT")
                                                .description(
                                                        "登录后获得的 JWT。"
                                                                + "授权时只粘贴 "
                                                                + "Token 本身,"
                                                                + "不手动添加 "
                                                                + "Bearer 前缀。"
                                                )
                                )
                );
    }
}

6.4 调整商品服务 OpenApiConfig:

private static final String SECURITY_SCHEME_NAME = HttpHeaders.AUTHORIZATION;

@Bean
public OpenAPI productOpenApi() {
    return new OpenAPI()
            .info(
                    new Info()
                            .title(
                                    "cloud-product 商品服务接口文档"
                            )
                            .description(
                                    "提供商品查询、管理和库存扣减接口"
                            )
                            .version("v1.0")
            )
            .addServersItem(
                    new Server()
                            .url(
                                    "http://localhost:9000"
                                            + "/api/product"
                            )
                            .description("Gateway 统一入口")
            )
            .components(
                    new Components()
                            .addSecuritySchemes(
                                    SECURITY_SCHEME_NAME,
                                    new SecurityScheme()
                                            .type(
                                                    SecurityScheme
                                                            .Type
                                                            .HTTP
                                            )
                                            .scheme("bearer")
                                            .bearerFormat("JWT")
                                            .description(
                                                    "登录后获得的 JWT。"
                                                            + "只粘贴 "
                                                            + "Token 本身。"
                                            )
                            )
            );
}

6.5 调整订单服务 OpenApiConfig:

private static final String SECURITY_SCHEME_NAME = HttpHeaders.AUTHORIZATION;

@Bean
public OpenAPI orderOpenApi() {
    return new OpenAPI()
            .info(
                    new Info()
                            .title(
                                    "cloud-order 订单服务接口文档"
                            )
                            .description(
                                    "提供订单创建、查询及 MQ 演练接口"
                            )
                            .version("v1.0")
            )
            .addServersItem(
                    new Server()
                            .url(
                                    "http://localhost:9000"
                                            + "/api/order"
                            )
                            .description("Gateway 统一入口")
            )
            .components(
                    new Components()
                            .addSecuritySchemes(
                                    SECURITY_SCHEME_NAME,
                                    new SecurityScheme()
                                            .type(
                                                    SecurityScheme
                                                            .Type
                                                            .HTTP
                                            )
                                            .scheme("bearer")
                                            .bearerFormat("JWT")
                                            .description(
                                                    "登录后获得的 JWT。"
                                                            + "只粘贴 "
                                                            + "Token 本身。"
                                            )
                            )
            );
}

6.6 先观察用户Network请求

重启:

cloud-auth
cloud-user
cloud-product
cloud-order

打开:

http://localhost:9200/doc.html

选择:

根据 ID 查询用户

先确认页面显示的请求地址已经变成:

http://localhost:9000/api/user/users/1

而不是:

http://localhost:9200/users/1

然而结果是:
OpenAPI JSON 中确实出现:

"servers": [
  {
    "url": "http://localhost:9000/api/user"
  }
]

但实际 Network 请求仍然是:

http://localhost:9200/users/1

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

这说明:

OpenAPI servers 已生效
    ↓
但 Knife4j 当前调试请求没有自动使用它

6.7 servers 不等于实际调试 Host

最终在 Nacos 中cloud-user-dev.yaml增加:

knife4j:
  enable: true
  setting:
    language: zh_cn

    # 开启 Knife4j 调试请求的自定义 Host。
    enable-host: true

    # 调试用户服务接口时,统一通过 Gateway 访问。
    enable-host-text: http://localhost:9000/api/user

Knife4j 自定义 Host 会参与调试请求的基础地址拼接。

在这里插入图片描述

修改配置后:

1. 重启 cloud-user
2. 浏览器对 /doc.html 执行 Ctrl + F5
3. 必要时关闭当前页面,重新打开

在这里插入图片描述

用户接口:

Host:
http://localhost:9000/api/user

接口路径:
/users/1

最终:
http://localhost:9000/api/user/users/1

另外三个服务分别配置:

认证服务:

knife4j:
  enable: true
  setting:
    language: zh_cn
    enable-host: true
    enable-host-text: http://localhost:9000/api

商品服务:

knife4j:
  enable: true
  setting:
    language: zh_cn
    enable-host: true
    enable-host-text: http://localhost:9000/api/product

订单服务:

knife4j:
  enable: true
  setting:
    language: zh_cn
    enable-host: true
    enable-host-text: http://localhost:9000/api/order

6.8 解决跨域

独立用户文档页面来源:

http://localhost:9200

调试目标:

http://localhost:9000

端口不同,属于跨域。

Gateway 的 CORS 配置中增加:

allowedOrigins:
  - "http://localhost:9100"
  - "http://localhost:9200"
  - "http://localhost:9300"
  - "http://localhost:9400"

在这里插入图片描述

同时放行:

GET
POST
PUT
DELETE
OPTIONS

JWT Filter 也必须放行 OPTIONS 预检请求。

修复后,未携带 Token 请求用户接口,能够正常到达 Gateway 并返回 401。

这证明请求已经不再绕过网关。
在这里插入图片描述


6.9 配置 JWT 调试

通过认证服务文档登录

打开:

http://localhost:9100/doc.html

进入登录接口:

POST /auth/login

请求体使用当前测试账号,例如:

{
  "username": "zhangsan",
  "password": "123456"
}

页面显示的最终请求地址应为:

http://localhost:9000/api/auth/login

预期返回:

{
  "code": 0,
  "message": "success",
  "data": {
    "token": "eyJ...",
    "tokenType": "Bearer",
    "expiresIn": 3600,
    "userId": 1,
    "username": "zhangsan"
  }
}

复制:

data.token

只复制三段式 JWT 本身:

eyJ...xxx.yyy

不要复制:

Bearer eyJ...

因为 OpenAPI 的 HTTP Bearer 安全方案会自动拼接:

Authorization: Bearer <token>

在这里插入图片描述

点击左侧:

Authorize

Authorization中只粘贴 Token 本身。
在这里插入图片描述
结果未出现预期中的请求头Authorization

在这里插入图片描述
在这里插入图片描述

实际现象:

Authorize 中填写 Token
    ↓
页面显示已经授权
    ↓
接口请求仍然没有 Authorization
    ↓
Gateway 返回 401

为了先确认 Token 和 Gateway 没有问题,临时使用:

文档管理
    ↓
全局参数设置

手工添加:

参数名称:Authorization
参数类型:header
参数值:Bearer <JWT>

在这里插入图片描述

请求成功。
在这里插入图片描述

这一步证明:

Token 正常
Gateway 正常
路由正常
问题在 Knife4j 对安全方案的应用

6.10 添加Operation.security

仅声明 SecurityScheme 还不够,OpenAPI 3 的安全配置有两层:

components.securitySchemes
    ↓
定义系统支持什么认证方案

Operation.security
    ↓
声明具体接口使用哪种认证方案

如果接口本身没有 security,Knife4j 会认为该接口不需要认证。

不希望给每个方法都加:

@SecurityRequirement

因此给cloud-user、cloud-product、cloud-order三个服务的OpenApiConfig增加:

@Bean
public GlobalOpenApiCustomizer bearerAuthGlobalOpenApiCustomizer() {
    return openApi -> {
        if (openApi.getPaths() == null) {
            return;
        }

        openApi.getPaths().values().forEach(
                pathItem -> pathItem
                        .readOperations()
                        .forEach(
                                operation ->
                                        operation.addSecurityItem(
                                                new SecurityRequirement()
                                                        .addList(
                                                                SECURITY_SCHEME_NAME
                                                        )
                                        )
                        )
        );
    };
}

这个定制器为当前服务的所有 Operation 增加安全方案。

认证服务不添加该定制器,因为登录接口本来就是匿名接口。

需要区分:

名称 含义
Authorize Knife4j 左侧授权入口
Authorization 标准 HTTP 请求头
Bearer Authorization 请求头中的认证方案
JWT Bearer 后面的 Token 内容

最终请求格式:

Authorization: Bearer eyJ...

重启服务刷新文档并删除之前手工添加的全局参数,最终只保留左侧 Authorize。
在这里插入图片描述


7、阶段五:通过 Gateway 聚合四个服务文档

7.1 本阶段目标

将四个独立入口统一为:

http://localhost:9000/doc.html

并在左上角切换:

认证服务
用户服务
商品服务
订单服务

7.2 Gateway 引入聚合组件

父工程管理:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-gateway-spring-boot-starter</artifactId>
    <version>${knife4j.version}</version>
</dependency>

cloud-gateway 引入:

<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-gateway-spring-boot-starter</artifactId>
</dependency>

Gateway 的职责是聚合子服务文档,不需要扫描业务 Controller。


7.3 认证服务文档路由的特殊处理

用户、商品和订单路由都使用:

StripPrefix=2

所以:

/api/user/v3/api-docs
    ↓
/v3/api-docs

可以直接获取文档。

认证业务路由使用:

/api/auth/**
StripPrefix=1

如果直接请求:

/api/auth/v3/api-docs

下游会收到:

/auth/v3/api-docs

而真实文档地址是:

/v3/api-docs

因此增加专用文档路由:

spring:
  cloud:
    gateway:
      routes:
        # 认证服务专用文档路由。
        #
        # /api/auth-docs/v3/api-docs
        # 删除前两段 /api/auth-docs 后,
        # cloud-auth 实际收到 /v3/api-docs。
        - id: cloud-auth-docs
          uri: lb://cloud-auth
          predicates:
            - Path=/api/auth-docs/**
          filters:
            - StripPrefix=2

在这里插入图片描述


7.4 手动聚合配置

knife4j:
  gateway:
    enabled: true
    strategy: manual
    tags-sorter: order
    operations-sorter: order

    routes:
      - name: 认证服务
        service-name: cloud-auth
        url: /api/auth-docs/v3/api-docs?group=default
        context-path: /
        order: 1

      - name: 用户服务
        service-name: cloud-user
        url: /api/user/v3/api-docs?group=default
        context-path: /
        order: 2

      - name: 商品服务
        service-name: cloud-product
        url: /api/product/v3/api-docs?group=default
        context-path: /
        order: 3

      - name: 订单服务
        service-name: cloud-order
        url: /api/order/v3/api-docs?group=default
        context-path: /
        order: 4

在这里插入图片描述

其中:

配置 作用
name 左上角服务名称
url Gateway 获取子服务 OpenAPI JSON 的地址
service-name 服务标识
context-path 聚合时补充的业务前缀
order 服务排序

四个服务都使用:

?group=default

没有冲突。

分组名称来自 name,不是 group 参数。


7.5 放行文档资源

Gateway 统一鉴权会拦截文档页面和文档 JSON。

开发环境需要放行:

/doc.html
/favicon.ico
/webjars/**
/v3/api-docs/**
/api/auth-docs/v3/api-docs/**
/api/user/v3/api-docs/**
/api/product/v3/api-docs/**
/api/order/v3/api-docs/**

最终由 JwtAuthGlobalFilter 中的文档资源判断负责放行。不要重写整个 JwtAuthGlobalFilter,只在当前白名单判断中补充文档资源。可以在现有过滤器中增加一个辅助方法:

/**
 * 判断当前请求是否属于 Knife4j 或 OpenAPI 文档资源。
 *
 * 这些资源只在当前开发学习环境中放行。
 */
private boolean isApiDocumentResource(String path) {
    return "/doc.html".equals(path)
            || "/favicon.ico".equals(path)
            || path.startsWith("/webjars/")
            || path.startsWith("/swagger-resources")
            || path.startsWith("/v3/api-docs")
            || path.startsWith(
                    "/api/auth-docs/v3/api-docs"
            )
            || path.startsWith(
                    "/api/user/v3/api-docs"
            )
            || path.startsWith(
                    "/api/product/v3/api-docs"
            )
            || path.startsWith(
                    "/api/order/v3/api-docs"
            );
}

在这里插入图片描述

生产环境不能直接沿用该策略。


7.6 先逐个验证文档 JSON

聚合页面之前,先访问:

http://localhost:9000/api/auth-docs/v3/api-docs?group=default
http://localhost:9000/api/user/v3/api-docs?group=default
http://localhost:9000/api/product/v3/api-docs?group=default
http://localhost:9000/api/order/v3/api-docs?group=default

在这里插入图片描述

四个地址都正常返回 JSON 后,再排查聚合页面。


7.7 注意新版诊断地址

排查聚合分组时,曾访问:

http://localhost:9000/swagger-resources

在这里插入图片描述

结果是 404。

原因是:

/swagger-resources
属于旧 Springfox / Swagger 2 路线

当前项目采用:

springdoc-openapi
+
OpenAPI 3

正确诊断地址:

http://localhost:9000/v3/api-docs/swagger-config

在这里插入图片描述

最终确认其中 urls 数组包含四项。

这一问题没有影响项目本身,但说明排查时不能混用两代技术栈。


7.8 左上角切换服务

打开聚合页面时,只看到认证服务。

需要点击左上角下拉框切换:

认证服务
用户服务
商品服务
订单服务

这属于界面使用方式,不是聚合故障。


8、最终验证链路

8.1 登录

在聚合页面切换到认证服务:

POST /auth/login

实际请求:

http://localhost:9000/api/auth/login

登录成功后复制 data.token

在这里插入图片描述


8.2 Authorize

在业务服务分组中点击:

Authorize

只填写 JWT 本身:

eyJ...

不要手工增加:

Bearer 

Knife4j 自动生成:

Authorization: Bearer eyJ...

8.3 用户服务

GET /users/1

实际请求:

http://localhost:9000/api/user/users/1

正常返回用户信息。
在这里插入图片描述


8.4 商品服务

GET /products/1

实际请求:

http://localhost:9000/api/product/products/1

正常返回商品信息。
在这里插入图片描述


8.5 订单服务

GET /context/user-id

实际请求:

http://localhost:9000/api/order/context/user-id

正常返回 JWT 中的真实用户 ID。

在这里插入图片描述


9、几个容易混淆的配置

配置 真实职责
springdoc.group-configs 控制 Controller 扫描与 OpenAPI 分组
OpenAPI.info 设置标题、说明和版本
OpenAPI.servers 描述接口服务器
knife4j.setting.enable-host-text 控制独立文档页面的实际调试 Host
components.securitySchemes 定义认证方案
Operation.security 声明接口使用该认证方案
GlobalOpenApiCustomizer 批量给所有 Operation 增加认证
Gateway routes.url 聚合时获取文档 JSON
Gateway context-path 聚合时补充业务前缀
/v3/api-docs/swagger-config 查看 OpenAPI 3 聚合分组
JWT 文档白名单 开发环境放行文档资源

10、Knife4j 与相关工具对比

10.1 Knife4j 与 Swagger UI

对比项 Knife4j Swagger UI
文档规范 OpenAPI OpenAPI
中文体验 较好 基础
接口调试 支持 支持
分组与增强功能 更丰富 相对基础
Gateway 聚合 提供专用组件 通常需要自行处理

Knife4j 不是 OpenAPI 的替代者,而是增强展示层。


10.2 Knife4j 与 Springfox

对比项 当前方案 旧方案
解析框架 springdoc-openapi Springfox
文档规范 OpenAPI 3 Swagger 2
原始地址 /v3/api-docs /v2/api-docs
聚合诊断 /v3/api-docs/swagger-config /swagger-resources
常用注解 @Tag@Operation @Api@ApiOperation

新项目更适合直接使用 OpenAPI 3 路线。


10.3 Knife4j 与 Apifox、Postman

工具 更适合的场景
Knife4j 接口文档随代码生成,开发环境快速联调
Swagger UI 基础 OpenAPI 展示
Apifox 接口设计、Mock、测试、团队协作
Postman 请求调试、集合管理、自动化测试
YApi 团队接口管理和 Mock

它们不是完全互斥的。

实际团队可以:

springdoc 生成 OpenAPI
    ↓
Knife4j 日常查看
    ↓
导入 Apifox 或 Postman 做团队协作

11、生产环境边界

11.1 关闭业务服务文档

生产配置:

springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: false

knife4j:
  enable: false

11.2 关闭 Gateway 聚合

knife4j:
  gateway:
    enabled: false

11.3 收紧 Gateway 白名单

生产环境不应继续匿名放行:

/doc.html
/webjars/**
/v3/api-docs/**
各服务文档 JSON 路径

仅关闭 UI 不够,OpenAPI JSON 也可能暴露接口结构。


11.4 文档安全声明不等于服务端鉴权

下面这些配置:

SecurityScheme
SecurityRequirement
GlobalOpenApiCustomizer

只负责:

告诉 Knife4j 如何携带请求头

真正校验 JWT 的仍然是:

JwtAuthGlobalFilter

即:

文档安全声明
≠
服务端安全实现

12、最终架构

cloud-auth
cloud-user
cloud-product
cloud-order
    ↓
各自生成 /v3/api-docs
    ↓
cloud-gateway 聚合
    ↓
http://localhost:9000/doc.html
    ↓
Authorize 填写 JWT
    ↓
Authorization: Bearer <JWT>
    ↓
Gateway 统一鉴权
    ↓
转发到业务服务

13、本章总结

本章完成了:

单服务 Knife4j
    ↓
OpenAPI 3 注解
    ↓
多服务独立文档
    ↓
Gateway 调试
    ↓
JWT Authorize
    ↓
Gateway 聚合
    ↓
生产关闭边界

最重要的不是记住几个配置,而是理解这些职责:

springdoc 负责生成文档
Knife4j 负责展示与调试
Gateway 负责聚合与鉴权
OpenAPI Security 负责描述认证
JwtAuthGlobalFilter 负责真正校验 JWT

本章也证明了一点:

文档页面看起来正确
不代表真实请求一定正确

最终仍然要通过浏览器 Network 检查:

请求地址
请求头
状态码
实际响应

14、下一章预告

第 14 章解决了微服务接口分散、说明不清、JWT 调试困难和文档入口不统一的问题。

第 15 章将引入:

TraceId
MDC
日志格式
Gateway 生成链路标识
Feign 透传
异步线程上下文传播

目标是让一次请求经过 Gateway、用户服务、商品服务和订单服务时,可以通过同一个 TraceId 串联完整日志链路。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐