Note 12. SpringBoot3 API 文档工程化:SpringDoc 与 OpenAPI 3


第十二章. API 文档工程化:SpringDoc 与 OpenAPI 3 完全指南

摘要: 手写 API 文档不仅枯燥乏味,而且永远追不上代码的变更速度,是团队协作效率的“头号杀手”。本章,我们将彻底告别这个“石器时代”的工作模式,拥抱 Spring Boot 3 时代的唯一官方推荐方案——SpringDoc。我们将从一个全新的 Spring Boot 项目开始,亲手搭建一个用户管理模块,然后一步步集成基于 OpenAPI 3 规范的新一代文档工具。我们不仅能实现 API 文档的自动生成与实时同步,还将学会如何通过注解精细化地描述接口、模型和参数。更重要的是,我们将攻克企业级项目中至关重要的 JWT 认证 配置,最终生成一份美观、可交互、支持在线调试的专业级 API 文档。

本章学习路径

  1. 项目初始化与“问题”复现:我们将从零开始,使用 start.spring.io 创建一个标准的 Spring Boot 3 项目,并编写基础的用户管理接口,直观感受“无文档”带来的协作困境。
  2. 技术演进与选型:我们将深入探讨为什么在 Spring Boot 3 时代,基于 OpenAPI 3 的 SpringDoc 是取代传统 SpringFox (Swagger 2) 的必然选择。
  3. 零配置快速集成:我们将体验 SpringDoc 带来的“开箱即用”的便利,仅需一步引入依赖,即可拥有一个功能完备但略显粗糙的 Swagger UI 界面。
  4. 文档精细化注解:我们将系统性地学习 @Tag, @Operation, @Parameter, @Schema 等核心注解,像“精装修”一样,将原始文档变得信息丰富、清晰易读。
  5. 攻克 JWT 全局认证:我们将通过代码配置,为 Swagger UI 添加全局的 Authorization 输入框,彻底解决需要认证的接口无法在线调试的痛点。
  6. 架构美学与体验升级:我们将学习如何通过 GroupedOpenApi Bean 按业务模块进行接口分组,并引入 Knife4j 增强 UI 界面,提升文档的专业性和易用性。
  7. API 版本管理实战:我们将探讨在项目迭代中如何优雅地管理 V1 和 V2 版本的接口,并让文档清晰地呈现不同版本。
  8. 打通团队协作生态:我们将学习如何利用生成的 OpenAPI 规范,与 Postman、Apifox 等工具联动,实现自动化测试和前端 Mock 开发。

12.1. 一切的开始:从一个无文档的项目说起

在探讨任何解决方案之前,我们必须先切身体会问题所在。让我们遵循企业级项目开发的标准流程,从零开始搭建一个简单的用户管理服务,并观察“无文档”这只“拦路虎”是如何出现的。

12.1.1. 步骤一:创建 Spring Boot 3 项目骨架

我们访问 Spring 官方的脚手架工具 https://start.spring.io

按照下列表进行配置,构建一个现代化的 Spring Boot 项目基础:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.x 或更高稳定版
  • Project Metadata:
    • Group: com.example
    • Artifact: springdoc-demo
    • Name: springdoc-demo
    • Packaging: Jar
    • Java: 17
  • Dependencies:
    • Spring Web: 构建 Web 应用,包括 RESTful 服务。
    • Lombok: 通过注解简化 JavaBean 的开发,如 @Data@Getter 等。

点击 “GENERATE”,下载项目压缩包并用 IntelliJ IDEA 打开。Maven 会自动下载所需的依赖。

12.1.2. 步骤二:编写“毛坯房”式的 API 接口

我们的目标是创建一套基础的用户增删改查接口。为此,我们需要创建对应的 Controller、VO (View Object) 和 DTO (Data Transfer Object)。

1. 创建数据传输对象 (DTO/VO)

首先,我们需要定义用于前端数据交互的对象。在 com.example.springdocdemo 包下创建 dtovo 两个新包。

文件路径: src/main/java/com/example/springdocdemo/vo/UserVO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.springdocdemo.vo;

import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true) // 开启链式调用
public class UserVO {
private Long id;
private String username;
private String statusText;
private String email;
}

文件路径: src/main/java/com/example/springdocdemo/dto/UserCreateDTO.java

1
2
3
4
5
6
7
8
9
10
package com.example.springdocdemo.dto;

import lombok.Data;

@Data
public class UserCreateDTO {
private String username;
private String password;
private String email;
}

2. 创建核心控制器

接下来,在 com.example.springdocdemo 包下创建 controller 包,并编写 UserController

文件路径: src/main/java/com/example/springdocdemo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.springdocdemo.controller;

import com.example.springdocdemo.dto.UserCreateDTO;
import com.example.springdocdemo.vo.UserVO;
import org.springframework.web.bind.annotation.*;

import java.util.Collections;
import java.util.List;

@RestController
@RequestMapping("/users")
public class UserController {

/**
* 根据 ID 查询用户
*/
@GetMapping("/{id}")
public UserVO getUserById(@PathVariable Long id) {
// 模拟业务逻辑
return new UserVO()
.setId(id)
.setUsername("张三")
.setEmail("zhangsan@example.com")
.setStatusText("正常");
}

/**
* 创建新用户
*/
@PostMapping("/create")
public Long createUser(@RequestBody UserCreateDTO dto) {
// 模拟业务逻辑
System.out.println("创建用户:" + dto.getUsername());
return 1001L; // 模拟返回新用户的 ID
}

/**
* 查询用户列表
*/
@GetMapping("/list")
public List<UserVO> listUsers(@RequestParam(required = false) String username) {
// 模拟业务逻辑
System.out.println("查询用户,用户名包含:" + username);
return Collections.singletonList(
new UserVO()
.setId(1001L)
.setUsername("张三")
.setEmail("zhangsan@example.com")
.setStatusText("正常")
);
}
}
  • 代码解读:我们创建了一个标准的 RESTful 控制器,提供了三个基础接口。注意,为了聚焦于文档,我们省略了 Service 和 Dao 层,直接在 Controller 中返回模拟数据。

12.1.3. 启动并暴露“问题”

现在,运行主启动类 SpringdocDemoApplication。项目成功启动后,问题也随之而来:

  1. 对于前端开发者:他拿到了一个黑盒。他不知道 /users 下到底有哪些接口,每个接口的 URL 是什么?是 GET 还是 POST?getUserByIdid 是路径参数还是查询参数?createUser 的请求体 JSON 格式是什么样的,有哪些字段是必填的?
  2. 对于后端开发者:他必须手动编写一份 Word 或 Markdown 文档,将上述所有信息逐条罗列。更糟糕的是,当下一次需求变更,比如给 UserCreateDTO 增加了一个 age 字段,他必须记得到处同步修改:代码要改,文档也要改。一旦遗忘,文档就与代码不一致,造成更大的协作混乱。

这就是手写 API 文档的根本痛点:高昂的维护成本和无法保证的同步性。这正是我们要用工程化手段解决的核心矛盾。


12.2. 时代的更迭:为何必须是 SpringDoc?

在上一节中,我们已经体会到了无文档协作的痛苦。现在,让我们正式引入解决方案。在 Spring Boot 2.x 时代,SpringFox (基于 Swagger 2 规范) 是事实上的标准。但进入 Spring Boot 3 时代,SpringDoc 成为了唯一的选择。这并非简单的工具替换,而是一次技术标准的代际升级。

痛点回顾:许多从 Spring Boot 2.x 升级上来的开发者,习惯性地在 pom.xml 中引入 springfox-swagger2,结果项目在 Spring Boot 3 下无法启动。根本原因在于 Spring Boot 3 将底层的 Java EE 规范从 javax 迁移到了 jakarta 命名空间,而 SpringFox 项目早已在 2020 年就停止了积极维护,无力跟进这一重大变化。

SpringDoc 的核心优势

特性SpringFox (基于 Swagger 2)SpringDoc (基于 OpenAPI 3)优势解读
底层规范Swagger 2.0OpenAPI 3.0OpenAPI 3 是一个更现代、更强大的 API 描述规范。它提供了更丰富的类型系统(如 oneOf, anyOf),更好的组件化和复用能力,是现代 API 设计的事实标准。
Spring Boot 兼容性不兼容 Spring Boot 3完全兼容 Spring Boot 3+SpringDoc 社区非常活跃,紧跟 Spring 生态的步伐,是 Spring 官方在文档中推荐的唯一选择。
集成方式需要 @EnableSwagger2 注解零配置,开箱即用SpringDoc 遵循 Spring Boot 的“约定优于配置”理念,通过 Starter 自动配置,极大简化了集成过程,我们无需添加任何启用注解。
功能支持基础功能支持 WebFlux、Spring Native、全局安全认证 等现代特性SpringDoc 的功能集更加现代化,能更好地支持云原生和响应式编程等新范式,其对安全方案的定义也比 Swagger 2 更加清晰和强大。

结论:在今天,选择 SpringDoc 已经不是一个“选项”,而是 Spring Boot 3+ 项目进行 API 文档工程化的 “唯一正确答案”


12.3. 快速集成:三步拥有你的 API 文档

在了解了 SpringDoc 的必要性后,我们会发现它的集成过程简单到令人愉悦。

12.3.1. 步骤一:引入核心依赖

我们只需要在 pom.xml 中添加一个官方提供的 Starter 即可。这个 Starter 已经帮我们打包好了核心库、UI 界面以及与 Spring Web MVC 集成的所有逻辑。

文件路径: pom.xml

1
2
3
4
5
6
7
8
<!-- 在 <dependencies> 标签内添加 -->
<dependency>
<!-- SpringDoc 官方 WebMVC UI Starter -->
<!-- 它会自动引入 springdoc-openapi-starter-common 等核心依赖 -->
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version> <!-- 写作本文时最新稳定版,建议定期检查并使用 Maven 中央仓库的最新版本 -->
</dependency>

12.3.2. 步骤二:(可选) 基础路径配置

虽然 SpringDoc 开箱即用,但为了规范和后续的定制,我们通常还是会在 application.yml 中明确指定文档的相关路径和基础行为。

文件路径: src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
springdoc:
# API 规范 JSON 的生成路径, 这是 OpenAPI 规范的核心产物
api-docs:
path: /v3/api-docs
# Swagger UI 界面的访问路径
swagger-ui:
path: /swagger-ui.html # 这是默认值,可以自定义为 /doc.html 等
# 对 UI 界面的显示进行一些优化
tags-sorter: alpha # 左侧的 Tag (控制器分组) 按字母顺序排序
operations-sorter: method # 同一个 Tag 下的接口 (Operation) 按 HTTP 方法排序

12.3.3. 步骤三:启动并验证

现在,重新启动你的 Spring Boot 应用 SpringdocDemoApplication。启动日志中不会有任何与 SpringDoc 相关的特殊信息,因为它已经通过自动配置无缝集成了。

启动完成后,打开浏览器,访问我们配置的 UI 路径:http://localhost:8080/swagger-ui.html

你会看到一个专业的 API 文档界面,如下图所示:

image-20251216203350197

成果与不足

  • 成果:太棒了!我们没有写一行文档相关的 Java 代码,SpringDoc 就自动扫描出了我们项目中的所有 Controller 和接口,并生成了一个可交互的界面。
  • 不足:但这份文档还是一份“毛坯房”。信息还很粗糙:分组是默认的控制器类名 user-controller、接口描述是方法名 getUserById、数据模型的字段都是英文变量名,也没有任何中文解释。

接下来的核心工作,就是通过注解,为这份“毛坯房”进行“精装修”。


12.4. 精装修:核心注解深度解析

在上一节中,我们已经拥有了一个自动生成的文档框架。现在,我们将学习如何运用 OpenAPI 3 的核心注解,为这份文档填充血肉,使其变得精准、易读、信息丰富。

12.4.1. 为控制器分组:@Tag

默认情况下,分组名是类名的小写并用 - 连接,如 user-controller,这非常不直观。@Tag 注解用于定义一个 API 资源分组,通常标注在 Controller 类上,赋予其一个业务含义明确的名称和描述。

文件路径: src/main/java/com/example/springdocdemo/controller/UserController.java

我们来改造 UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package com.example.springdocdemo.controller;

import com.example.springdocdemo.dto.UserCreateDTO;
import com.example.springdocdemo.vo.UserVO;
// 导入 @Tag 注解
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;

import java.util.Collections;
import java.util.List;

@RestController
@RequestMapping("/users")
// 使用 @Tag 为整个控制器添加元数据
@Tag(name = "用户管理模块", description = "提供用户的增、删、改、查等一系列功能")
public class UserController {
// ... 接口方法保持不变 ...
}
  • 代码解读
    • name: 定义了分组在 UI 左侧导航栏显示的名称。
    • description: 对整个模块的功能进行概括性描述,会显示在分组名称下方。

效果验证:无需重启,Spring Boot DevTools 会自动热加载。刷新 Swagger UI 页面,你会看到左侧的分组名称已经变成了清晰的“用户管理模块”,并且有了详细的描述。

image-20251216203649977

12.4.2. 描述接口信息:@Operation 和 @Parameter

@Operation 用于描述一个具体的接口方法(即一个操作),而 @Parameter 则用于精细化描述该方法的每一个参数。

我们来详细注解 getUserById 方法:

文件路径: src/main/java/com/example/springdocdemo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 在 UserController 类中
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.enums.ParameterIn;

// ...

@GetMapping("/{id}")
// 使用 @Operation 描述接口的用途和摘要
@Operation(summary = "根据 ID 查询用户详情", description = "传入用户的主键 ID,返回该用户的详细视图信息(已脱敏)")
public UserVO getUserById(
// 使用 @Parameter 详细描述路径参数
@Parameter(
name = "id", // 参数名称, 与 @PathVariable 中的值对应
description = "用户的主键 ID", // 参数的中文描述
required = true, // 标记为必填参数
in = ParameterIn.PATH, // 指定参数的位置 (PATH, QUERY, HEADER, COOKIE)
example = "1001" // 提供一个示例值,方便前端调试
)
@PathVariable Long id
) {
// ... 业务逻辑 ...
}
  • 代码解读
    • @Operationsummary 会成为接口在列表中的简短标题,description 则是展开后的详细说明。
    • @Parameter 的属性非常丰富,descriptionexample 对于前端开发者来说至关重要,required 明确了参数是否必须,in 则清晰地定义了参数的传递方式。

效果验证:刷新页面,展开“用户管理模块”,GET /users/{id} 接口的标题已经更新,点开后,参数部分有了详尽的中文描述和示例值,一目了然。

image-20251216210843772

12.4.3. 描述数据模型:@Schema

这是最重要、也是工作量最大的注解,用于详细描述 DTO 和 VO 的每一个字段。一个描述良好的 @Schema 能极大提升文档的可用性,让前端开发者不再需要猜测每个字段的含义和格式。

我们来“精装修” UserVO

文件路径: src/main/java/com/example/springdocdemo/vo/UserVO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.springdocdemo.vo;

// 导入 @Schema 注解
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
// 使用 @Schema 描述整个数据模型
@Schema(description = "返回给前端的用户视图对象(已脱敏)")
public class UserVO {

@Schema(description = "用户唯一 ID,主键", example = "1001", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;

@Schema(description = "用户昵称", example = "编程高手")
private String username;

@Schema(
description = "用户状态的文本描述",
example = "正常",
allowableValues = {"正常", "禁用", "待激活"} // 对于有固定取值范围的字段,明确列出所有可能的值
)
private String statusText;

@Schema(description = "用户邮箱地址", example = "dev@example.com", requiredMode = Schema.RequiredMode.REQUIRED)
private String email;
}
  • @Schema 常用属性解读
    • description: 字段的中文业务含义,这是最重要的属性。
    • example: 提供一个示例值,让前端能快速理解数据格式。
    • requiredMode: 标记字段是否必填 (REQUIRED, NOT_REQUIRED, AUTO)。对于响应对象,这表示该字段是否一定会有值。
    • allowableValues: 对于枚举或有固定取值范围的字段,明确列出所有可能的值。
    • accessMode: 访问模式。READ_ONLY 表示该字段只在响应中出现(如 ID),WRITE_ONLY 表示只在请求中出现(如密码)。
    • hidden: 如果某个字段是内部使用的,不希望暴露给前端,可以设置为 true 在文档中隐藏。

效果验证:刷新 Swagger UI,在 GET /users/{id} 接口的 Responses 部分,点击 200 状态码,你可以看到 UserVO 的 Schema(模型)定义。现在,每个字段都有了清晰的中文描述、示例值和约束说明。

image-20251216212108275

12.4.4. 描述复杂响应体:@ApiResponse

在企业级开发中,我们通常会对所有响应进行统一封装,例如 Result<T> 类,包含 code, message, data 等字段。如果不加处理,文档只会显示一个模糊的 Result 类型,前端不知道 data 里面到底是什么。@ApiResponse 配合 @Content@Schema 可以精确地描述这种结构。、

重要信息: SpringDoc 非常智能,它会直接读取方法签名 public Result<Long> ...,自动解析泛型 TLong ,所以这个小节的内容只是作为补充,大部分时间我们不会写这个注解

首先,我们定义一个统一响应类:
文件路径: src/main/java/com/example/springdocdemo/vo/Result.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.springdocdemo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "全局统一响应结果")
public class Result <T> {

@Schema(description = "业务状态码", example = "200")
private Integer code;

@Schema(description = "响应消息", example = "操作成功")
private String message;

@Schema(description = "响应数据")
private T data;

public static <T> Result <T> success(T data) {
Result <T> result = new Result <>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
return result;
}
}

然后,改造 createUser 接口,让它返回 Result<Long>
文件路径: src/main/java/com/example/springdocdemo/controller/UserController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 在 UserController 类中
import com.example.springdocdemo.vo.Result;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;

// ...

@PostMapping("/create")
@Operation(summary = "创建新用户")
// 使用 @ApiResponse 详细定义成功的响应
@ApiResponse(
responseCode = "200", // 对应的 HTTP 状态码
description = "创建成功",
// content 定义了响应的内容
content = @Content(
mediaType = "application/json",
// schema 指向我们具体的响应结构
schema = @Schema(implementation = Result.class)
)
)
public Result <Long> createUser(@RequestBody UserCreateDTO dto) {
Long userId = 1001L; // 模拟创建用户
return Result.success(userId);
}
  • 代码解读@ApiResponse 让我们能够针对不同的 responseCode (HTTP 状态码) 定义不同的响应结构。通过 @Content@Schema(implementation = Result.class),我们明确告诉 SpringDoc,当响应码为 200 时,返回的 JSON 结构是以 Result.class 为模板的。SpringDoc 会智能地解析泛型,如果 dataLong,它就会正确展示。

效果验证:刷新 UI,查看 POST /create 接口的响应部分。现在文档清晰地展示出实际的响应结构是 {"code": 200, "msg": "success", "data": 1001},而不是一个模糊的 Result 对象。

至此,我们已经掌握了最核心的注解,将一份“毛坯房”文档装修成了信息完备的“精装房”。但还有一个致命问题没有解决:如果接口需要登录才能访问,这个在线调试功能就形同虚设。下一节,我们将攻克这个企业级项目中最重要的场景。


12.5. 核心场景:配置全局 JWT 认证

在上一节中,我们已经将 API 文档的“颜值”和“内涵”都提升到了一个新高度。但在企业级项目中,绝大多数接口都需要认证后才能访问。如果文档不能支持在线调试,它的价值将大打折扣。SpringDoc 对此提供了非常优雅的解决方案,让我们能够轻松配置 JWT (JSON Web Token) Bearer 认证。

12.5.1. 步骤一:配置全局认证方案

我们需要通过 Java 配置的方式,告诉 SpringDoc 我们项目采用的是哪种安全认证方案。对于 JWT,它属于 OpenAPI 3 规范中的 HTTP Bearer 类型。SpringDoc 支持两种主流的配置方式:编程式注解式


方式一:编程式配置 (推荐,最灵活)

这种方式通过创建一个 OpenAPI 类型的 Spring Bean 来集中管理所有全局配置,具有最高的灵活性和可维护性,是企业级项目中的首选。

我们创建一个专门用于 SpringDoc 配置的 SpringDocConfig 类。

文件路径: src/main/java/com/example/springdocdemo/config/SpringDocConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.springdocdemo.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 org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringDocConfig {

@Bean
public OpenAPI customOpenAPI() {
// --- 1. 定义认证方案 ---
// 我们给这个认证方案起个名字,这个名字会用在后续的关联中
final String securitySchemeName = "bearerAuth";

SecurityScheme securityScheme = new SecurityScheme()
.name(securitySchemeName) // 名称
.type(SecurityScheme.Type.HTTP) // 类型定义为 HTTP
.scheme("bearer") // HTTP 授权方案为 "bearer"
.bearerFormat("JWT"); // bearer 的格式为 "JWT"

// --- 2. 定义 OpenAPI 文档的元信息,并将认证方案注册进去 ---
return new OpenAPI()
// a. 设置文档基本信息,如标题、版本、描述
.info(new Info().title("我的应用 API").version("v1.0").description("这是一个示例项目的 API 文档"))
// b. 将我们定义的认证方案添加到 Components 中,使其成为一个可用的组件
.components(new Components().addSecuritySchemes(securitySchemeName, securityScheme))
// c. 添加全局的安全要求,将认证方案应用到所有接口
// 这意味着每个接口的右上角都会出现一把小锁,提示需要认证
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName));
}
}

方式二:注解式配置 (更简洁)

对于仅需要定义安全方案和基础信息等简单场景,使用注解可以极大地简化代码。

文件路径: src/main/java/com/example/springdocdemo/config/SpringDocConfig.java
(注意:方式一和方式二选择其一即可,不要同时使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.springdocdemo.config;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import org.springframework.context.annotation.Configuration;

@Configuration
// 1. 定义 API 文档基本信息,并声明全局安全要求
@OpenAPIDefinition(
info = @Info(
title = "我的应用 API",
version = "v1.0",
description = "这是一个示例项目的 API 文档"
),
// 将名为 "bearerAuth" 的安全方案应用到所有 API
security = @SecurityRequirement(name = "bearerAuth")
)
// 2. 定义一个可重用的安全方案
@SecurityScheme(
name = "bearerAuth", // 逻辑名称,与上面 SecurityRequirement 中的 name 对应
type = SecuritySchemeType.HTTP, // 类型为 HTTP
scheme = "bearer", // HTTP 方案为 "bearer"
bearerFormat = "JWT" // Bearer 格式为 "JWT"
)
public class SpringDocConfig {
// 配置类可以是空的,所有配置由类级别的注解完成
}
  1. @SecurityScheme 注解:这个注解等同于方式一中创建 SecurityScheme 对象并注册到 Components 的步骤。我们直接定义了认证方案的名称、类型、模式等。
  2. @OpenAPIDefinition 注解:这个注解用于定义文档的全局信息。
    • info 属性:等同于方式一中的 .info() 部分,用于设置标题、版本等。
    • security 属性:等同于方式一中的 .addSecurityItem(),直接声明全局需要名为 bearerAuth 的认证。

12.5.2. 步骤二:重启与验证

无论你选择以上哪种配置方式,最终效果都是一样的。重启应用,再次访问 http://localhost:8080/swagger-ui.html

你会发现界面上出现了两个显著的变化:

  1. 右上角的 “Authorize” 按钮:界面右上角多了一个绿色的 “Authorize” 按钮。
  2. 接口上的小锁图标:每个接口的右侧都出现了一个关闭状态的小锁图标。

image-20251216215935623

点击 “Authorize” 按钮,会弹出一个输入框。你可以在这里填入你的 JWT Token。例如,填入 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

点击 “Authorize” 并关闭弹窗后,按钮会变为锁定状态。此时,该页面上的所有接口调试请求,都会自动在请求头中带上 Authorization: Bearer eyJhbGci...。你可以打开浏览器的开发者工具(F12),在 “Try it out” 执行一个接口调用,查看 Network 面板中的请求头,验证该 Header 是否已成功添加。

12.5.3. 步骤三:针对单个接口禁用/启用安全认证

全局配置虽然方便,但总有例外。例如,登录接口和注册接口本身就是用来获取 Token 的,它们不能要求认证。我们可以在 @Operation 注解中覆盖全局配置。

假设我们有一个登录接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在 UserController 中
import io.swagger.v3.oas.annotations.Operation;
// security 属性需要 io.swagger.v3.oas.annotations.security.SecurityRequirement

// ...

@Operation(
summary = "用户登录",
description = "此接口用于获取认证 Token,因此本身不需要认证",
// security 置为空数组,表示此接口无需应用任何全局安全方案
security = {}
)
@PostMapping("/login")
public Result <String> login(@RequestBody LoginDTO dto) {
// 模拟登录成功,返回一个假的 token
return Result.success("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...");
}
  • 代码解读@Operation 中的 security 属性接收一个 SecurityRequirement 数组。通过将其设置为空数组 {},我们明确地告诉 SpringDoc:“忽略全局配置,这个接口是公开的,不需要任何认证。”

效果验证:刷新 UI,你会发现 /login 接口旁边的小锁图标消失了,表明它是一个公开接口,可以直接在线调试。

通过这套组合拳,我们完美解决了企业级项目中 API 文档的认证调试问题,让文档的实用性大大增强。


12.6. 架构美学:模块化分组与元数据定制

在上一节中,我们攻克了最棘手的安全认证问题。但在真实的企业级应用中,随着业务迭代,接口数量会迅速突破成百上千个。如果将所有接口一股脑地堆叠在一个 Tag 下,不仅加载缓慢,更会让前端同事在寻找接口时感到绝望。本节,我们将学习如何利用 GroupedOpenApi 进行“微服务式”的逻辑隔离,并定制更专业的文档元数据。

12.6.1. 为什么要进行接口分组?

在单体架构(Monolithic)向微服务演进的过程中,或者在领域驱动设计(DDD)的实践中,我们通常会按“业务域”拆分代码。API 文档也应遵循这一原则。

通过分组,我们可以实现:

  1. 视图隔离:管理后台的开发人员只需关注 /admin/** 相关的接口,移动端 App 的开发人员只需关注 /app/** 相关的接口,互不干扰。
  2. 版本隔离:将 V1 老接口与 V2 新接口完全分开,避免混淆(这将在下一节详细讲解)。
  3. 加载优化:Swagger UI 无需一次性加载和解析所有接口的 JSON 数据,而是按需加载选中的分组,显著提升大型项目的页面渲染速度。
  4. 权责清晰:每个分组对应一个业务模块,接口的归属更加清晰。

12.6.2. 实战:基于包路径与 URL 的双重分组策略

我们将通过配置多个 GroupedOpenApi 类型的 Bean 来实现分组。SpringDoc 允许我们通过扫描“包路径”或匹配“URL 规则”来圈定每个分组包含的接口范围。

为了演示,我们先调整一下项目结构,模拟一个既有对内管理接口,又有对外 App 接口的场景。

1. 创建新的 Controller 包和类

1
2
3
4
5
src/main/java/com/example/springdocdemo/controller/
├── admin/
│ └── AdminUserController.java --> 管理后台的用户接口
└── app/
└── AppUserController.java --> 移动端的用户接口
  • AdminUserController.java: 将其 @RequestMapping 设置为 /admin/users
  • AppUserController.java: 将其 @RequestMapping 设置为 /app/users
    (可以简单地将原 UserController 的代码复制到这两个新类中,并修改 @RequestMapping@Tag 注解)。

2. 升级 SpringDocConfig 配置

现在,我们升级 SpringDocConfig.java,用 GroupedOpenApi 来定义分组。

文件路径: src/main/java/com/example/springdocdemo/config/SpringDocConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.springdocdemo.config;

// 导入 GroupedOpenApi
import org.springdoc.core.models.GroupedOpenApi;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringDocConfig {

/**
* 全局元数据配置
* 无论哪个分组,都共享这份基础信息(如服务条款、作者联系方式)
*/
@Bean
public OpenAPI customOpenAPI() {
// 全局认证配置 (保持不变)
}

/**
* 分组一:移动端 API
* 策略:只扫描 controller.app 包下的接口,并且 URL 必须以 /app/ 开头
*/
@Bean
public GroupedOpenApi appApi() {
return GroupedOpenApi.builder()
.group("1. 移动端接口 (App)") // 分组名称,会显示在 UI 的下拉列表中
.pathsToMatch("/app/**") // 1. 过滤规则:只包含匹配此路径的接口
.packagesToScan("com.example.springdocdemo.controller.app") // 2. 过滤规则:只扫描此包下的 Controller
.build();
}

/**
* 分组二:后台管理 API
* 策略:只扫描 controller.admin 包下的接口,并且 URL 必须以 /admin/ 开头
*/
@Bean
public GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("2. 管理端接口 (Admin)")
.pathsToMatch("/admin/**")
.packagesToScan("com.example.springdocdemo.controller.admin")
.build();
}
}
  • 代码解析
    • customOpenAPI(): 这个 Bean 现在专门负责定义 全局元数据,如整个项目的标题、描述、联系人信息以及全局的安全认证方案。这些信息对于所有分组都是共享的。
    • appApi()adminApi(): 我们定义了两个 GroupedOpenApi 类型的 Bean。SpringDoc 会自动扫描到它们,并创建对应的分组。
    • .group(): 定义了分组在 Swagger UI 顶部下拉框中显示的名字。建议加上数字前缀(如 "1. "),以便控制分组的显示顺序。
    • .pathsToMatch(): 通过 Ant 风格的路径表达式来过滤接口的 URL。"/app/**" 表示匹配所有以 /app/ 开头的路径。
    • .packagesToScan(): 指定要扫描的包。这是一个双重保险,建议同时配置路径匹配和包扫描,这样可以确保文档的纯净度,避免因为 URL 规则写得不严谨而误将测试接口或内部接口暴露出去。

效果验证:重启应用并刷新 Swagger UI。你会看到页面顶部出现了一个下拉选择框,里面包含了我们定义的“1. 移动端接口 (App)”和“2. 管理端接口 (Admin)”两个选项。选择不同的分组,页面会只显示该分组下的接口,实现了完美的逻辑隔离。

image-20251217084114117


这是一个根据你的要求修改后的版本。我移除了所有关于 Scalar 的内容,并大幅扩充了 Knife4j 的深度解析、进阶配置、鉴权处理以及生产环境安全建议,确保内容充实、字数不减反增,且更具实战深度。


12.7. 体验升级:引入增强 UI —— Knife4j 深度集成指南

在上一节中,我们通过分组优化了文档的逻辑结构,使其更加清晰。然而,这仅仅是“骨架”层面的优化。对于 API 文档而言,“皮囊”——也就是用户界面(UI),同样至关重要。一个优秀的 UI 能显著提升开发者的使用体验(DX, Developer Experience),降低沟通成本,甚至成为项目专业度的体现。

不幸的是,SpringDoc 默认集成的原生 Swagger UI,虽然功能完备,但在 2025 年的今天,其界面风格已略显陈旧。它存在一些公认的体验痛点:例如信息密度较低、对复杂 JSON 结构的展示和折叠不够智能、缺乏便捷的离线文档导出功能,以及整体交互流程不够符合国内开发者的操作直觉。

为了解决这些问题,本节我们将聚焦于国内 Spring Boot 社区中口碑极佳、功能最为全面的增强解决方案——Knife4j。我们将不仅介绍如何集成它,更会深入挖掘其隐藏的高级功能,将其打造为提升团队效率的利器。

12.7.1. 为什么选择 Knife4j?

Knife4j(原名 swagger-bootstrap-ui)是国内开发者 xiaoymin 及其社区主导的开源项目。它完全兼容 SpringDoc 生成的 OpenAPI 3 规范,其核心价值在于提供了一个功能极其丰富、高度可定制化且深度符合国内开发习惯的 UI 界面。

您可以把它理解为对原生 Swagger UI 的一次全方位、深度定制的“魔改”。它带来的不仅仅是视觉上的美化,更是生产力上的质变。

Knife4j 的核心优势深度解析

  • 符合国人习惯的交互布局:原生 Swagger UI 采用单页长滚动模式,当接口数量达到上百个时,查找和定位非常困难。Knife4j 采用了经典的 左右分栏布局(类似 IDE 或许多管理后台)。左侧是清晰的树状接口菜单,支持多级分组、快速搜索和过滤;右侧则是独立的接口详情页。这种 Tab 标签页式的交互设计,允许开发者同时打开多个接口文档进行对比调试,极大地提高了信息检索和操作效率。

  • 企业级的离线文档能力:这是一项“杀手级”功能。在实际的企业开发流程中,我们经常面临需要向非技术人员(如产品经理)、外部合作伙伴或在涉密内网环境交付文档的场景。Knife4j 内置了强大的导出引擎,可以 一键将所有 API 文档导出为多种主流格式

    • Markdown: 适合导入 Notion、Obsidian 或存入 Git 仓库。
    • HTML: 单文件网页,方便直接发送给对方浏览器查看。
    • Word: 最适合传统的企业级文档归档和离线评审。
    • PDF: 适合正式的对外发布。
  • 无与伦比的调试增强体验
    Knife4j 在 API 调试方面下足了功夫,解决了原生 UI 的诸多痛点:

    • 请求参数缓存:您是否遇到过刷新页面后,辛辛苦苦填写的几十个测试参数全部清空的崩溃场景?Knife4j 支持参数自动缓存,刷新页面或切换标签后,参数依然保留。
    • 响应内容美化:对响应的 JSON 数据提供了自动格式化、语法高亮、复制和层级折叠功能,即使面对几千行的复杂 JSON 也能轻松阅读。
    • 全局参数管理:支持设置全局的 Header(如 Authorization Token)或 Query 参数。配置一次,后续所有接口请求自动携带,彻底告别每次调试都要手动粘贴 Token 的繁琐。
  • 个性化与品牌定制:支持对 UI 的各种文案进行自定义,例如文档标题、页脚信息等,方便打造带有企业品牌烙印的 API 文档。同时还提供了许多可开关的增强功能,如动态请求参数、个性化设置面板等。

12.7.2. 快速集成 Knife4j

集成 Knife4j 的过程遵循 Spring Boot “约定优于配置”的原则,非常简单。但由于 Spring Boot 3 的规范变更,我们需要格外注意版本的选择。

冲突提示:一个项目中,我们通常只引入一个 UI 依赖。如果您决定使用 Knife4j,请确保项目中 没有 springdoc-openapi-starter-webmvc-ui 依赖。虽然它们可以共存,但会加载多余的资源,且容易造成入口混淆。建议移除原生的 UI 包,只保留 Core 包和 Knife4j。

第一步:引入 Maven 依赖

版本兼容性警告:Spring Boot 3 全面迁移到了 Jakarta EE 9 规范,其包名从 javax.* 变为了 jakarta.*。因此,我们必须使用 Knife4j 专门为其适配的 jakarta 版本。如果您错误地引入了旧版的 knife4j-spring-boot-starter,项目将因包名冲突而无法启动。

文件路径: pom.xml

<dependencies> 标签内,添加 Knife4j 的 Jakarta Starter:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 移除原生的 springdoc-ui 依赖 (如果存在) -->
<!-- <dependency> -->
<!-- <groupId>org.springdoc</groupId> -->
<!-- <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> -->
<!-- </dependency> -->

<!-- 引入 Knife4j 增强 UI (专为 Spring Boot 3+ 适配的 Jakarta 版本) -->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.5.0</version> <!-- 建议定期检查 Maven Central 获取最新版本 -->
</dependency>

第二步:基础配置

Knife4j 开箱即用,但通过配置能让它更好地为我们服务。

文件路径: src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# SpringDoc 的基础配置保持不变,Knife4j 会自动读取它们来生成文档结构
springdoc:
api-docs:
enabled: true
group-configs:
# ... (之前的分组配置)

# Knife4j 的专属配置
knife4j:
# 核心开关,设置为 true 以开启 Knife4j 的所有增强功能
enable: true
# 个性化设置
setting:
# 将 UI 界面语言设置为中文,默认为中文
language: zh_cn
# 开启请求参数缓存,刷新页面后上一次的调试参数不会丢失 (开发神器)
enable-request-cache: true
# 开启在头部菜单栏显示所有 Controller 的 Class 名称,方便后端快速定位代码
enable-controller-class: true
# 在文档管理菜单中,开启 OpenAPI 原始规范的导出功能
enable-openapi-docs: true
# 开启 Footer 页脚显示
enable-footer: false
# 是否开启界面中对自定义 Host 的配置能力
enable-host: false

12.7.3. 进阶实战:解决鉴权与生产安全问题

仅仅把文档展示出来是不够的,在真实项目中,我们还需要解决两个关键问题:接口鉴权调试生产环境安全

12.7.3.1. 配置全局鉴权参数 (Global Parameters)

大多数现代 API 都受到 JWT 或 OAuth2 的保护,需要在 Header 中携带 Authorization: Bearer <token>。如果每个接口都要手动填一遍,效率极低。Knife4j 提供了全局参数功能来解决这个问题。

方法一:通过 UI 界面动态配置 (推荐)

启动项目后,访问 Knife4j 文档界面:

  1. 点击左侧菜单栏的 “文档管理” -> “全局参数设置”
  2. 点击 “添加参数”
    • 参数名称: token (或者后端要求的 header key,如 Authorization)
    • 参数值: Bearer eyJhbGciOiJIUzI1Ni... (你的测试 Token)
    • 参数类型: header
  3. 保存。

现在,您发送的每一个调试请求,Knife4j 都会自动在 Header 中带上这个参数。

方法二:通过代码预置 (适合固定 Key 的场景)

您也可以在 SpringDoc 的配置类中,通过 GlobalOpenApiCustomizer 预设这些 Header,这样所有开发者打开文档时都能看到统一的鉴权输入框(这部分在前面的 OpenApi 配置章节已有提及,Knife4j 会完美渲染这些配置)。

12.7.3.2. 生产环境屏蔽文档

API 文档是后端系统的“地图”,如果暴露在生产环境(Production),极易被黑客利用进行攻击。因此,在生产环境中彻底关闭 Knife4j 是必须遵守的安全规范

我们可以利用 Spring Boot 的 profiles 机制来实现这一点。

文件路径: src/main/resources/application-prod.yml (生产环境配置)

1
2
3
4
5
6
7
8
9
10
springdoc:
api-docs:
# 关闭 OpenAPI JSON 数据的生成接口 (/v3/api-docs)
enabled: false

knife4j:
# 关闭 Knife4j 的 UI 增强功能
enable: false
# 彻底通过过滤器屏蔽资源访问 (双重保险)
production: true

knife4j.production 设置为 true 时,即使用户猜到了访问地址,Knife4j 也会直接拦截请求并返回 403 禁止访问,确保系统安全。

12.7.4. 验证与深度体验导览

完成以上配置后,重启您的 Spring Boot 项目。请注意,Knife4j 的默认访问入口与原生 Swagger 不同:

  • 旧地址http://localhost:8080/swagger-ui.html (如果您移除了原生依赖,此地址将失效)
  • 新地址http://localhost:8080/doc.html

在浏览器中访问新地址,一个焕然一新的界面将呈现在您眼前。

Knife4j UI 界面示意图

沉浸式体验指南:

  1. 主页概览:首页展示了我们在 OpenAPI Bean 中配置的 Info 信息(标题、描述、版本)。右侧通常会有项目简介。请注意顶部的搜索框,尝试输入一个接口路径的部分关键词,观察其毫秒级的检索速度。

  2. 多标签页调试:从左侧菜单树中打开两个不同的接口(例如“用户登录”和“查询用户信息”)。注意看顶部,它们以 Tab 标签页的形式并存。您可以在“登录”接口获取 Token,然后瞬间切换到“查询”接口粘贴 Token,无需像原生 UI 那样来回滚动寻找。

  3. 调试面板细节:点击“调试”选项卡。

    • 请求参数:尝试输入一些参数。如果是文件上传接口,Knife4j 会自动渲染文件选择器。
    • 发送请求:点击发送。
    • 响应区域:查看响应数据。尝试点击响应 JSON 左侧的小箭头,折叠部分层级。点击“Raw”查看原始报文。点击“Headers”查看响应头。
  4. 导出离线文档:点击左侧菜单的 “文档管理” -> “离线文档”。选择 “Markdown”。系统会立即下载一个 .md 文件。用您喜欢的 Markdown 编辑器打开它,您会发现格式排版已经非常完美,甚至可以直接复制粘贴到技术博客或项目 Wiki 中。


12.8. 进阶架构:API 版本管理实战

在掌握了 UI 增强后,我们面临一个更深层次的架构挑战:接口版本管理。当业务需求发生重大变更,需要修改现有接口的参数或响应结构,且这些变更无法兼容旧版本的客户端(如 APP 或小程序)时,我们必须引入版本控制。直接修改原接口会导致正在使用旧版 App 的用户出现程序崩溃。本节我们将探讨如何利用 SpringDoc 优雅地管理并展示并存的 /v1//v2/ 接口。

12.8.1. 为什么需要版本控制?

  • 应对破坏性变更 (Breaking Changes):例如,你将一个响应字段 username (String) 修改为了 userInfo (Object),或者删除了某个请求参数。对于已经发布、安装在用户手机上的旧版 APP,它们的代码逻辑是写死的,无法适应这种变化,直接调用新接口就会导致解析错误甚至崩溃。
  • 实现平滑过渡:通过在一段时间内同时保留旧接口(如 /api/v1/user)和新接口(如 /api/v2/user),我们可以让新发布的应用使用新接口,而老用户则继续使用旧接口,直到所有用户都升级到新版本,我们再将旧接口下线。这是一种保护用户体验和保证业务连续性的关键策略。

12.8.2. 架构方案:基于 URL Path 的版本化

业界常见的 API 版本控制方式有多种,例如通过请求头(Accept: application/vnd.company.v1+json)、通过查询参数(/users?version=v2),以及通过 URL 路径。在文档展示和语义明确性上,URL 路径版本化(如 /api/v1/...)最为直观和常用。

目录结构设计

为了让代码结构与 URL 结构保持一致,我们应该在物理上也对不同版本的 Controller 进行隔离。

1
2
3
4
5
src/main/java/com/example/springdocdemo/controller/
├── v1/
│ └── UserControllerV1.java --> @RequestMapping("/api/v1/users")
└── v2/
└── UserControllerV2.java --> @RequestMapping("/api/v2/users")
  • UserControllerV1: 包含旧的业务逻辑。
  • UserControllerV2: 包含新的业务逻辑,例如,它的 getUserById 接口可能返回一个更丰富的 UserDetailVO 对象。

12.8.3. 配置 SpringDoc 分组映射

我们的目标是让 Knife4j/Swagger UI 的下拉框中清晰地显示“V1.0 稳定版”和“V2.0 进阶版”这样的分组,让不同版本的接口使用者能快速找到自己需要的文档。

文件路径: src/main/java/com/example/springdocdemo/config/SpringDocConfig.java

在配置类中,我们废弃之前按 admin/app 的分组方式,改为按版本分组。追加以下 Bean 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 在 SpringDocConfig 类中添加
@Configuration
public class SpringDocConfig {

// customOpenAPI() Bean 保持不变 ...

/**
* 分组策略:V1 版本接口
* 匹配 /api/v1/** 路径,并扫描 controller.v1 包
*/
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("V1.0 稳定版")
.pathsToMatch("/api/v1/**") // 精确匹配 URL 路径
.packagesToScan("com.example.springdocdemo.controller.v1") // 精确扫描物理包
.build();
}

/**
* 分组策略:V2 版本接口
* 匹配 /api/v2/** 路径,并扫描 controller.v2 包
*/
@Bean
public GroupedOpenApi v2Api() {
return GroupedOpenApi.builder()
.group("V2.0 进阶版")
.pathsToMatch("/api/v2/**")
.packagesToScan("com.example.springdocdemo.controller.v2")
.build();
}
}
  • 代码解读:这里的配置逻辑与 12.6 节完全相同,只是我们将分组的依据从业务域(admin/app)变成了版本号(v1/v2)。通过 .pathsToMatch().packagesToScan() 的双重限定,我们确保了每个分组都只包含对应版本的接口。

12.8.4. 最佳实践:DTO/VO 的版本化

一个非常重要的原则:不仅 Controller 要分版本,与之配套的输入(DTO)和输出(VO)对象通常也需要分版本。

错误的做法:V1 和 V2 的 Controller 方法都使用同一个 UserVO.java。如果 V2 版本需要给 UserVO 增加一个新字段 age,这会直接影响到 V1 接口的响应,这是一个潜在的破坏性变更。

正确的做法:创建独立的、版本化的 DTO/VO 类。

1
2
3
4
5
6
7
8
9
10
11
src/main/java/com/example/springdocdemo/
├── dto/
│ ├── v1/
│ │ └── UserCreateDTOV1.java
│ └── v2/
│ └── UserCreateDTOV2.java
└── vo/
├── v1/
│ └── UserVOV1.java
└── v2/
└── UserVOV2.java

虽然这会产生一些看似重复的代码,但它保证了 V1 接口契约的 绝对稳定性。V1 接口永远不会因为 V2 的需求变更而意外地改变其输入输出结构。这种物理隔离是保证 API 向后兼容性的最可靠手段。