Java微服务(二):2.0 SpringMVC - 前后端交互核心内核

1. [Web 核心] Spring MVC 与 RESTful API

摘要: 在掌握了 Spring Boot 的基础之后,本章我们将揭开 spring-boot-starter-web 的神秘面纱,深入其核心——Spring MVC 框架。我们将学会在 Spring Boot 的“羽翼”下,轻松创建出第一个 RESTful API 接口,为后续的实战项目打下坚实的基础。

1.1. 承上启下:Spring Boot 与 Spring MVC 的关系

在正式开始学习 Spring MVC 的具体功能前,我们首先需要精确理解其在 Spring Boot 项目中的角色与关系。请您回顾在 第一、二章中我们创建的 Spring Boot 项目,其 pom.xml 文件中包含一项关键依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

该依赖是 Spring Boot 提供的“场景启动器” (Starter)。它的核心作用主要有两点:依赖传递触发自动配置

  1. 依赖传递: spring-boot-starter-web 会将构建 Web 应用所需的一整套相关库(JAR 包)自动引入到我们的项目中。这其中包括了 spring-webmvcspring-web 等 Spring MVC 框架的核心,同时也内嵌了 Tomcat 服务器作为默认的 Servlet 容器。

  2. 触发自动配置: 更重要的是,当 Spring Boot 检测到 spring-boot-starter-web 存在于类路径中时,其强大的 自动配置 机制便会生效。它会在后台为我们自动配置好所有在传统 Spring MVC 开发中需要手动处理的核心组件,例如:

  • DispatcherServlet (前端控制器)
    * HandlerMapping (处理器映射器)
    * HandlerAdapter (处理器适配器)
    * 多种 HttpMessageConverter (用于处理 JSON、表单等数据的消息转换器)

因此,我们可以这样精准地定义二者的关系:

  • Spring MVC:是一个功能强大且成熟的 Web 框架,它提供了构建 Web 应用所需的全套组件和清晰的架构模式。
  • Spring Boot:是一个 集成与简化框架。它并非 Spring MVC 的替代品,而是通过自动配置技术,免去了我们手动配置 Spring MVC 的所有繁琐步骤,使我们能够直接专注于编写业务代码。

总结:我们接下来的学习,本质上是在 Spring Boot 提供的“全自动化”环境中,深入使用 Spring MVC 这个核心 Web 框架的各项功能。理解这一点,将有助于我们更好地把握后续所有知识点。


1.2. 第一个 API 接口

在第二章中,我们为了快速体验 Spring Boot 的 Web 功能,已经创建过一个 HelloController。当时我们只关注了运行结果,并未深究其工作原理。

现在,让我们以 Spring MVC 的专业视角,回顾并深度解析 这段我们已经很熟悉的代码。

文件路径: src/main/java/com/example/springbootdemo/controller/HelloController.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
package com.example.demo.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* @RestController
* 这是一个组合注解,它本身包含了 @Controller@ResponseBody 两个注解。
* 它的核心作用是:
* 1. @Controller:向 Spring IoC 容器声明这是一个控制器(Controller)组件,
* 使其能够被组件扫描(ComponentScan)机制发现并注册为 Bean。
* 2. @ResponseBody:告知 Spring MVC 框架,本类中的所有方法返回的都不是视图名,
* 而是需要直接写入 HTTP 响应体(Response Body)的数据。
* 这是构建 RESTful API 的关键,意味着我们将直接返回 JSON、XML 或纯文本等数据。
*/
@RestController
public class HelloController {

/**
* @GetMapping("/hello")
* 这是一个请求映射(Request Mapping)注解,用于处理 HTTP GET 请求。
* 它是 @RequestMapping(method = RequestMethod.GET) 的一个更简洁的缩写形式。
* 它的作用是将所有访问路径为 "/hello" 的 GET 请求,都路由到这个 sayHello() 方法上来进行处理。
*
* @return String
* 由于类上标注了 @RestController,此方法返回的 String "Hello..." 将被
* Spring MVC 视为响应体内容,通过 StringHttpMessageConverter 直接写入响应,
* 最终在浏览器或 API 测试工具中呈现。
*/
@GetMapping("/hello")
public String sayHello() {
return "Hello, 这是我的第一个 Spring MVC API!";
}
}

如代码注释所示,@RestController@GetMapping 这两个注解的组合,便构成了 Spring MVC 中最基础的 API 接口。前者负责声明类的身份和数据响应模式,后者负责将具体的 URL 路径映射到处理方法上。

正是因为 Spring Boot 的自动配置为我们处理了所有底层细节,我们才能如此简洁地实现这一功能。在下一节,我们将简要地探讨一下这个请求在其内部的流转过程。


1.3. 自动配置的背后:DispatcherServlet 流程简述

在上一节,我们看到仅用两个注解就成功创建了一个 API 接口。现在,我们自然会产生一个疑问:当我们启动应用并在浏览器中访问 /hello 时,这个请求是如何精确地找到并执行我们编写的 sayHello() 方法的?

答案的核心,在于一个由 Spring Boot 自动为我们配置和注册的组件——DispatcherServlet

您可以将它理解为 Spring MVC 框架在 Web 应用中的 总调度中心前端控制器。所有进入我们应用的 HTTP 请求,都会首先被它拦截。它接收到请求后,会像一位经验丰富的交通警察,遵循一套固定的、高效的流程来处理和分发请求。

我们可以将这个流程简化为以下几个关键步骤:

SpringMVC 流程

接收请求

浏览器或 API 工具发出 GET /hello 请求,被 Spring Boot 内嵌的 Tomcat 服务器接收。

转交DispatcherServlet

Tomcat 将请求转交给 Spring MVC 的总指挥 DispatcherServlet

查找处理器

DispatcherServlet 询问:“谁能处理 /hello 这个请求?”。它通过查询 HandlerMapping (处理器映射器),找到了 HelloController.sayHello() 方法这个最终的处理器。

调用处理方法

DispatcherServlet 通过 HandlerAdapter (处理器适配器),去适配并调用我们编写的 sayHello() 方法。

返回数据

sayHello() 方法执行,并返回字符串 "Hello, 这是我的第一个 Spring MVC API!"

返回响应

由于 HelloController 类上有 @RestController 注解,DispatcherServlet 知道这是一个 API 请求。它会选择一个合适的 HttpMessageConverter (消息转换器),将返回的字符串直接写入 HTTP 响应体。

完成请求

最终,完整的 HTTP 响应被送回给客户端。

这个看起来复杂的流程,在 Spring Boot 的帮助下,我们一行配置代码都不需要写。DispatcherServlet 及其配套的 HandlerMappingHandlerAdapter 等所有组件,都由 spring-boot-starter-web 自动配置完成。我们只需要专注编写 @RestController 里的业务逻辑即可。


1.4. 深入请求映射:@RequestMapping 全方位解析

我们已经掌握了 @GetMapping 的基础用法,但 Spring MVC 的请求映射远不止于此。@RequestMapping 及其衍生注解提供了一套强大而灵活的工具集,能够让我们应对各种复杂的 URL 映射场景。接下来,我们将逐一探索这些高级用法。

1.4.1. 组合运用:类级别与方法级别映射

为什么需要它?
当项目逐渐变大,一个模块(如用户管理)可能会包含多个相关的 API 接口。如果所有 URL 映射都直接写在方法上,会显得杂乱且容易产生路径冲突。通过在类上添加 @RequestMapping,我们可以为该控制器的所有接口定义一个统一的“命名空间”或“父路径”。

如何使用?
在控制器类上添加 @RequestMapping("/some-prefix"),那么该类中所有方法的映射路径都会自动带上这个前缀。

代码示例
我们创建一个 UserController,并将其所有接口都归属在 /users 路径下。

文件路径: src/main/java/com/example/demo/controller/UserController.java (新增文件)

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

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/users") // 在类上定义父路径 /users
public class UserController {

/**
* 此方法的完整访问路径是类上的 @RequestMapping + 方法上的 @GetMapping
* 即: /users + /all = /users/all
*/
@GetMapping("/all")
public String getAllUsers() {
return "返回所有用户列表";
}
}

运行验证

启动 Spring Boot 应用,使用 cURL 或 Postman 访问 http://localhost:8080/users/all

1
curl http://localhost:8080/users/all

1.4.2. 动态路径:@PathVariable 与路径占位符

为什么需要它?
在 RESTful API 设计中,我们经常需要通过 URL 来指定要操作的资源,例如,通过用户 ID 来获取特定用户的信息。这时,URL 中就会包含动态变化的部分。

如何使用?
@RequestMapping 的路径中使用 {} 来定义一个路径变量(占位符),然后在方法参数中使用 @PathVariable 注解来获取这个变量的值。

代码示例
我们在 UserController 中添加一个根据 ID 查询用户的方法。

文件路径: src/main/java/com/example/demo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... 省略 import 和类注解 ...
@RestController
@RequestMapping("/users")
public class UserController {

// ... 已有的 getAllUsers() 方法 ...

/**
* {id} 是一个路径占位符。
* @PathVariable("id") 注解会将 URL 中占位符 {id} 的实际值,
* 绑定到方法的 Long id 参数上。
* 例如,当请求 /users/101 时,id 参数的值就是 101。
*/
@GetMapping("/{id}")
public String getUserById(@PathVariable("id") Long id) {
return "正在查询 ID 为: " + id + " 的用户";
}
}

如果方法参数名与路径占位符的名称完全相同,@PathVariable("id") 部分可以省略,直接写 @PathVariable Long id 即可。

运行验证

访问 http://localhost:8080/users/101

1
curl http://localhost:8080/users/101

1.4.3. 模糊匹配:Ant 风格路径

为什么需要它?
有时我们需要一个方法能处理一类相似但不完全相同的 URL,而不是为每个 URL 都写一个方法。Ant 风格的通配符就提供了这种模糊匹配的能力。

如何使用?
@RequestMapping 支持三种 Ant 风格的通配符:

  • ?:匹配任意 单个 字符。
  • *:匹配任意数量(0 或多个)的字符,但不包括 /
  • **:匹配任意数量(0 或多个)的字符,可以包括 /

代码示例

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
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AntController {

// 匹配 /ant/testA, /ant/testB, 但不匹配 /ant/testAB
@GetMapping("/ant/test?")
public String testAnt1() {
return "Ant-style match: ?";
}

// 匹配 /ant/test, /ant/testABC, 但不匹配 /ant/test/abc
@GetMapping("/ant/test*")
public String testAnt2() {
return "Ant-style match: *";
}

// 匹配 /ant/any/path/can/be/here
// 这个在 Springboot3 是会报错的!
@GetMapping("/ant/**/any")
public String testAnt3() {
return "Ant-style match: **";
}
}

重要: 在 Spring Framework 6.x (Spring Boot 3.x) 及更高版本中,出于安全性考虑,不再允许 ** 通配符出现在路径的中间部分。它通常只能用在末尾,例如 /ant/**

运行验证

访问 http://localhost:8080/ant/testABC

1
curl http://localhost:8080/ant/testABC

1.4.4. 精准匹配:params 与 headers 属性

为什么需要它?
有时,仅通过 URL 路径还不足以区分请求。我们可能需要根据请求中是否包含 特定的参数特定的请求头,来决定由哪个方法处理。这在 API 版本控制或根据特定条件路由时非常有用。

如何使用?
@RequestMapping 及其衍生注解中,使用 paramsheaders 属性来添加匹配条件。

代码示例

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
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PreciseController {

/**
* 只有当请求 URL 中包含名为 "version" 的参数时,此方法才会被调用。
* 例如: /precise?version = 1
*/
@GetMapping(value = "/precise", params = "version")
public String testParams() {
return "Match with params 'version'";
}

/**
* 只有当请求 URL 中包含名为 "version" 且其值等于 "2" 的参数时,才会被调用。
* 例如: /precise?version = 2
*/
@GetMapping(value = "/precise", params = "version=2")
public String testParamsWithValue() {
return "Match with params 'version=2'";
}

/**
* 只有当请求头中包含名为 "X-API-VERSION" 的头信息时,此方法才会被调用。
*/
@GetMapping(value = "/precise", headers = "X-API-VERSION")
public String testHeaders() {
return "Match with header 'X-API-VERSION'";
}
}

运行验证

使用 cURL 的 -H 选项来添加请求头,验证 headers 属性。

1
curl -H "X-API-VERSION: v1.0" http://localhost:8080/precise

如果请求不满足 paramsheaders 的匹配条件,客户端通常会收到一个 404 Not Found400 Bad Request 的错误,因为 Spring MVC 认为没有找到合适的处理器方法来处理该请求。


1.5. 优雅地获取请求参数

我们已经学会了如何将不同的 URL 映射到控制器方法上。接下来的关键一步,是学习如何从这些请求中 获取客户端传递过来的数据。无论是 URL 中的查询参数,还是请求体中的 JSON 数据,Spring MVC 都提供了极为便捷的方式来获取它们。

1.5.1. 获取 URL 参数:@RequestParam

为什么需要它?
URL 查询参数是在 ? 之后,以 key=value 形式拼接的参数,是 GET 请求传递少量数据的最常见方式。例如,在一个搜索功能中,URL 可能是 /users/search?keyword=admin。我们需要一种方式来获取 keyword 的值。

如何使用?
@RequestParam 注解可以精确地将 URL 查询参数绑定到控制器方法的参数上。

代码示例
我们在 UserController 中添加一个搜索方法来演示。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

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

// ... 此前已有的方法 ...

/**
* @RequestParam 用于获取 URL 查询参数 (Query Parameter)。
* * @param keyword 绑定 URL 中名为 "keyword" 的参数。如果 URL 是 /users/search?keyword = admin, keyword 的值就是 "admin"。
* @param page 绑定名为 "page" 的参数。它有两个额外属性:
* - required = false: 表示这个参数不是必需的。如果请求中不包含 page 参数,程序不会报错。
* - defaultValue = "1": 如果请求中没有传递 page 参数,则默认为 "1"。
* @return 响应字符串
*/
@GetMapping("/search")
public String searchUsers(
// 这里一样的,可以省略
@RequestParam String keyword,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page) {

return "正在搜索用户,关键词: " + keyword + ", 页码: " + page;
}
}

运行验证

使用 cURL 访问 /users/search 路径,并附带查询参数。

1
2
# 测试必需参数和默认值参数
curl "http://localhost:8080/users/search?keyword=admin"
1
2
# 测试同时提供两个参数
curl "http://localhost:8080/users/search?keyword=admin&page=3"

1.5.2. 处理请求体:@RequestBody 与 JSON

为什么需要它?
当需要提交的数据结构比较复杂时(例如创建一个新用户,包含姓名、密码、邮箱等多个字段),通常会将这些数据作为一个整体,放在 HTTP 请求体(Request Body)中发送,而 JSON 是当今最主流的数据格式。

如何使用?
@RequestBody 注解告诉 Spring MVC:请获取完整的请求体内容,并使用内置的 HttpMessageConverter(通常是 Jackson)将其反序列化成一个指定的 Java 对象(POJO)。

代码示例
首先,我们需要创建一个 User 类来承载数据。

文件路径: src/main/java/com/example/springbootdemo/model/User.java (新增文件)

1
2
3
4
5
6
7
8
9
10
package com.example.springbootdemo.model;

import lombok.Data;

@Data // Lombok 注解,自动生成 Getter, Setter, toString, equals, hashCode 等方法
public class User {
private Long id;
private String username;
private String email;
}

然后,在 UserController 中添加一个创建用户的方法。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.model.User;
import org.springframework.web.bind.annotation.*;

// 注意,这里的 RestController 其实就是 @Controller + @ResponseBody
@RestController
@RequestMapping("/users") // 在类上定义父路径 /users
public class UserController {

// ....其余的所有代码

/**
* @RequestBody 注解告诉 Spring MVC 将请求体中的 JSON 数据
* 自动转换为一个 User 对象
* @param user Spring MVC 自动实例化的 User 对象
* @return 响应字符串
*/
@PostMapping("/create")
public String createUser(@RequestBody User user) {
// 在实际项目中,这里会调用 Service 层将 user 对象存入数据库
return "成功创建用户: " + user.toString();
}
}

重要信息: @RestController 是类级注解,让类成为返回数据的控制器;@RequestBody 是方法参数注解,能把请求体数据转成对象供方法使用,这两者是不同的,需要严格区分!

运行验证

我们使用 cURL 模拟一个 POST 请求,并通过 -H 指定 Content-Typeapplication/json,使用 -d 传入 JSON 数据。

1
2
3
4
curl -X POST \
http://localhost:8080/users/create \
-H 'Content-Type: application/json' \
-d '{"id":1, "username":"zhangsan", "email":"zhangsan@example.com"}'

1.5.3. 自动封装:使用 POJO 接收参数

为什么需要它?
除了接收 JSON 请求体,Spring MVC 还提供了一种更便捷的方式来处理多个普通的 URL 查询参数或表单参数——直接用一个 POJO 对象来接收。

如何使用?
当控制器方法的参数是一个 没有@RequestBody 注解的 POJO 时,Spring MVC 会自动尝试将请求中的 同名参数(无论是 URL 查询参数还是 x-www-form-urlencoded 表单参数)赋值给这个 POJO 对象的相应属性。

代码示例
我们为 UserController 添加一个更复杂的、支持多条件筛选的查询方法。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.model.User;
import org.springframework.web.bind.annotation.GetMapping;
// ... 其他 import ...

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

// ... 此前已有的方法 ...

/**
* 方法参数是一个 POJO (User user),且没有 @RequestBody 注解。
* Spring MVC 会自动将 URL 中的查询参数 ?username =...&email =...
* 绑定到 user 对象的 username 和 email 属性上。
* @param user Spring MVC 自动实例化的 User 对象
* @return 响应字符串
*/
@GetMapping("/filter")
public String filterUsers(User user) {
return "根据条件筛选用户: " + user.toString();
}
}

运行验证

我们像调用普通 GET 请求一样,在 URL 后面附上多个查询参数。

1
curl "http://localhost:8080/users/filter?username=lisi&email=lisi@example.com"

核心区别: @RequestBody 用于处理一个 单一的、完整的请求体(通常是 JSON 或 XML)。而 POJO 直接接收参数的方式,则用于处理 零散的、多个的请求参数(通常是 URL 查询参数或表单)。一个方法中,@RequestBody 注解最多只能使用一次。


2. [专业实战] 分层架构与用户 CRUD API

摘要: 欢迎来到项目的核心实战章节。我们将彻底告别简单的“玩具代码”,引入后端开发中至关重要的 分层解耦思想领域对象模型 (VO/DTO/PO)。本章,我们将搭建一个标准的 Controller-Service-Mapper 三层架构,并在这个坚实的基础上,遵循 先单元测试、后 API 测试 的严谨流程,完成用户管理模块的全套 CRUD 接口开发。

2.1. 严谨的分层架构:VO, DTO, PO 的职责与转换

在开始编写业务代码前,我们必须先解决一个核心的架构问题:我们的数据应该如何在不同层之间流转?

一个常见的、但 不推荐 的做法是,只创建一个 User 实体类,让它从数据库一直贯穿到前端。这种“一招鲜,吃遍天”的模式,在项目初期看似便捷,但随着业务变复杂,会迅速带来一系列问题:

  • 数据冗余:查询用户列表时,前端可能只需要用户的 idusername,但实体类通常包含 password, create_time, update_time 等全部 20 个字段,这会造成不必要的数据库查询和网络传输开销。
  • 安全性问题:实体类直接映射数据库,通常包含密码、盐值等敏感信息。如果不慎将其直接序列化并返回给前端,将造成严重的安全漏洞。
  • 耦合度高:前端的一个展示需求变更(比如需要一个新的组合字段,displayName = username + nickname),可能会迫使我们去修改数据库实体类,这严重违反了各层独立、职责单一的设计原则。

为了解决这些问题,专业的后端开发(如《阿里巴巴 Java 开发手册》中强制规定)都会遵循“领域模型”分层的思想,为不同场景创建不同的 Java 对象。在我们的项目中,将严格遵循以下约定:

对象类型全称约定包名核心职责
POPersistent Objectentity持久化对象。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。
DTOData Transfer Objectdto数据传输对象。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据。它的字段完全根据业务操作的需求来定义。
VOView Objectvo视图对象。由 Controller 层 返回给前端的数据对象。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。

转换的挑战与解决方案

看到这里,您可能会想:在这么多对象之间转换数据,会不会非常麻烦?我们用一个真实的场景来直面这个挑战。

场景设定:假设数据库中的 User (PO) 包含 usernamestatus (整型 1 代表“正常”,2 代表“禁用”)。而前端展示时,需要的是 name 字段和文本描述 statusText (“正常”, “已禁用”)。

首先,我们来定义这两个类。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (新增文件,替换之前的 model 包)

1
2
3
4
5
6
7
8
9
10
package com.example.springbootdemo.entity;

import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String password; // 敏感字段
private Integer status; // 1-正常, 2-禁用
}

文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java (新增文件)

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

import lombok.Data;

@Data
public class UserVO {
private Long id;
private String name; // 对应 User 的 username
private String statusText; // 对应 User 的 status
// 注意:VO 中没有 password 属性,保证了安全
}

如果我们天真地直接使用 BeanUtil.copyProperties,由于 usernamename 名称不匹配,且 statusstatusText 类型和逻辑都不同,转换会失败。

接下来,我们将在一个模拟的 Service 和 Controller 中,演示处理这个问题的三种专业方案。

方案一:手动设置(最清晰直接)

这是最基础、最直观的方法。先用 BeanUtil.copyProperties 拷贝同名属性,再对不匹配或需要逻辑处理的属性进行手动的 set 操作。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.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
package com.example.springbootdemo.service;

import cn.hutool.core.bean.BeanUtil;
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;

@Service
public class UserService {

// 模拟数据库
private static final Map<Long, User> userDatabase = new HashMap<>();
static {
User user1 = new User();
user1.setId(1L);
user1.setUsername("zhangsan");
user1.setPassword("123456");
user1.setStatus(1);
userDatabase.put(1L, user1);
}

public User getUserById(Long id) {
return userDatabase.get(id);
}

// 方案一:手动转换
public UserVO convertToVOManual(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
// 1. 先拷贝名称和类型都相同的属性
BeanUtil.copyProperties(user, userVO);
// 2. 手动处理名称不同或需要逻辑转换的属性
userVO.setName(user.getUsername());
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

@Autowired
private UserService userService;

// ... 此前已有的方法可以删除掉 ...

@GetMapping("/vo/manual/{id}")
public UserVO getUserVOManual(@PathVariable Long id) {
User user = userService.getUserById(id);
return userService.convertToVOManual(user);
}
}

启动应用,访问 http://localhost:8080/users/vo/manual/1

1
curl http://localhost:8080/users/vo/manual/1

方案二:Hutool @Alias 注解(推荐用法)

Hutool 提供了一个非常方便的 @Alias 注解,专门用来解决属性名不一致的问题。

文件路径: src/main/java/com/example/springbootdemo/vo/UserVO.java (修改)

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

import cn.hutool.core.annotation.Alias;
import lombok.Data;

@Data
public class UserVO {
private Long id;
@Alias("username") // 告诉 Hutool, 这个 name 属性对应源对象的 username 属性
private String name;
private String statusText;
}

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ... 省略 import 和类 ...
@Service
public class UserService {
// ... 已有代码 ...

// 方案二:使用 @Alias 注解
public UserVO convertToVOWithAlias(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
// Hutool 会自动识别 @Alias 注解并完成 username -> name 的拷贝
BeanUtil.copyProperties(user, userVO);
// 依然需要手动处理 status -> statusText 的逻辑转换
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
// ... 省略 import 和类 ...
@RestController
@RequestMapping("/users")
public class UserController {
// ... 已有代码 ...

@GetMapping("/vo/alias/{id}")
public UserVO getUserVOWithAlias(@PathVariable Long id) {
User user = userService.getUserById(id);
return userService.convertToVOWithAlias(user);
}
}

启动应用,访问 http://localhost:8080/users/vo/alias/1,结果与方案一完全相同。此方案让 Service 层的代码更简洁,将映射关系维护在了 VO 定义中,权责更分明。

对于复杂的、大量的 DTO 转换,MapStruct 是业界公认的最佳实践。它在 编译期 生成映射代码,性能极高(接近手写 get/set),并且功能强大。MapStruct 功能强大但需要额外配置,在本系列笔记的早期,我们将主要采用 方案一和方案二。后续高级篇章中,我们再深入探讨其详细配置与使用。


对象类型全称约定包名核心职责
POPersistent Objectentity持久化对象。与数据库中的表结构一一对应,一个 PO 对象就是数据库中的一条记录。它只应出现在数据访问层(Mapper)与服务层(Service)之间。
DTOData Transfer Objectdto数据传输对象。用于在各个层之间传递数据,我们主要用它来 接收前端传递到 Controller 的请求数据。它的字段完全根据业务操作的需求来定义。
VOView Objectvo视图对象。由 Controller 层 返回给前端的数据对象。它的字段完全根据前端界面的展示需求来定制,可以隐藏敏感字段,也可以组合多个 PO 的数据。
BOBusiness Objectservice/bo业务对象。封装了核心的业务逻辑,是业务规则的载体。在复杂业务中,Service 层会处理 BO,并由 BO 完成具体的业务计算和状态变更。
QOQuery Objectdto/query查询对象。用于封装复杂的查询条件,通常作为 Controller 方法的参数,接收前端传递的筛选、排序、分页等请求参数。

您可能已经注意到,上表中除了我们使用的 PO, DTO, VO 之外,还出现了两个更高级的对象:BO 和 QO。了解它们将有助于我们建立更完整的后端分层思想。

  • BO (Business Object): 业务对象
    这是纯粹的业务层核心,封装了最核心的业务逻辑和规则。在一个非常复杂的系统中(例如,包含复杂计价、风控、状态流转的订单系统),Service 层可能会创建和操作 BO 来执行业务计算。比如,一个 OrderBO 可能有一个 calculateTotalPrice() 方法,其中包含了复杂的折扣、优惠券和运费计算逻辑。

    在我们当前的用户管理 CRUD 项目中,业务逻辑相对简单(主要是数据库操作的组合),我们将直接在 Service 层中实现这些逻辑,因此 暂时不会创建独立的 BO 类,但您需要理解这个概念,它对于驾驭复杂系统至关重要。

  • QO (Query Object): 查询对象
    当我们的查询条件变得复杂时,QO 就派上了大用场。想象一下,如果我们需要根据用户名、邮箱、状态、注册时间范围来筛选用户,并且还要支持分页和排序,Controller 的方法签名可能会有七八个 @RequestParam 参数,显得非常臃肿。

    此时,我们可以创建一个 UserQuery 对象,将所有这些查询参数都作为其属性。这样,Controller 方法就只需要接收一个 UserQuery 对象即可,代码会变得非常整洁和易于扩展。我们将在后续实现复杂查询功能时,正式引入 QO。


2.2. 项目骨架搭建与持久化整合

在上一节,我们明确了 VO, DTO, PO 等领域对象的职责。现在,我们将正式动手“施工”。本节的目标是双重的:首先,我们将搭建起一个专业、可扩展的三层架构骨架;其次,我们将为项目接入真实的 MySQL 数据库,为后续的 CRUD 实战做好万全准备。

2.2.1. 搭建三层架构的包与类结构

我们再次明确各层的职责:

  • Controller 层: Web 入口,负责处理 HTTP 请求、参数校验、调用 Service 并返回响应。
  • Service 层: 业务核心,负责实现业务逻辑、管理事务。
  • Mapper 层: 数据持久层,负责与数据库直接交互。

首先,请按照下图的结构,在您的项目中创建或重构出相应的包。一个清晰的目录结构是项目可维护性的第一保障。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
. 📂 src/main/java/com/example/springbootdemo
├── 📂 controller <- Controller 层
│ └── 📄 UserController.java
├── 📂 dto <- DTOs (Data Transfer Objects)
├── 📂 entity <- POs (Persistent Objects), 即实体类
│ └── 📄 User.java
├── 📂 mapper <- Mapper 层 (数据访问接口)
│ └── 📄 UserMapper.java
├── 📂 service <- Service 接口层
│ └── 📄 UserService.java
├── 📂 service/impl <- Service 实现层
│ └── 📄 UserServiceImpl.java
├── 📂 vo <- VOs (View Objects)
│ └── 📄 UserVO.java
└── 📄 SpringBootDemoApplication.java

现在,请将我们在 2.1 节中定义的 User.java 类移动到 entity 包下,将 UserVO.java 类移动到 vo 包下。

接下来,我们创建 Service 层的接口和它的实现类骨架。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (新增文件)

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

import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {
/**
* 根据 ID 查询用户
* @param id 用户 ID
* @return 用户视图对象
*/
UserVO findUserById(Long id);

/**
* 查询所有用户
* @return 用户视图对象列表
*/
List<UserVO> findAllUsers();
}

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.service.impl;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.stereotype.Service;

import java.util.List;

@Service // Service 注解注解在实现类上,因为只有实现类才有业务逻辑
public class UserServiceImpl implements UserService {


@Override
public UserVO findUserById(Long id) {
return null;
}

@Override
public List<UserVO> findAllUsers() {
return List.of();
}
}

2.2.2. 整合 MyBatis-Plus 与数据库连接

项目骨架已经搭好,现在我们来为它注入灵魂——连接真实的数据库。

1. 添加 Maven 依赖

文件路径: pom.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!--        引入HuTool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.13</version>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<!-- 注意这里,这里是一个很常见的坑,AI总喜欢给spring没有-boot3的包,然后就会有兼容性问题 -->
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

2. 配置数据源

文件路径: src/main/resources/application.yml(修改)

1
2
3
4
5
6
7
8
9
10
11
# --------------------
# 主配置文件 (共享配置)
# --------------------
# DataSource Settings
spring:
datasource:
url: jdbc:mysql://localhost:3306/springboot_demo?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

请确保您已创建名为 springboot_demo 的数据库,并将用户名和密码替换为您自己的配置。

3. 改造实体类并建表

请在您的 springboot_demo 数据库中执行以下 SQL 语句来创建 t_user 表:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `t_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(255) DEFAULT NULL COMMENT '用户名',
`password` varchar(255) DEFAULT NULL COMMENT '密码(后续章节会加密)',
`email` varchar(255) DEFAULT NULL COMMENT '邮箱',
`status` int DEFAULT NULL COMMENT '状态: 1-正常, 2-禁用',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
INSERT INTO `t_user` (`username`, `password`, `email`, `status`) VALUES
('张三', 'e10adc3949ba59abbe56e057f20f883e', 'zhangsan@163.com', 1),
('李四', 'e10adc3949ba59abbe56e057f20f883e', 'lisi_dev@qq.com', 1),
('王五', 'e10adc3949ba59abbe56e057f20f883e', 'wang.wu@gmail.com', 2),
('赵六', 'e10adc3949ba59abbe56e057f20f883e', 'zhaoliu@outlook.com', 1),
('孙悟空', 'e10adc3949ba59abbe56e057f20f883e', 'sunwukong@huaguoshan.com', 1),
('陈晓明', 'e10adc3949ba59abbe56e057f20f883e', 'chen.xm@126.com', 1),
('刘静', 'e10adc3949ba59abbe56e057f20f883e', 'liujing88@hotmail.com', 1),
('周伟', 'e10adc3949ba59abbe56e057f20f883e', 'zhouwei_cool@qq.com', 1),
('吴磊', 'e10adc3949ba59abbe56e057f20f883e', 'wulei.actor@gmail.com', 2),
('郑秀丽', 'e10adc3949ba59abbe56e057f20f883e', 'zhengxiuli@163.net', 1),
('马云', 'e10adc3949ba59abbe56e057f20f883e', 'jack.ma@alibaba-inc.com', 1),
('黄蓉', 'e10adc3949ba59abbe56e057f20f883e', 'huangrong@taohuadao.net', 1),
('郭靖', 'e10adc3949ba59abbe56e057f20f883e', 'guojing@xiangyang.gov', 1),
('杨过', 'e10adc3949ba59abbe56e057f20f883e', 'yangguo_daxia@gumu.org', 2),
('林黛玉', 'e10adc3949ba59abbe56e057f20f883e', 'lin.daiyu@rongguofu.com', 1),
('贾宝玉', 'e10adc3949ba59abbe56e057f20f883e', 'jia.baoyu@rongguofu.com', 1),
('曹操', 'e10adc3949ba59abbe56e057f20f883e', 'caocao.ceo@san.guo', 1),
('诸葛亮', 'e10adc3949ba59abbe56e057f20f883e', 'zhuge.liang@shuhan.cn', 1),
('陆小凤', 'e10adc3949ba59abbe56e057f20f883e', 'luxiaofeng@sitaimei.com', 1),
('花满楼', 'e10adc3949ba59abbe56e057f20f883e', 'huamanlou@baihuayuan.com', 1);

现在,我们修改 User.java 实体类,为其添加 MyBatis-Plus 的注解。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (修改)

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

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;

@Data
@TableName("t_user")
public class User {

@TableId(value = "id", type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String email;
private Integer status;
}

4. 创建 Mapper 接口

文件路径: src/main/java/com/example/springbootdemo/mapper/UserMapper.java (新增文件)

1
2
3
4
5
6
7
package com.example.springbootdemo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.springbootdemo.entity.User;

public interface UserMapper extends BaseMapper<User> {
}

仅需继承 BaseMapper<User>UserMapper 就立刻拥有了强大的 CRUD 能力。

5. 启用 Mapper 扫描

最后一步,让 Spring Boot 知道去哪里查找我们的 Mapper 接口。

文件路径: src/main/java/com/example/springbootdemo/SpringBootDemoApplication.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.springbootdemo.mapper") // 扫描 mapper 包下的所有接口
public class SpringBootDemoApplication {

public static void main(String[] args) {
SpringApplication.run(SpringBootDemoApplication.class, args);
}

}

至此,我们已经完成了项目最关键的奠基工作。我们不仅拥有了专业的分层结构,还成功地将应用与 MySQL 数据库连接了起来。现在,我们的项目已经万事俱备,只待我们去实现真正的业务功能。下一节,我们将从用户查询功能开始,正式进入 CRUD 接口的开发。


2.3. [R] 用户查询功能开发

项目骨架和持久化层已经准备就绪。现在,我们正式开始实现第一个核心功能——用户查询。我们将严格遵循我们制定的专业流程,并在实践中应用我们刚刚学到的分层思想。

2.3.1. DTO/QO 设计:封装查询参数

2.1 节,我们明确了 DTO 的职责之一是 接收前端的请求数据。当查询条件变得复杂时,我们通常会使用一种特殊的 DTO——查询对象 (Query Object, QO) 来封装这些参数。

为什么需要 QO?
设想一下,我们的“查询所有用户”功能未来肯定需要支持 分页,甚至可能需要根据 用户名关键词 进行筛选。如果直接在 Controller 方法里写多个 @RequestParam 参数,代码会显得非常臃肿且难以扩展。

因此,我们首先创建一个 UserPageQuery,用于封装分页查询的参数。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdemo.dto.User;

import lombok.Data;

@Data
public class UserPageQuery {
// 页码,默认值为 1
private int pageNo = 1;

// 每页条数,默认值为 10
private int pageSize = 10;
}

2.3.2. 配置 MyBatis-Plus 分页插件

在使用 MyBatis-Plus 的分页功能(即 selectPage 方法)之前,我们需要先通过配置来启用它的 分页插件。这是保证分页查询能够正常工作的关键一步。

文件路径: src/main/java/com/example/springbootdemo/config/MybatisPlusConfig.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

/**
* 添加分页插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 向 MyBatis-Plus 的过滤器链中添加分页拦截器,并指定数据库类型为 MySQL
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

2.3.3. Service 层开发与单元测试

1. Service 层功能实现

现在,我们来填充 UserServiceImpl 中的业务逻辑。首先,确保 UserService 接口的定义是正确的。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

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

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

UserVO findUserById(Long id);

/**
* 修改方法签名,以接收分页查询参数
* @param query 分页查询参数
* @return 用户视图对象列表
*/
List<UserVO> findAllUsers(UserPageQuery query);
}

接下来,实现 UserServiceImpl

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
53
54
55
56
57
58
59
60
61
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

@Service // Service 注解注解在实现类上,因为只有实现类才有业务逻辑
public class UserServiceImpl implements UserService {

@Autowired
private UserMapper userMapper;

@Override
public UserVO findUserById(Long id) {
User user = userMapper.selectById(id);
// 1. 调用 Mapper 方法从数据库查询 PO
if (user == null) {
return null;
}
// 2. 将 PO 转换为 VO
return convertToVO(user);
}

@Override
public List<UserVO> findAllUsers(UserPageQuery query) {
Page<User> page = new Page<>(query.getPageNo(), query.getPageSize());

// selectPage 返回的 Page 对象永远不会是 null,它的 records 列表也非 null
Page<User> userPage = userMapper.selectPage(page, null);

// 直接在 stream 上操作,如果 records 是空列表,stream().map()... 会自然地返回一个空列表
return userPage.getRecords().stream()
.map(this::convertToVO)
.toList();
}

// 私有的转换方法,遵循在 2.1 节定义的转换逻辑
private UserVO convertToVO(User user) {
if (user == null) {
return null;
}
// 此处我们 UserVO 中没有与 User 不同的属性名,所以直接拷贝
// 如果有,则需在此处手动处理,如 userVO.setName(user.getUsername());
UserVO userVO = new UserVO();
BeanUtil.copyProperties(user, userVO);
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
return userVO;
}
}

2. Service 层单元测试

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.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
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;


@Test
void testFindAllUsers_Pagination() {
// 测试分页查询
UserPageQuery query = new UserPageQuery();
query.setPageNo(1);
query.setPageSize(5);
List<UserVO> users = userService.findAllUsers(query);
Assertions.assertNotNull(users);
Assertions.assertEquals(5, users.size()); // 断言返回了 5 条记录
System.out.println("第一页,5条数据:" + users);
}
// ...其余的 test 方法删除
}

2.3.4. Controller 层开发与优雅响应

Service 层通过单元测试后,我们来编写 Controller。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

// 注意,这里的 RestController 其实就是 @Controller + @ResponseBody
@RestController
@RequestMapping("/users") // 在类上定义父路径 /users
// 这个注解独属于 Lombok,针对类中用 final 修饰或标记了 @NonNull 的字段,自动生成对应的构造函数,减少代码冗余。
@RequiredArgsConstructor
public class UserController {
// 构造函数注入能确保 userService 对象一定被注入,利于保证对象状态完整
// 而且在进行依赖替换和测试时更方便,@Autowired 字段注入相对没这么严格。
private final UserService userService;

@GetMapping
public List<UserVO> getAllUsers(UserPageQuery query) {
return userService.findAllUsers(query);
}

@GetMapping("/{id}")
public ResponseEntity<UserVO> getUserById(@PathVariable Long id) {
UserVO userVO = userService.findUserById(id);
// 使用 ResponseEntity 来处理查询结果可能为 null 的情况
// 如果 userVO 不为 null,返回 200 OK 和 userVO 数据
// 如果 userVO 为 null,返回 404 Not Found
return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();
}

}

getUserById 方法中,我们使用了一行关键代码:

1
`return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();`

这行代码背后,体现了专业 API 设计的核心思想:精确控制 HTTP 响应。

  • 为什么需要它?
    如果我们直接返回 UserVO 对象,当查询的用户不存在时,userService 会返回 null。Spring MVC 默认会将 null 转换为空的响应体,并返回 200 OK 状态码。这对前端来说是有歧义的:是成功地查到了一个“空”的用户,还是用户根本不存在?这不符合 RESTful 的设计原则。

  • ResponseEntity<T> 是什么?
    它是 Spring 提供的一个泛型类,用于 完整地封装一个 HTTP 响应。通过它,我们可以随心所欲地控制:

    • 响应状态码 (Status Code): 如 200 OK, 404 Not Found, 400 Bad Request 等。
    • 响应头 (Headers): 如 Content-Type, Location 等。
    • 响应体 (Body): 我们实际返回的数据(比如我们的 UserVO)。

通过使用 ResponseEntity,我们的 API 变得更加健壮和语义化,能够清晰地向客户端传达操作的结果。

2.3.5. API 接口测试

现在,启动您的 Spring Boot 主应用,并使用 cURL 或 Apifox 等工具,手动对我们刚刚完成的接口进行验证。

测试用例 1:分页查询用户

  • 请求方法: GET
  • 请求 URL: http://localhost:8080/users?pageNo=1&pageSize=5
1
curl "http://localhost:8080/users?pageNo=1&pageSize=5"

测试用-例 2:查询不存在的用户

  • 请求方法: GET
  • 请求 URL: http://localhost:8080/users/999
1
curl -i "http://localhost:8080/users/999"

通过手动测试,我们验证了接口的正确性。但您可能也发现了,每次都需要手动构建 URL、查看 JSON 响应,当接口变多、参数变复杂时,这个过程会变得相当繁琐且容易出错。有没有更高效、更直观的方式呢?下一节,我们将正式引入 SpringDoc 来彻底解决这个“痛点”


2.4. API 文档与测试:SpringDoc 的引入与实践

随着 UserController 中的查询功能编写完毕,一个现实的团队协作问题摆在了我们面前:

我们如何将这些 API 接口的信息,准确、高效地传递给其他人(比如前端同事,或者未来的自己)?


2.4.1. 痛点:为什么需要自动化 API 文档?

在没有自动化工具的时代,我们通常依赖以下方式,但它们都存在明显弊端:

  • 手动编写文档 (如 Word, Wiki): 极其繁琐、容易出错,而且一旦代码更新,文档几乎总是会忘记同步,导致文档与代码不一致,造成更大的困扰。
  • 口头沟通或发送消息: 效率低下,信息零散,无法作为可靠的、可追溯的技术凭证。
  • 代码注释: 虽然必要,但无法提供一个全局的、可交互的 API 视图。

这些痛点最终都指向一个核心需求:我们需要一个能够 与代码自动同步、标准化且支持在线交互 的 API 文档解决方案。

2.4.2 解决方案:SpringDoc 与 OpenAPI 3

  • OpenAPI 3: 它是当今 RESTful API 领域的事实标准规范(前身是 Swagger 2.0 规范)。它定义了一套标准的、与语言无关的格式(通常是 YAML 或 JSON),用于完整地描述 API 的所有细节。
  • Swagger UI: 这是一个开源工具,它可以解析符合 OpenAPI 规范的文档,并生成一个美观、可交互的 HTML 界面,让开发者可以直接在浏览器中浏览和测试 API。
  • SpringDoc: 这是一个 Java 库,它能够 自动扫描 我们的 Spring Boot 应用中的 @RestController 等注解,并 自动生成 符合 OpenAPI 3 规范的 API 文档。

简而言之,我们的工作流程是:编写代码 -> SpringDoc 自动生成文档 -> Swagger UI 可视化文档


2.4.3 技术选型:为什么是 SpringDoc,而不是传统的 Swagger (SpringFox)?

在 Spring Boot 2.x 时代,SpringFox 是集成 Swagger 2 的主流选择。但随着技术发展,SpringDoc 已经成为当下的最佳实践,原因如下:

特性SpringDoc (我们选择的)SpringFox (旧方案)
核心规范OpenAPI 3.xSwagger 2.0
Spring Boot 兼容性完美兼容 Spring Boot 3.x / 2.x对 Spring Boot 3.x 支持停滞,存在兼容性问题
社区活跃度持续活跃开发与维护社区已基本停止维护
配置依赖更少,自动化配置程度更高配置相对繁琐

结论: SpringDoc 是面向未来的、与 Spring Boot 生态结合最紧密的选择,因此我们毫无疑问地选择它。


2.4.4 实战:集成 SpringDoc 到我们的项目中

第一步:添加 Maven 依赖

我们只需添加一个依赖,即可同时拥有 OpenAPI 文档生成和 Swagger UI 界面的能力。

文件路径: pom.xml (修改)

1
2
3
4
5
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</dependency>

第二步:丰富 API 注解

为了让生成的文档信息更丰富、更易读,我们可以使用 SpringDoc 提供的注解来“装饰”我们的 Qo 和 Controller 。

文件路径: src/main/java/com/example/springbootdemo/dto/UserPageQuery.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

package com.example.springbootdemo.dto;

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

@Schema(description = "用户分页查询参数")
@Data
public class UserPageQuery {

@Schema(description = "页码,从0开始", example = "0", defaultValue = "0")
private int pageNo = 0;

@Schema(description = "每页大小", example = "10", defaultValue = "10")
private int pageSize = 10;
}

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口") // 新增
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;


@GetMapping
@Operation(summary = "查询所有用户列表")
public List<UserVO> getAllUsers(
@Parameter(
description = "分页查询参数",
required = true,
schema = @Schema(implementation = UserPageQuery.class)
)
UserPageQuery query) {
return userService.findAllUsers(query);
}

@GetMapping("/{id}")
@Operation(summary = "根据ID查询单个用户") // 新增
public ResponseEntity<UserVO> getUserById(
// 新增
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id
) {
UserVO userVO = userService.findUserById(id);
return userVO != null ? ResponseEntity.ok(userVO) : ResponseEntity.notFound().build();
}

}
  • @Tag: 在类上使用,用于对整个 Controller 的接口进行 分组
  • @Operation: 在方法上使用,用于 一句话描述 该接口的功能。
  • @Parameter: 在方法参数上使用,用于 描述参数 的含义、是否必需等信息。
  • @Schema:在属性上使用,用于描述 参数的示例值 提供给接口调用传参使用

第三步:启动与验证

现在,请 重启 您的 Spring Boot 主应用。

无需打开 Apifox,直接在浏览器中访问:

1
http://localhost:8080/swagger-ui.html

您将会看到一个专业、美观且功能强大的 API 文档界面。在这里,您可以清晰地看到我们定义的接口分组、描述、参数等信息,并可以直接点击 “Try it out” 按钮,在线发起请求并查看实时响应。这套动态文档将成为我们后续开发和测试的“指挥中心”。

这仅仅是 SpringDoc 的入门。在后续章节中,当我们遇到 DTO 的详细描述、统一的认证配置等更复杂的场景时,我们还会学习 @Schema@ApiResponse 等更多高级注解,让我们的 API 文档变得更加专业和完善。


2.5. 统一响应封装:构建全局 Result 返回值

2.3 节的查询功能中,我们已经能成功返回 UserVO 列表或单个 UserVO。但这还不够“专业”。一个成熟的后端 API,其所有接口都应该返回 结构统一 的响应数据。本节,我们将引入企业级开发中的一项最佳实践——构建全局响应封装。

2.5.1. 为什么要统一响应格式?

想象一下前端同事在调用我们的 API 时的场景,如果没有统一的响应格式,他们会遇到以下痛点:

  • 前端处理困难:前端开发者需要为每个接口编写不同的逻辑来处理成功和失败。GET /users 成功时返回一个 UserVO[] 数组,而 GET /users/1 成功时返回一个 UserVO 对象,失败时又可能返回空或 404。这种不一致性会极大地增加前端的处理逻辑复杂度。
  • 成功与否判断不清晰:仅通过 HTTP 状态码(200)无法区分所有业务场景。例如,“用户名已存在”是一个业务逻辑上的失败,但 HTTP 状态码可能依然是 200,前端无法仅凭状态码判断操作是否真正成功。
  • 缺乏元信息:响应中只包含业务数据 data,缺少了像 业务状态码 code提示信息 message 这样的元数据,不利于前端进行统一的提示或错误处理。

为了解决这些问题,我们需要定义一个通用的响应体结构,通常称为 ResultApiResponse


2.5.2. 创建通用响应类 Result < T >

我们首先创建一个通用的、支持泛型的 Result<T> 类,并约定所有 API 接口都返回这个结构的对象。

我们先创建一个枚举来标准化业务状态码。

文件路径: src/main/java/com/example/springbootdemo/common/ResultCode.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
package com.example.springbootdemo.common;

import lombok.Getter;
import lombok.AllArgsConstructor;

@Getter
@AllArgsConstructor
public enum ResultCode {
// 2xx: 成功
SUCCESS(200, "操作成功"),

// 4xx: 客户端错误
BAD_REQUEST(400, "错误的请求"),
UNAUTHORIZED(401, "未经授权"),
NOT_FOUND(404, "资源未找到"),

// 5xx: 服务器错误
ERROR(500, "服务器内部错误"),
SERVICE_UNAVAILABLE(503, "服务不可用"),

// 100x: 自定义用户业务逻辑
UserNotFound(1001, "用户未找到"),
UserAlreadyExists(1002, "用户已存在"),
UserNotLogin(1003, "用户未登录");

private final int code;
private final String message;
}

接下来,创建 Result<T> 类。

文件路径: src/main/java/com/example/springbootdemo/common/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
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
package com.example.springbootdemo.common;

import lombok.Getter;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import java.io.Serializable;

/**
* 最终推荐版本:
* 1. 使用 @Getter 代替 @Data 去掉 setter,实现不变性。
* 2. 字段声明为 final,确保只能在构造时赋值。
* 3. 使用 @AllArgsConstructor(access = AccessLevel.PRIVATE) 自动生成私有全参构造,代码更简洁。
*/
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 自动生成私有全参构造函数
public final class Result<T> implements Serializable { // class 也可以声明为 final

private final Integer code; // 业务状态码
private final String message; // 提示信息
private final T data; // 数据

// 成功-无数据
public static <T> Result<T> success() {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
}

// 成功-有数据
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}

// 失败-使用预定义的 ResultCode 枚举,这是最规范的方式
public static <T> Result<T> error(ResultCode resultCode) {
return new Result<>(resultCode.getCode(), resultCode.getMessage(), null);
}

// 失败-自定义消息
public static <T> Result<T> error(String message) {
return new Result<>(ResultCode.ERROR.getCode(), message, null);
}

// 失败-自定义错误码和消息
public static <T> Result<T> error(Integer code, String message) {
return new Result<>(code, message, null);
}

// 失败-通用错误
public static <T> Result<T> error() {
return error(ResultCode.ERROR);
}
}

2.5.3. ResponseEntity 深度应用

2.3.4 节,我们初步接触了 ResponseEntity。现在,我们将深度挖掘它的潜力,学习如何将我们自定义的 Result<T> 对象与精确的 HTTP 状态码结合,构建出真正专业的 API 响应。

我们需要明确两者之间的职责分工:

  • Result<T> (响应体 Body): 负责承载 业务层面 的信息。codemessage 反映的是业务的成功、失败或校验结果。
  • ResponseEntity (HTTP 响应): 负责承载 HTTP 协议层面 的信息。它的 Status Code 反映的是 HTTP 请求本身的处理结果(如 200 OK, 404 Not Found, 500 Internal Server Error)。

最佳实践映射关系:

操作场景HTTP Status (ResponseEntity)响应体 (Result Body)Controller 返回类型
查询成功 (GET)200 OKResult.success(data)ResponseEntity<Result<UserVO>>
创建成功 (POST)201 CreatedResult.success()ResponseEntity<Result<Void>>
更新成功 (PUT)200 OKResult.success(updatedData)ResponseEntity<Result<UserVO>>
删除成功 (DELETE)204 No Content(无响应体)ResponseEntity<Void>
客户端错误400 Bad RequestResult.error(错误信息)ResponseEntity<Result<Void>>
资源未找到404 Not FoundResult.error("用户不存在")ResponseEntity<Result<Void>>
服务端异常500 Internal Server ErrorResult.error("系统异常")ResponseEntity<Result<Void>>

现在,我们就根据上方的实践关系来修改我们的 Controller 业务代码

文件路径: src/main/java/com/example/springbootdemo/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
53
54
55
56
57
58
59
60

package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

// 通过构造函数注入 UserService 依赖
private final UserService userService;

@GetMapping
@Operation(summary = "查询所有用户列表")
public ResponseEntity<Result<List<UserVO>>> getAllUsers(
@Parameter(
description = "分页查询参数",
required = true,
schema = @Schema(implementation = UserPageQuery.class)
)
UserPageQuery query) {
// 调用服务层获取用户列表数据
List<UserVO> users = userService.findAllUsers(query);
// 使用 Result.success()包装数据并返回统一格式的响应
return ResponseEntity.ok(Result.success(users));
}

@GetMapping("/{id}")
@Operation(summary = "根据ID查询单个用户")
public ResponseEntity<Result<UserVO>> getUserById(
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id
) {
// 调用服务层根据 ID 查询用户
UserVO userVO = userService.findUserById(id);

// 判断用户是否存在并返回相应结果
if (userVO != null) {
// 用户存在,返回成功结果
return ResponseEntity.ok(Result.success(userVO));
} else {
// 用户不存在,返回错误信息 - 注意无论是成功或是失败都最好返回 200 响应码
return ResponseEntity.ok(Result.error("用户不存在"));
}
}
}

现在我们打开 http://localhost: 8080/swagger-ui/index.html 即可查看到如下的内容信息:

可以看到我们的返回值完全符合大型级别返回规范,且前端可以根据不同的返回状态码和信息去接收数据!

image-20250816114701281


2.6. [C] 新增功能开发 (POST)

完成了查询(Read)功能后,我们来继续实现 CRUD 中的创建(Create)功能。我们将严格遵循之前确立的 分层DTO“单元测试 -> API 测试” 的严谨流程。

2.6.1. DTO 设计与 Service 层开发

1. DTO 设计

为“新增用户”操作创建一个专门的 UserSaveDTO 是一个非常好的实践。它的职责是精确地承载创建用户时 所有必需 的、且 允许客户端提供 的数据。

为什么需要独立的 UserSaveDTO

  • 安全性: 防止客户端通过 API 请求传递一些不应由他们设置的字段,例如 id(应由数据库生成)、status(可能有默认值或由特定逻辑控制)等。
  • 职责单一: DTO 的字段完全为“新增”这个业务场景服务。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java (新增文件)

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

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

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", required = true, example = "newuser")
private String username;

@Schema(description = "密码", required = true, example = "123456")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
private String email;
}

2. Service 层功能实现

首先,更新 UserService 接口,添加 saveUser 方法。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.UserSaveDTO; // 新增导入
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

UserVO findUserById(Long id);

List<UserVO> findAllUsers(UserPageQuery query);

/**
* 新增用户
* @param dto 用户新增数据
* @return 新增用户的 ID
*/
Long saveUser(UserSaveDTO dto);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.UserSaveDTO; // 新增导入
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;
import com.example.springbootdemo.service.UserService;
import com.example.springbootdemo.vo.UserVO;
import lombok.RequiredArgsConstructor; // 使用构造函数注入
import org.springframework.stereotype.Service;

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

@Service
@RequiredArgsConstructor // Lombok 注解,为所有 final 字段生成一个构造函数
public class UserServiceImpl implements UserService {

private final UserMapper userMapper; // 使用 final 和构造函数注入

// ... findUserById 和 findAllUsers 方法保持不变 ...

@Override
public Long saveUser(UserSaveDTO dto) {
// 1. 业务校验:断言用户名不存在
User existingUser = userMapper.selectOne(
// 这里的条件转换为 SQL: SELECT * FROM t_user WHERE username = ?
new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername())
);
// 使用 HuTool 提供的断言工具优雅的抛出异常
Assert.isNull(existingUser, "用户名 [{}] 已存在,请更换!", dto.getUsername());

// 2. DTO -> PO: 使用 Convert 一步到位,创建并拷贝属性
User user = Convert.convert(User.class, dto);

// 3. 设置默认值
user.setStatus(1); // 默认为正常状态

// 4. 插入数据
userMapper.insert(user);

// 5. 返回 ID
return user.getId();
}

// ... convertToVO 方法保持不变 ...
}

这里细心的朋友可能会发现,我在代码里面使用了 Hutool 中的 Convert.convert 而不使用原来的 ConvertToVo 方法,其实他们的差距就如下表所示

简单来说:

  • Convert.convert(User.class, dto) 能成功,是因为 UserSaveDTOUser 之间的属性(如 username, password)是同名同类型的,可以直接复制。
  • Convert.convert(UserVO.class, user) 无法 正确生成 statusText 字段,因为它不知道 User 里的 status 字段和 UserVO 里的 statusText 字段之间存在 1 -> "正常" 的映射关系。

2.6.2. Service 层单元测试

我们必须为新的 saveUser 方法编写单元测试,以确保其逻辑的正确性。

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.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
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testSaveUser() {
// 1. 准备 DTO
UserSaveDTO dto = new UserSaveDTO();
// 使用 Hutool 的 IdUtil 生成一个随机的用户名,防止重复
String username = IdUtil.fastSimpleUUID();
dto.setUsername(username);
dto.setPassword("123456");
dto.setEmail(username + "@test.com");
// 2. 调用 Service 方法
Long newUserId = userService.saveUser(dto);
// 3. 断言返回的 ID 不为 null
Assertions.assertNotNull(newUserId);
// 4. 从数据库中查出新用户并进行验证
UserVO savedUser = userService.findUserById(newUserId);
Assertions.assertNotNull(savedUser);
Assertions.assertEquals(username, savedUser.getName());
System.out.println(StrUtil.format("用户 {} 保存成功", username));
}


@Test
void testSaveUser_UsernameExists() {
// 准备一个已存在的用户名的 DTO
UserSaveDTO dto = new UserSaveDTO();
dto.setUsername("张三"); // 假设 "张三" 已存在
dto.setPassword("123456");
userService.saveUser(dto); // java.lang.IllegalArgumentException: 用户名 [张三] 已存在,请更换!
}
}

2.6.3. Controller 层开发与 API 测试 (SpringDoc)

Service 层逻辑验证无误后,我们来创建对应的 Controller 接口。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.dto.UserSaveDTO;
// ... 其他 import ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET 接口 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(@RequestBody UserSaveDTO dto) {
Long userId = userService.saveUser(dto);
// 对于创建操作 (POST),RESTful 风格推荐返回 HTTP 状态码 201 Created
// 我们将新创建的资源 ID 作为 data 返回
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}
}

API 接口测试 (使用 SpringDoc)

重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html。您会看到新增的 POST /users 接口。

  1. 展开 POST /users 接口。
  2. 点击 “Try it out”
  3. Request body 的 JSON 编辑区中,输入以下内容:
    1
    2
    3
    4
    5
    {
    "username": "new_user_from_swagger",
    "password": "password123",
    "email": "swagger@example.com"
    }
  4. 点击 “Execute”

您将会看到如下的响应结果,这表明用户已成功创建。

data 字段中的 21 是数据库为新用户生成的自增 ID,您的实际结果可能会不同。同时请注意,我们遵循 RESTful 最佳实践,为“创建成功”返回了 201 Created 状态码。


2.7. [U] 修改功能开发 (PUT)

完成了新增(Create)和查询(Read)之后,我们来继续实现 CRUD 中的更新(Update)功能。我们将继续遵循之前确立的严谨流程。

2.7.1. DTO 设计与 Service 层开发

1. DTO 设计

与新增操作类似,为“修改用户”创建一个专门的 UserUpdateDTO 也至关重要。

为什么需要独立的 UserUpdateDTO

  • 明确意图: DTO 的字段清晰地表明了哪些信息是允许被修改的。例如,我们通常不允许用户修改他们的 username,这个规则就可以在 DTO 的字段定义中体现。
  • 数据绑定: DTO 中必须包含 id 字段,以便 Service 层知道要更新的是哪一条记录。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.springbootdemo.dto.User;

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

@Data
@Schema(description = "用户修改数据传输对象")
public class UserUpdateDTO {

@Schema(description = "用户ID", required = true, example = "1")
private Long id;

// 注意:我们在这里没有提供 username 字段,意味着我们不允许通过此接口修改用户名

@Schema(description = "密码", example = "new_password_123")
private String password;

@Schema(description = "邮箱", example = "new_email@example.com")
private String email;

@Schema(description = "状态: 1-正常, 2-禁用", example = "2")
private Integer status;
}

2. Service 层功能实现

首先,更新 UserService 接口。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.service;

import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.dto.User.UserUpdateDTO; // 新增导入
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {

// ... 已有方法 ...

Long saveUser(UserSaveDTO dto);

/**
* 修改用户
* @param dto 用户修改数据
*/
void updateUser(UserUpdateDTO dto);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
package com.example.springbootdemo.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Assert;
// ... 其他 import ...
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import com.example.springbootdemo.entity.User;
// ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... 已有方法 ...

@Override
public void updateUser(UserUpdateDTO dto) {
// 1. 业务校验:断言要修改的用户必须存在
User user = userMapper.selectById(dto.getId());
Assert.notNull(user, "用户 ID [{}] 不存在,无法修改!", dto.getId());

// 2. DTO -> PO: 使用 Convert 转换
User updatedUser = Convert.convert(User.class, dto);

// 3. 调用 Mapper 方法更新数据
// updateById 方法会根据传入实体的主键 ID 去更新数据
userMapper.updateById(updatedUser);
}

// ... convertToVO 方法保持不变 ...
}

2.7.2. Service 层单元测试

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.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
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.dto.User.UserSaveDTO;
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import com.example.springbootdemo.vo.UserVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

@SpringBootTest // 启动完整的 Spring Boot 应用上下文来进行测试
public class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testUpdateUser() {
// 1. 准备一个待更新的 DTO
UserUpdateDTO dto = new UserUpdateDTO();
dto.setId(1L);
String newEmail = IdUtil.fastSimpleUUID() + "@updated.com";
dto.setEmail(newEmail);
dto.setStatus(2); // 将状态更新为“已禁用”
// 2. 调用更新方法
userService.updateUser(dto);
// 3. 重新查询该用户进行验证
UserVO updatedUser = userService.findUserById(1L);
System.out.println(StrUtil.format("用户 {} 更新成功", updatedUser.getName()));
}


@Test
void testUpdateUser_NotFound() {
// 准备一个不存在的用户 ID 的 DTO
UserUpdateDTO dto = new UserUpdateDTO();
dto.setId(9999L);
dto.setEmail("test@test.com");

// 断言调用 updateUser 会因为找不到用户而抛出异常
// java.lang.IllegalArgumentException: 用户 ID [9999] 不存在,无法修改!
Assertions.assertThrows(IllegalArgumentException.class, () -> {
userService.updateUser(dto);
});
}
}

2.7.3. Controller 层开发与 API 测试 (SpringDoc)

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.common.Result;
// ... 其他 import ...
import com.example.springbootdemo.dto.User.UserUpdateDTO;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET 和 POST 接口 ...

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(@RequestBody UserUpdateDTO dto) {
userService.updateUser(dto);
// 对于修改操作(PUT),如果成功,通常返回 200 OK 和一个成功的业务响应
return ResponseEntity.ok(Result.success());
}
}

API 接口测试 (使用 SpringDoc)

重启您的 Spring Boot 应用,并访问 http://localhost:8080/swagger-ui.html。您会看到新增的 PUT /users 接口。

  1. 展开 PUT /users 接口。
  2. 点击 “Try it out”
  3. Request body 的 JSON 编辑区中,输入以下内容来修改 ID 为 2 的用户:
    1
    2
    3
    4
    5
    {
    "id": 2,
    "email": "lisi_updated@example.com",
    "status": 2
    }
  4. 点击 “Execute”

您将会看到一个 200 OK 的成功响应,表示用户数据已成功更新。您可以再次调用 GET /users/2 接口来验证数据是否真的发生了变化。


2.8. [D] 删除功能开发 (DELETE)

现在,我们来实现用户管理 CRUD 功能的最后一部分:删除指定的用户。我们将继续遵循之前确立的严谨流程,确保代码的健壮性和专业性。

2.8.1. Service 层开发与单元测试

对于删除操作,我们不需要设计新的 DTO,因为通常只需要一个 id 即可唯一确定要删除的资源。

1. Service 层功能实现

首先,在 UserService 接口中添加我们的新方法。

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.springbootdemo.service;

// ... other imports ...

public interface UserService {

// ... 已有方法 ...

void updateUser(UserUpdateDTO dto);

/**
* 根据 ID 删除用户
* @param id 用户 ID
*/
void deleteUserById(Long id);
}

接下来,在 UserServiceImpl 中实现该方法。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
package com.example.springbootdemo.service.impl;

import cn.hutool.core.lang.Assert;
// ... other imports ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... 已有方法 ...

@Override
public void deleteUserById(Long id) {
// 1. 业务校验:断言要删除的用户必须存在
User user = userMapper.selectById(id);
Assert.notNull(user, "用户 ID [{}] 不存在,无法删除!", id);

// 2. 调用 Mapper 方法删除数据
userMapper.deleteById(id);
}

// ... convertToVO 方法保持不变 ...
}

2. Service 层单元测试

为确保删除逻辑及其前置校验的正确性,我们需要编写相应的单元测试。

文件路径: src/test/java/com/example/springbootdemo/service/UserServiceTest.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
package com.example.springbootdemo.service;

import cn.hutool.core.util.IdUtil;
// ... other imports ...
import com.example.springbootdemo.entity.User;
import com.example.springbootdemo.mapper.UserMapper;

@SpringBootTest
public class UserServiceTest {

@Autowired
private UserService userService;

@Autowired
private UserMapper userMapper; // 注入 UserMapper 以便准备测试数据

// ... 已有测试方法 ...

@Test
void testDeleteUser() {
// 1. 准备一条可供删除的测试数据
User testUser = new User();
testUser.setUsername(IdUtil.fastSimpleUUID());
testUser.setPassword("to_be_deleted");
userMapper.insert(testUser);
Long newUserId = testUser.getId();
Assertions.assertNotNull(newUserId, "测试数据插入失败");

// 2. 调用删除方法
userService.deleteUserById(newUserId);

// 3. 验证用户是否真的被删除
UserVO deletedUser = userService.findUserById(newUserId);
Assertions.assertNull(deletedUser);
System.out.println(StrUtil.format("用户 ID [{}] 删除成功", newUserId));
}

@Test
void testDeleteUser_NotFound() {
// 准备一个不存在的用户 ID
Long nonExistentUserId = 9999L;

// 断言调用 deleteUserById 会因为找不到用户而抛出异常
// java.lang.IllegalArgumentException: 用户 ID [9999] 不存在,无法删除!
Assertions.assertThrows(IllegalArgumentException.class, () -> {
userService.deleteUserById(nonExistentUserId);
});
}
}

2.8.2. Controller 层开发与 API 测试 (SpringDoc)

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

// ... other imports ...
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

// ... 已有的 GET, POST, PUT 接口 ...

@Operation(summary = "根据ID删除用户")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(
@Parameter(description = "用户ID", required = true, example = "20") @PathVariable Long id) {
userService.deleteUserById(id);
// 对于删除操作(DELETE),如果成功,RESTful 风格的最佳实践是返回 204 No Content
return ResponseEntity.noContent().build();
}
}

RESTful 最佳实践: 对于 DELETE 操作,如果成功执行,最佳实践是返回 HTTP 204 No Content 状态码。这个状态码表示服务器成功处理了请求,但响应体中 没有内容ResponseEntity.noContent().build() 正是用于构建这种标准响应。注意,由于没有响应体,我们返回的类型是 ResponseEntity<Void>,也就不再需要包装 Result 对象了。


3. [深度交互] 高级请求处理与数据绑定

摘要: 在第二章的实战中,我们已经搭建了项目骨架并实现了核心的 CRUD 功能。这让我们对 Spring MVC 的基础工作流程有了扎实的体感。从本章开始,我们将深入框架的“毛细血管”,探索那些能让我们的代码更灵活、更健壮、更专业的高级功能。

3.1. 自定义类型转换器:实现枚举参数绑定

3.1.1. 需求分析:实现按状态筛选用户

2.x 版本中,我们的用户查询接口只能进行简单的分页。现在,产品经理提出了新需求:在查询用户列表时,能够根据用户状态(正常/禁用)进行筛选

从 API 设计的角度,一个理想的请求 URL 应该是这样的:GET /users?status=1,其中 1 代表“正常”。

在后端,为了代码的可读性和健壮性,我们不希望在代码里到处使用 12 这样的“难懂数字”,而是倾向于使用更具语义的枚举 (Enum) 来代表用户状态。这就带来了一个问题:

Spring MVC 默认不知道如何将前端传来的字符串 "1" 转换为我们后端定义的 UserStatusEnum 枚举。 本节,我们就来优雅地解决这个问题。

3.1.2. 改造实践:在 DTO 与 Service 中使用枚举

1. 创建状态枚举

首先,我们创建一个代表用户状态的枚举类。

文件路径: src/main/java/com/example/springbootdemo/enums/UserStatusEnum.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
package com.example.springbootdemo.enums;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UserStatusEnum {
NORMAL(1, "正常"),
DISABLED(2, "已禁用");

private final int code;
private final String description;

// 根据 code 查找枚举的静态方法,便于后续转换
public static UserStatusEnum fromCode(int code) {
for (UserStatusEnum status : values()) {
if (status.getCode() == code) {
return status;
}
}
// 如果找不到匹配的 code,可以返回 null 或抛出异常
return null;
}
}

2. 更新查询 DTO

接下来,我们在分页查询 DTO 中,添加 status 字段,并将其类型定义为我们刚刚创建的枚举。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserPageQuery.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.springbootdemo.dto.User;

import com.example.springbootdemo.enums.UserStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户分页查询参数")
public class UserPageQuery {

@Schema(description = "页码,从1开始", example = "1")
private int pageNo = 1;

@Schema(description = "每页条数", example = "10")
private int pageSize = 10;

@Schema(description = "用户状态: 1-正常, 2-禁用", example = "1")
private UserStatusEnum status; // 添加 status 字段,类型为枚举
}

3. 更新 Service 层

现在,我们修改 Service 层的 findAllUsers 方法,让它能够根据传入的 status 参数,动态地构建查询条件。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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.springbootdemo.service.impl;

// ... other imports ...
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.example.springbootdemo.entity.User;

// ...

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
public List<UserVO> findAllUsers(UserPageQuery query) {
Page<User> page = new Page<>(query.getPageNo(), query.getPageSize());

// 1. 创建 LambdaQueryWrapper 来构建查询条件,支持Lambda表达式
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();

// 2. 只有当 query.getStatus() 不为 null 时,才添加状态筛选条件
UserStatusEnum status = query.getStatus();
if (ObjectUtil.isNotEmpty(status)) {
queryWrapper.eq(User::getStatus, status.getCode());
}

// 3. 执行分页查询
Page<User> pageResult = userMapper.selectPage(page, queryWrapper);

// 4. 转换为 VO 列表并返回
return pageResult.getRecords().stream()
.map(this::convertToVO)
.collect(Collectors.toList());
}
}

3.1.3. 核心技术:实现并注册自定义 Converter

完成了业务逻辑的改造,现在我们来解决最核心的问题:搭建起前端传入的字符串 “1” 和后端 UserStatusEnum.NORMAL 之间的桥梁。

我们需要实现 Spring 提供的 Converter<S, T> 接口,其中 S 是源类型(String),T 是目标类型(UserStatusEnum)。

文件路径: src/main/java/com/example/springbootdemo/converter/StringToUserStatusEnumConverter.java (新增文件)

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

import com.example.springbootdemo.enums.UserStatusEnum;
import org.springframework.stereotype.Component;
import org.springframework.core.convert.converter.Converter;

@Component // 将此转换器注册为 Spring Bean,Spring Boot 会自动发现并应用它
public class StringToUserStatusEnumConverter implements Converter<String, UserStatusEnum> {
@Override
public UserStatusEnum convert(String source) {
if (source == null || source.isEmpty()) {
return null;
}
// 根据前端传入的 1 或 2 转换成对应的枚举值
int code = Integer.parseInt(source);
return UserStatusEnum.fromCode(code);
}
}

自动注册的魔力:因为我们将这个转换器声明为了一个 @Component Bean,Spring Boot 的自动配置机制会扫描到它,并自动将其添加到全局的转换服务中。这意味着我们无需任何额外配置,这个转换规则就会对所有 Controller 生效。

最妙的是,我们的 UserController 中的 getAllUsers 方法无需任何改动。Spring MVC 在进行参数绑定时,会自动发现并使用我们自定义的 StringToUserStatusEnumConverter,将 status 请求参数(String 类型)转换为 UserPageQuery 对象中的 status 字段(UserStatusEnum 类型)。

示例流程如下图所示:

mermaid-diagram-2025-08-17-104210


一个 HTTP 请求所承载的信息,远不止 URL 查询参数和请求体。请求头(Headers)和 Cookies 也是传递上下文信息的重要载体。本节,我们将通过一系列真实的业务场景,来学习如何通过注解,轻松地获取这些位置的数据。

3.2.1. @RequestHeader:获取请求头信息

@RequestHeader 注解用于将请求头(Request Header)中的字段值,绑定到控制器方法的参数上。

场景一:API 版本控制

在 API 开发中,我们经常通过请求头来传递版本号,以便后端可以针对不同版本的客户端返回不同的数据结构或执行不同的逻辑。

代码示例:

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

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class VersionController {

@GetMapping("/version")
public String getApiVersion(
@RequestHeader(value = "X-API-Version", defaultValue = "1.0") String apiVersion) {
return "当前请求的 API 版本号是: " + apiVersion;
}
}

解释:
getApiVersion 方法通过 @RequestHeader("X-API-VERSION") 注解获取请求头中的版本信息,并提供了一个默认值 "1.0"

场景二:链路追踪

在微服务架构中,为了追踪一个请求在多个服务之间的调用链,通常会在初始请求时生成一个唯一的追踪ID(Trace ID),并通过请求头(如 X-Trace-Id)在后续服务间传递。

代码示例:

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
package com.example.springbootdemo.controller;

import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@Slf4j
public class TraceController {

@GetMapping("/trace")
public String getTraceInfo(
@RequestHeader(value = "X-Trace-Id", required = false) String traceId) {

// 如果没有追踪ID,我们可以为其生成一个
if (StrUtil.isBlank(traceId)) {
traceId = cn.hutool.core.util.IdUtil.fastSimpleUUID();
}

// 在日志中打印追踪ID,便于后续ELK等日志系统进行聚合查询
log.info("处理业务逻辑, Trace ID: {}", traceId);

return "请求已处理, Trace ID: " + traceId;
}
}

解释:
getTraceInfo 方法获取一个可选的 X-Trace-Id 请求头。我们可以在日志中记录它,这对于问题排查至关重要。

@CookieValue 注解是 Spring 框架中用于获取 HTTP 请求中 Cookie 值的便捷工具。

场景一:用户认证

在传统的会话管理中,用户的会话ID(Session ID)通常存储在 Cookie 中。通过 @CookieValue 注解,可以轻松获取用户的会话信息。

代码示例:

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

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AuthController {

@GetMapping("/auth/info")
public String getUserInfo(@CookieValue("session-id") String sessionId) {
// 实际业务中,会根据 sessionId 从 Redis 或其他存储中获取用户信息
String userInfo = getUserInfoFromSession(sessionId);
return "获取到用户信息: " + userInfo;
}

private String getUserInfoFromSession(String sessionId) {
// 模拟从会话存储中获取用户信息
return "User_" + sessionId.substring(0, 6);
}
}

解释:
getUserInfo 方法通过 @CookieValue("session-id") 注解获取用户的会话 ID,并根据会话 ID 获取用户信息。

场景二:语言偏好设置

在多语言应用中,通常会将用户的语言偏好(如 en-US, zh-CN)存储在 Cookie 中。

代码示例:

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

import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class LanguageController {

@GetMapping("/language")
public String getLanguagePreference(
@CookieValue(value = "language", defaultValue = "zh-CN") String language) {
return "您当前的语言偏好是: " + language;
}
}

解释:
getLanguagePreference 方法通过 @CookieValue("language") 注解获取用户的语言偏好,并优雅地使用了默认值 "zh-CN"

3.2.3. @PathVariable: 路径变量回顾

最后,我们再次回顾一个已经熟练使用的注解——@PathVariable,以形成完整的知识体系。它专门用于从 URL 路径中提取动态片段。

回顾代码
文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (回顾)

1
2
3
4
5
6
7
8
9
10
// ...
@Operation(summary = "根据ID查询单个用户")
@GetMapping("/{id}")
public ResponseEntity<Result<UserVO>> getUserById(
@Parameter(description = "用户ID", required = true, example = "1")
@PathVariable Long id // @PathVariable 从路径 /users/{id} 中提取 id
) {
// ...
}
// ...

总结:参数绑定的位置
至此,我们已经掌握了从 HTTP 请求不同位置获取数据的核心注解:

  • @PathVariable: 从 URL 路径 (/users/{id}) 中获取。
  • @RequestParam: 从 URL 查询参数 (?name=value) 中获取。
  • @RequestHeader: 从 请求头 (Headers) 中获取。
  • @CookieValue: 从 Cookie 中获取。
  • @RequestBody: 从 请求体 (Request Body) 中获取。

3.3. 解构请求体:@RequestBody 与 Jackson 定制

在第二章,我们已经成功地使用 @RequestBody 将前端传来的 JSON 数据自动绑定到了 UserSaveDTO 上。这个过程之所以能自动完成,是因为 Spring Boot 默认集成的 Jackson 库在背后默默地承担了“反序列化”(JSON -> Java 对象)的工作。

然而,在真实的业务场景中,我们经常会遇到前端约定的 JSON 格式与后端 Java 对象的属性不完全一致的情况。例如,字段命名风格不同(下划线 vs. 驼峰)、日期格式需要特殊处理、某些字段需要被忽略等。本节,我们就将深入学习如何通过 Jackson 提供的注解,来精确地定制 JSON 与 Java 对象之间的相互转换,进一步优化我们的用户管理 API。

3.3.1. 需求升级:定制 JSON 字段与格式

现在,我们的项目收到了来自前端团队的两个新需求:

  1. 命名风格统一:前端团队习惯使用下划线命名法 (snake_case),他们希望所有 API 交互的 JSON 字段都遵循此规范。例如,Java 中的 username 属性,在 JSON 中应该显示为 user_name
  2. 日期格式化:我们需要为用户添加一个创建时间 createTime 字段。在查询用户时,需要将这个 LocalDateTime 类型的字段格式化为 yyyy-MM-dd HH:mm:ss 的标准字符串格式返回给前端。
  3. 安全增强:在任何情况下,用户的 password 字段都绝对不能出现在返回给前端的 JSON 数据中。

3.3.2. 改造实践:在 VO 与 DTO 中应用 Jackson 注解

1. 更新数据库与实体类

首先,我们需要为 t_user 表添加 create_time 字段。请在您的数据库中执行以下 SQL 语句:

1
2
ALTER TABLE `t_user`
ADD COLUMN `create_time` datetime NULL COMMENT '创建时间' AFTER `status`;

接着,更新 User 实体类。

文件路径: src/main/java/com/example/springbootdemo/entity/User.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.springbootdemo.entity;

// ... imports ...
import java.time.LocalDateTime;

@Data
@TableName("t_user")
public class User {
// ... 其他字段 ...
private Integer status;
private LocalDateTime createTime; // 新增创建时间字段
}

2. 定制 VO (View Object)

现在,我们来改造 UserVO,以满足前端的输出格式需求。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.vo;

import cn.hutool.core.annotation.Alias;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.time.LocalDateTime;

@Data
@JsonInclude(JsonInclude.Include.NON_NULL) // 序列化时,值为 null 的字段将被忽略
public class UserVO {

private Long id;

@JsonProperty("user_name") // 将 name 属性在 JSON 中映射为 user_name
@Alias("username") // HuTool用于转换的别名,在2.1章节中我们使用过 他和我们的实体类是对应的
private String name;

private String statusText;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 将 LocalDateTime 格式化为指定样式
private LocalDateTime createTime;
}

3. 定制 DTO (Data Transfer Object)

同样,我们也需要改造 UserSaveDTO,以正确接收前端传递的输入数据

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo.dto.User;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", required = true, example = "newuser")
@JsonProperty("user_name") // 接收前端传来的 user_name 字段,并映射到 username 属性
private String username;

@Schema(description = "密码", required = true, example = "123456")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
private String email;
}

4. 更新 Service 层

最后,我们需要在 Service 层中处理 createTime 字段的赋值和转换。

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
// ... imports ...
import java.time.LocalDateTime;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

// ... findUserById 和 findAllUsers 方法保持不变 ...

@Override
public Long saveUser(UserSaveDTO dto) {
// ... 已有的业务校验 ...
User user = Convert.convert(User.class, dto);
user.setStatus(1);
user.setCreateTime(LocalDateTime.now()); // 设置创建时间

userMapper.insert(user);
return user.getId();
}

private UserVO convertToVO(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
BeanUtil.copyProperties(user, userVO, "username");
userVO.setName(user.getUsername());
if (user.getStatus() != null) {
userVO.setStatusText(user.getStatus() == 1 ? "正常" : "已禁用");
}
// 拷贝 createTime 属性
userVO.setCreateTime(user.getCreateTime());
// 注意,我们已经在Hutool中使用过了别名注解,所以这里不需要对于username进行转换
return userVO;
}

// ... updateUser 和 deleteUserById 方法保持不变 ...
}

3.3.3. 核心技术:Jackson 核心注解详解

我们刚刚在实战中使用了几个强大的 Jackson 注解,现在来系统性地总结一下:

注解作用常用场景
@JsonProperty在 Java 属性和 JSON 字段之间建立双向映射关系。解决 Java(驼峰)与 JSON(下划线)的命名不一致问题。
@JsonFormat序列化时,将日期时间类型格式化为指定的字符串样式。LocalDateTime 格式化为 yyyy-MM-dd HH:mm:ss
@JsonIgnore在序列化和反序列化时,完全忽略某个属性。防止密码等敏感信息泄露到前端。
@JsonInclude序列化时,可以指定包含属性的条件,最常用的是 NON_NULL忽略值为 null 的字段,精简 API 响应体。

3.3.4. 回归测试:验证定制效果

重启应用并访问 http://localhost:8080/swagger-ui.html

测试新增接口 (POST)

  1. 在 Swagger UI 中,展开 POST /users 接口。
  2. 验证:您会发现 Request body 的 Schema 示例中,字段名已经变成了 user_name
  3. 使用 { "user_name": "jackson_user", "password": "123", "email": "jackson@test.com" } 作为请求体执行请求。
  4. 请求会成功,证明我们的后端已能正确接收 user_name 字段。

测试查询接口 (GET)

  1. 在 Swagger UI 中,执行 GET /users/{id},查询我们刚刚新增的记录。
  2. 验证:您会看到响应的 JSON 中,createTime 字段被格式化为了 "2025-08-17 10:30:00",由于我们之前的Vo对象并不期望

3.4. 数据校验:Validation API 最佳实践

目前,我们的新增(saveUser)和修改(updateUser)接口存在一个严重的安全隐患:我们对前端传来的数据完全信任。这会导致数据库中出现大量的“垃圾数据”,甚至引发程序异常。

本节,我们将学习如何通过 Jakarta Bean Validation API 和 Spring 的 @Validated 注解,实现声明式的、自动化的参数校验。

3.4.1. 关键一步:引入 Validation Starter

要使校验注解生效,我们必须首先在 pom.xml 中显式地添加 spring-boot-starter-validation 依赖。

文件路径: pom.xml (修改)

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

3.4.2. 改造实践:为 DTO 添加 Validation 注解

现在,我们为 DTO 的字段添加上具体的校验规则。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserSaveDTO.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
package com.example.springbootdemo.dto.User;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
@Schema(description = "用户新增数据传输对象")
public class UserSaveDTO {

@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "newuser")
@JsonProperty("user_name")
@NotBlank(message = "用户名不能为空")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
@Email(message = "邮箱格式不正确")
private String email;
}

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserUpdateDTO.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.springbootdemo.dto.User;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

@Data
@Schema(description = "用户修改数据传输对象")
public class UserUpdateDTO {

@Schema(description = "用户ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "用户ID不能为空")
private Long id;

@Schema(description = "邮箱", example = "new_email@example.com")
@Email(message = "邮箱格式不正确")
private String email;

// ... 其他需要校验的字段 ...
}

3.4.3. 核心技术:在 Controller 中使用 @Validated 激活校验

仅仅在 DTO 中添加注解还不够,我们还需要在 Controller 中明确地开启校验。

  1. UserController 上添加 @Validated 注解。
  2. 在需要校验的 @RequestBody 参数前,同样使用 @Validated 注解。

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

// ... other imports ...
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated // 1. 在类上添加 @Validated 注解
public class UserController {

private final UserService userService;

// ... 已有的 GET 接口 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(@Validated @RequestBody UserSaveDTO dto) {
Long userId = userService.saveUser(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(@Validated @RequestBody UserUpdateDTO dto) {
userService.updateUser(dto);
return ResponseEntity.ok(Result.success());
}

// ... 已有的 DELETE 接口 ...
}

3.4.4. 回归测试:验证校验效果

重启应用并访问 http://localhost:8080/swagger-ui.html

  1. 展开 POST /users 接口,点击 “Try it out”
  2. 在请求体中输入用户名为空格的非法数据:
    1
    2
    3
    4
    5
    {
    "user_name": " ",
    "password": "password123",
    "email": "swagger@example.com"
    }
  3. 点击 “Execute”

预期结果
这一次,请求会被成功拦截,您会看到服务器返回了一个 400 Bad Request 错误,响应体中包含了详细的、由 Spring Boot 默认格式化的校验失败信息

虽然校验成功了,但这个默认的错误响应格式并不清晰,对前端并不友好。在 第四章,我们将学习如何通过全局异常处理来捕获这类 MethodArgumentNotValidException 异常,并返回我们自定义的、结构统一的 Result 错误信息,从而完美解决这个问题。


3.4.5. 进阶:分组校验与 @Validated

痛点:我们当前的校验有一个潜在问题。@Validated 会触发 DTO 内所有它能找到的校验注解。但如果未来我们的 UserSaveDTOUserUpdateDTO 中有同名字段,但校验规则却略有不同呢?或者,我们想创建一个包含所有字段的 UserDTO,然后根据是“新增”还是“修改”场景,来执行不同的校验规则,应该怎么做?

解决方案:使用 @Validated 注解独有的分组校验功能

定义校验分组接口:
文件路径: src/main/java/com/example/springbootdemo/validation/ValidationGroups.java(新增文件)

1
2
3
4
5
package com.example.springbootdemo.validation;
public interface ValidationGroups {
interface Save {}
interface Update {}
}

我们将通过一次代码重构,来真正体验分组校验的强大之处。我们的目标是:废弃 UserSaveDTOUserUpdateDTO,只用一个 UserEditDTO 来同时服务于新增和修改两个场景。

1. 创建统一的 UserEditDTO

这个新的 DTO 将包含新增和修改所需的所有字段,并通过 groups 属性为每个字段的校验规则打上“场景标签”。

文件路径: src/main/java/com/example/springbootdemo/dto/User/UserEditDTO.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
package com.example.springbootdemo.dto.User;

import com.example.springbootdemo.validation.ValidationGroups;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
@Schema(description = "用户编辑(新增/修改)数据传输对象")
public class UserEditDTO {

@Schema(description = "用户ID,修改时必填", example = "1")
@NotNull(message = "用户ID不能为空", groups = ValidationGroups.Update.class)
private Long id;

@Schema(description = "用户名,新增时必填", example = "newuser")
@JsonProperty("user_name")
@NotBlank(message = "用户名不能为空", groups = ValidationGroups.Save.class)
private String username;

@Schema(description = "密码,新增时必填,修改时可选", example = "123456")
@NotBlank(message = "密码不能为空", groups = ValidationGroups.Save.class)
@Size(min = 6, max = 20, message = "密码长度必须在6-20位之间", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class})
private String password;

@Schema(description = "邮箱", example = "newuser@example.com")
@Email(message = "邮箱格式不正确", groups = {ValidationGroups.Save.class, ValidationGroups.Update.class})
private String email;
}

注解解析:

  • @NotNull(groups = ValidationGroups.Update.class): id 字段只在 Update 这个场景下才校验非空。
  • @NotBlank(groups = ValidationGroups.Save.class): usernamepassword 字段只在 Save 这个场景下才校验非空。
  • @Size(groups = {Save.class, Update.class}): 密码长度的校验,在 SaveUpdate 两种场景下都会生效(前提是 password 字段不为 null)。

2. 重构 Service 层

现在,我们修改 UserService 接口和实现类,让它们都使用这个新的 UserEditDTO

文件路径: src/main/java/com/example/springbootdemo/service/UserService.java (修改)

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

import com.example.springbootdemo.dto.User.UserEditDTO; // 修改导入
import com.example.springbootdemo.dto.User.UserPageQuery;
import com.example.springbootdemo.vo.UserVO;
import java.util.List;

public interface UserService {
// ...
Long saveUser(UserEditDTO dto); // 修改参数类型
void updateUser(UserEditDTO dto); // 修改参数类型
void deleteUserById(Long id);
}

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ... imports ...
import com.example.springbootdemo.dto.User.UserEditDTO;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
@Override
public Long saveUser(UserEditDTO dto) { // 修改参数类型
}
@Override
public void updateUser(UserEditDTO dto) { // 修改参数类型
}
// ...
}

3. 重构 Controller 层 (见证奇迹)

最后,我们来修改 UserController

文件路径: src/main/java/com/example/springbootdemo/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
package com.example.springbootdemo.controller;

import com.example.springbootdemo.dto.User.UserEditDTO;
// ...
import com.example.springbootdemo.validation.ValidationGroups;
// ...

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserController {

private final UserService userService;

// ... GET 接口不变 ...

@Operation(summary = "新增用户")
@PostMapping
public ResponseEntity<Result<Long>> saveUser(
@Validated(ValidationGroups.Save.class) @RequestBody UserEditDTO dto) {
Long userId = userService.saveUser(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(Result.success(userId));
}

@Operation(summary = "修改用户信息")
@PutMapping
public ResponseEntity<Result<Void>> updateUser(
@Validated(ValidationGroups.Update.class) @RequestBody UserEditDTO dto) {
userService.updateUser(dto);
return ResponseEntity.ok(Result.success());
}

// ... DELETE 接口不变 ...
}

4. 清理与验证

现在,您可以安全地删除 UserSaveDTO.javaUserUpdateDTO.java 这两个文件了。

重启应用并访问 Swagger UI:

  • 测试新增 (POST /users):
    • 如果您不提供 user_namepassword,请求将被 400 Bad Request 拦截。
    • 如果您提供了 id,它会被忽略。
  • 测试修改 (PUT /users):
    • 如果您不提供 id,请求将被 400 Bad Request 拦截。
    • 您可以不提供 password,只修改 email,请求会成功。
    • 如果您提供了 user_name,它会被忽略(因为 DTO 到 PO 的转换不会处理这个字段)。

这才是分组校验的真正威力! 我们通过一个 UserEditDTO,结合 @Validated 注解中不同的分组,实现了对“新增”和“修改”两个不同业务场景的精准校验,极大地提升了代码的复用性和可维护性。


4. [高级特性] 全局处理与特殊场景

摘要: 一个健壮的 API 不仅要能正确处理成功的情况,更要能优雅地应对各种异常。在本章,我们将为项目引入全局异常处理机制,解决之前章节中遗留的“错误响应不统一”的问题。同时,我们还会处理前后端分离架构中常见的跨域(CORS)问题,并为项目增加文件上传下载这一实用的高级功能。

4.1. 全局异常处理:@RestControllerAdvice

4.1.1. 痛点回顾与核心技术

在之前的章节中,我们的 API 在遇到错误时,会暴露两个典型的问题:

  • 业务异常返回 500:当我们在 Service 层检测到“用户名已存在”并抛出 IllegalArgumentException 时,前端收到的是一个笼统的 500 Internal Server Error,这既不准确,也没有清晰地告诉前端失败的原因。
  • 参数校验返回默认格式:当 @Validated 校验失败时,前端收到的虽然是 400 Bad Request,但其 JSON 结构是 Spring Boot 默认的,与我们精心设计的 Result<T> 格式完全不符。

这两个问题都指向了同一个需求:我们需要一个全局的、统一的机制来捕获所有 Controller 抛出的异常,并按照我们自己的 Result<T> 格式,将它们转换为对前端友好的、标准化的响应。

Spring MVC 为此提供了一套极其优雅的组合拳:@RestControllerAdvice@ExceptionHandler

  • @RestControllerAdvice: 将一个类声明为全局控制器增强器,它会“监听”所有 @RestController 中抛出的异常。
  • @ExceptionHandler: 在 @RestControllerAdvice 类中的方法上使用,声明该方法是用于处理特定类型的异常。

4.1.2. 实战:创建全局异常处理器

我们将创建一个 GlobalExceptionHandler,并在其中一步步地添加针对不同异常的处理逻辑。

1. 捕获自定义业务异常

首先,我们来处理像“用户名已存在”这类由我们的业务逻辑主动抛出的异常。

第一步:创建自定义业务异常
一个良好的实践是定义一个自己的 BusinessException,用于封装所有业务层面的错误。

文件路径: src/main/java/com/example/springbootdemo/exception/BusinessException.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.exception;

import com.example.springbootdemo.common.ResultCode;
import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

private final ResultCode resultCode;

public BusinessException(ResultCode resultCode) {
super(resultCode.getMessage());
this.resultCode = resultCode;
}
}

第二步:重构 Service 层以抛出新异常
修改 UserServiceImpl,当用户名已存在时,抛出我们自定义的 BusinessException

文件路径: src/main/java/com/example/springbootdemo/service/impl/UserServiceImpl.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
// ... imports ...
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.exception.BusinessException;
import com.example.springbootdemo.entity.User;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;


@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
public Long saveUser(UserEditDTO dto) {
// 业务校验:检查用户名是否已存在
User existingUser = userMapper.selectOne(new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername()));
if (existingUser != null) {
// 如果用户已存在,抛出自定义业务异常
throw new BusinessException(ResultCode.UserAlreadyExists);
}
// ... 后续代码不变 ...
}


@Override
public void updateUser(UserEditDTO dto) {
// 1. 业务校验:断言要修改的用户必须存在
User user = userMapper.selectById(dto.getId());
if (user == null) {
throw new BusinessException(ResultCode.UserNotFound);
}

}


// ... 其他方法 ...
}

第三步:创建全局异常处理器并添加业务异常处理逻辑

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.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
package com.example.springbootdemo.advice;

import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

/**
* 专门处理我们自定义的 BusinessException
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<Result<Void>> handleBusinessException(BusinessException ex) {
log.error("业务异常: {}", ex.getMessage(), ex);
// 对于业务异常,我们通常认为这是客户端的“错误”请求,所以返回 400 Bad Request
// 响应体中包含我们预定义的业务错误码和消息
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.error(ex.getResultCode()));
}
}

2. 美化参数校验异常

接下来,我们在同一个处理器中,增加对 @Validated 校验失败异常的处理。

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.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
// ... imports ...
import org.springframework.web.bind.MethodArgumentNotValidException;
import java.util.stream.Collectors;


@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

// ... 已有的 handleBusinessException 方法 ...

/**
* 专门处理由 @Validated 注解触发的参数校验异常
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Result<Void>> handleValidationException(MethodArgumentNotValidException ex) {
// 从异常中获取所有校验失败的信息,并拼接成一个字符串
String message = ex.getBindingResult().getFieldErrors().stream()
.map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
.collect(Collectors.joining("; "));

log.warn("参数校验失败: {}", message);

return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.error(message));
}
}

3. 捕获未知系统异常

最后,我们需要一个“兜底”方案,来处理所有未预料到的服务器内部错误。

文件路径: src/main/java/com/example/springbootdemo/advice/GlobalExceptionHandler.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ... imports ...

@Slf4j
@RestControllerAdvice(basePackages = "com.example.springbootdemo.controller")
public class GlobalExceptionHandler {

// ... 已有的两个 ExceptionHandler ...

/**
* 兜底处理所有其他未被捕获的异常
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<Result<Void>> handleUnknownException(Exception ex) {
log.error("系统未知异常: {}", ex.getMessage(), ex);
// 对于未知的系统异常,我们返回 500 Internal Server Error
// 并使用一个通用的、模糊的错误提示,避免泄露服务器内部细节
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Result.error("系统异常,请联系管理员"));
}
}

4.1.3. 回归测试:验证统一错误响应

重启应用,并使用 SpringDoc 重新测试我们之前遇到的所有错误场景。

测试用例 1:新增已存在的用户

  • 操作: 调用 POST /users 接口,Request body 中使用一个已存在的用户名。
  • 验证: 响应码为 400 Bad Request,响应体为:
    1
    2
    3
    4
    5
    {
    "code": 1002,
    "message": "用户已存在",
    "data": null
    }

测试用例 2:新增用户时参数校验失败

  • 操作: 调用 POST /users 接口,Request bodyuser_name 字段为空字符串。
  • 验证: 响应码为 400 Bad Request,响应体为:
    1
    2
    3
    4
    5
    {
    "code": 500,
    "message": "username: 用户名不能为空",
    "data": null
    }

通过创建 GlobalExceptionHandler,我们成功地将所有异常处理逻辑集中到了一个地方。现在,无论我们的 API 遇到业务异常、参数校验异常还是未知的系统异常,都能向前端返回统一、规范、友好的 Result 响应。这极大地提升了 API 的健壮性和专业性。


4.2. 跨域配置:CORS (Cross-Origin Resource Sharing)

随着我们的后端 API 功能日益完善,前端同事已经准备好对接我们的接口了。然而,当他们在自己的开发环境(例如 http://localhost:5173)中尝试调用我们部署在 http://localhost:8080 上的 API 时,浏览器的控制台无情地报出了一个经典错误:

1
2
Access to fetch at 'http://localhost:8080/users' from origin 
'http://localhost:5173' has been blocked by CORS policy...

这就是前后端分离开发中几乎必然会遇到的“拦路虎”——跨域问题

4.2.1. 理论:浏览器的同源策略与 CORS 工作原理

1. 同源策略

这是浏览器的一个核心安全机制。它规定,一个源(origin)的网页脚本,只能访问与其同源的资源,而不能访问不同源的资源。

什么是“源”?
一个源由协议 (protocol)域名 (domain)端口 (port) 三者共同定义。只有当这三者完全相同时,两个 URL 才被认为是同源的。

URL 1URL 2是否同源原因
http://example.com/page.htmlhttp://example.com/api/data协议、域名、端口(默认80)都相同
http://example.comhttps://example.com协议不同 (http vs https)
http://www.example.comhttp://api.example.com域名不同 (www vs api)
http://example.comhttp://example.com:8080端口不同 (默认80 vs 8080)

在我们的场景中,前端应用运行在 http://localhost:5173,而后端 API 运行在 http://localhost:8080,因为端口不同,所以它们是不同源的。因此,浏览器出于安全考虑,默认禁止了这次请求。

2. CORS (跨域资源共享)

CORS 是一种 W3C 标准,它允许服务器在响应头中添加一些特殊的 Access-Control-* 字段,从而“告诉”浏览器,我允许来自指定不同源的请求访问我的资源。

当浏览器发起一个跨域的“非简单请求”(例如,PUT, DELETE,或者带有自定义请求头的 POST)时,它会自动先发送一个 OPTIONS 方法的预检请求 到服务器。服务器需要在响应中明确告知浏览器,它允许哪些源、哪些 HTTP 方法、哪些请求头进行跨域访问。浏览器验证通过后,才会发送真正的业务请求。


4.2.2. 实践:通过 WebMvcConfigurer 全局配置 CORS

虽然我们可以在每个 @RestController 或每个 @RequestMapping 上使用 @CrossOrigin 注解来单独开启跨域,但这会导致配置分散,难以维护。最佳实践是进行全局 CORS 配置

我们将创建一个 WebConfig 配置类,通过实现 WebMvcConfigurer 接口来一站式地解决整个应用的跨域问题。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (新增文件)

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

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 对所有请求路径生效
.allowedOrigins("http://localhost:5173") // 允许来自指定源的请求
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的方法
.allowCredentials(true) // 允许发送 Cookie
.maxAge(3600); // 预检请求的有效期,单位为秒
}
}

代码解析:

  • addMapping("/**"): /** 表示此 CORS 配置将应用于我们应用中的所有 API 接口。
  • .allowedOrigins("http://localhost:5173"): 这是最核心的配置,它明确地告诉浏览器,我只允许来自 http://localhost:5173 这个源的跨域请求。在生产环境中,您应该将其替换为您的前端应用的实际域名。
  • .allowedMethods(...): 允许跨域的 HTTP 方法列表。
  • .allowCredentials(true): 是否允许客户端在跨域请求中携带凭证信息(如 Cookies)。
  • .maxAge(3600): 设置预检请求(Preflight Request)的缓存时间,单位为秒。在此时间内,浏览器对相同的跨域请求将不再发送预检请求。

4.2.3. 前端验证:构建 Vue3 应用测试 CORS

为了验证我们后端的 CORS 配置是否成功,我们将快速搭建一个基于 Vite + Vue 3 的前端应用,并使用 axios 库来尝试调用我们部署在 8080 端口的 /users 接口。

1. 环境与项目创建

在开始之前,请确保您的开发环境满足以下要求:

  • Node.js: 16.0 或更高版本
  • 包管理器: 本教程使用 pnpm

如果尚未安装 pnpm,可以通过 npm install -g pnpm 命令进行全局安装。

现在,我们来创建前端项目:

1
2
3
4
5
6
7
8
# 1. 使用 Vite 创建一个新的 Vue 3 项目
pnpm create vite cors-test-app --template vue

# 2. 进入项目目录
cd cors-test-app

# 3. 安装项目依赖
pnpm install

2. 安装核心依赖

我们需要为项目添加 Tailwind CSS, DaisyUI 用于美化界面,以及 axios 用于发起 HTTP 请求。

1
2
3
4
5
6
7
8
# 1. 安装 Tailwind CSS 及其 Vite 插件
pnpm add -D tailwindcss @tailwindcss/vite

# 2. 安装 DaisyUI
pnpm add -D daisyui

# 3. 安装 axios
pnpm add axios

3. 项目配置

第一步:配置 Vite (vite.config.js)
编辑项目根目录下的 vite.config.js 文件,引入 Tailwind CSS 插件。

文件路径: cors-test-app/vite.config.js (修改)

1
2
3
4
5
6
7
8
9
10
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite' // 导入插件

export default defineConfig({
plugins: [
vue(),
tailwindcss(), // 使用插件
],
})

第二步:创建并引入 CSS (style.css & main.js)
src 目录下创建 style.css 文件。

文件路径: cors-test-app/src/style.css (新增文件)

1
2
@import "tailwindcss";
@plugin "daisyui";

然后,在 main.js 中引入这个样式文件。

文件路径: cors-test-app/src/main.js (修改)

1
2
3
4
5
import { createApp } from 'vue'
import App from './App.vue'
import './style.css' // 确保引入了样式文件

createApp(App).mount('#app')

4. 编写接口调用代码

现在,我们来修改 App.vue,添加一个按钮,点击后调用后端的 /users 接口并展示数据。

文件路径: cors-test-app/src/App.vue (修改)

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<script setup>
import { ref } from 'vue'
import axios from 'axios'

const users = ref([])
const loading = ref(false)
const error = ref(null)

const fetchUsers = async () => {
loading.value = true
error.value = null
users.value = []
try {
// 调用我们部署在 8080 端口的后端 API
const response = await axios.get('http://localhost:8080/users?pageNo=1&pageSize=5')

users.value = response.data.data
} catch (err) {
console.error('API 调用失败:', err)
error.value = '获取用户数据失败!请检查浏览器控制台(F12)以确认是否为 CORS 错误。'
} finally {
loading.value = false
}
}
</script>

<template>
<div class="container mx-auto p-8">
<h1 class="text-3xl font-bold mb-6">后端 CORS 配置验证</h1>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="card-actions justify-start">
<button class="btn btn-primary" @click="fetchUsers" :disabled="loading">
<span v-if="loading" class="loading loading-spinner"></span>
{{ loading ? '正在加载...' : '获取用户列表 (GET /users)' }}
</button>
</div>

<div v-if="error" class="alert alert-error mt-4">
<span>{{ error }}</span>
</div>

<div v-if="users.length > 0" class="overflow-x-auto mt-4">
<table class="table w-full table-zebra">
<thead>
<tr>
<th>ID</th>
<th>用户名 (user_name)</th>
<th>状态</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<th>{{ user.id }}</th>
<td>{{ user.user_name }}</td>
<td>{{ user.statusText }}</td>
<td>{{ user.createTime }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>

5. 运行与验证

  1. 确保您的 Spring Boot 后端应用正在运行
  2. 在前端项目 cors-test-app 的根目录下,执行启动命令:
    1
    pnpm dev
  3. Vite 通常会启动一个运行在 5173 端口的开发服务器。请在浏览器中打开它提供的地址(如 http://localhost:5173)。
  4. 点击页面上的 “获取用户列表” 按钮。

验证结果:

  • 成功: 如果您能看到用户列表被成功加载并显示在表格中,那么恭喜您,后端的全局跨域配置已完全生效!
  • 失败: 如果您看到红色的错误提示,并且在浏览器控制台(按 F12 打开)中看到了关于 CORS policy 的错误信息,请回到 4.2.2 节检查您的后端 WebConfig.java 配置是否正确,并确保前端应用的运行端口(5173)与后端配置中 .allowedOrigins() 的值一致。

4.3. 文件处理:上传、下载与静态资源访问

4.3.1. 后端:配置与静态资源映射

1. 添加配置 (YAML 格式)

首先,我们在 application.yml 中启用文件上传并定义存储目录。

文件路径: src/main/resources/application.yml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
# ... spring.datasource ...

# Servlet Multipart Settings
servlet:
multipart:
enabled: true
max-file-size: 2MB
max-request-size: 10MB

# 自定义属性
app:
upload:
dir: D:/springboot-uploads/ # 文件最终存储物理路径

请将 app.upload.dir 的值修改为您计算机上一个真实存在的、用于存放上传文件的目录路径,并确保路径以 / 结尾。

2. 配置静态资源映射

接下来,我们配置一个 URL 路径(如 /springboot-uploads/**)直接映射到我们的物理存储目录,这是最高效的文件预览方式。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.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.springbootdemo.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Value("${app.upload.dir}")
private String uploadDir;

@Override
public void addCorsMappings(CorsRegistry registry) {
// ... 已有的 CORS 配置 ...
}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
/**
* 配置静态资源映射
* addResourceHandler("/springboot-uploads/**"): 对外暴露的访问路径
* addResourceLocations("file:" + uploadDir): 文件存放的物理路径
*/
registry.addResourceHandler("/springboot-uploads/**")
.addResourceLocations("file:" + uploadDir);
}
}

4.3.2. 后端:FileController 功能实现

FileController 将负责三个核心功能:处理上传、提供文件列表、处理强制下载请求。

文件路径: src/main/java/com/example/springbootdemo/controller/FileController.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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package com.example.springbootdemo.controller;


import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.example.springbootdemo.common.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

@Tag(name = "文件管理", description = "提供文件上传下载接口")
@RestController
@RequestMapping("/files")
public class FileController {
@Value("${app.upload.dir}")
private String uploadDir;

@Operation(summary = "文件上传")
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
return Result.error("文件名不能为空");
}
// 1.获取文件的原始文件名
String originalFilename = file.getOriginalFilename();
// 2.获取文件的扩展名 例如:.jpg
String extName = FileUtil.extName(originalFilename);
// 3.生成一个随机ID
String uniqueId = IdUtil.fastSimpleUUID();
// 4.拼接新文件名 id.jpg
String newFileName = uniqueId + "." + extName;
// 5.创建一个文件对象 读取到我们预设置的文件夹中
File uploadPath = new File(uploadDir);
if (!uploadPath.exists()) {
// 如果文件夹不存在,创建文件夹
uploadPath.mkdir();
}
// 6.将我们的文件对象写入到我们预设置的文件夹中
File destFile = new File(uploadPath, newFileName);
// 7.将文件转移到我们预设置的文件夹中
file.transferTo(destFile);
return Result.success(newFileName);
}

@Operation(summary = "获取文件列表")
@GetMapping("/list")
public Result<List<String>> listFiles() {
File uploadDirFile = new File(uploadDir);
File[] files = uploadDirFile.listFiles();
if (files == null) {
return Result.success(Collections.emptyList());
}
List<String> filenames = Arrays.stream(files).map(File::getName).toList();
return Result.success(filenames);
}
@Operation(summary = "文件下载(强制附件)")
@GetMapping("/download/{filename}")
public ResponseEntity<Resource> download(@PathVariable String filename) throws IOException {
// 构建文件路径
File file = new File(uploadDir, filename);

// 检查文件是否存在
if (!file.exists()) {
return ResponseEntity.notFound().build();
}

// 创建文件资源
Resource resource = new FileSystemResource(file);

// 设置响应头
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"");
headers.add(HttpHeaders.CONTENT_TYPE, "application/octet-stream");

return ResponseEntity.ok()
.headers(headers)
.body(resource);
}

}

4.3.3. 前端联动:组件化实现文件管理

现在我们来构建前端界面,将所有功能整合在一起。

1. 文件上传组件 (FileUpload.vue)

文件路径: cors-test-app/src/components/FileUpload.vue (新增文件)

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
53
54
55
56
57
58
59
60
61
62
63
64
<script setup>
import { ref } from 'vue'
import axios from 'axios'
const selectedFile = ref(null)
const uploadResult = ref(null)
const isUploading = ref(false)

// 定义组件可以发出的事件
const emit = defineEmits(['upload-success'])


const handleFileChange = (event) => {
selectedFile.value = event.target.files[0]
uploadResult.value = null
}

const handleUpload = async () => {
if (!selectedFile.value) {
alert('请选择要上传的文件')
return
}

isUploading.value = true

// FormData 对象:用于将文件和其他数据打包成一个整体,以便发送给后端
const formData = new FormData()
formData.append('file', selectedFile.value)

try {
const response = await axios.post('http://localhost:8080/files/upload', formData)
uploadResult.value = response.data

if (response.data.code === 200) {
emit('upload-success', response.data.data)
}
} catch (error) {
console.error('上传失败:', error)
} finally {
isUploading.value = false
}
}



</script>


<template>
<div class="card bg-base-100 shadow-xl mt-8">
<div class="card-body">
<h2 class="card-title">文件上传</h2>
<div class="form-control w-full max-w-xs">
<input type="file" @change="handleFileChange" class="file-input file-input-bordered w-full max-w-xs" />
</div>
<div class="card-actions justify-start mt-4">
<button class="btn btn-secondary" @click="handleUpload" :disabled="isUploading">
<span v-if="isUploading" class="loading loading-spinner"></span>
{{ isUploading ? '正在上传...' : '上传文件' }}
</button>
</div>
</div>
</div>
</template>

2. 文件列表组件 (FileList.vue)

文件路径: cors-test-app/src/components/FileList.vue (新增文件)

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const fileList = ref([])
const isLoading = ref(false)

// 获取文件列表
const fetchFileList = async () => {
isLoading.value = true

try {
const response = await axios.get('http://localhost:8080/files/list')
if (response.data.code === 200) {
fileList.value = response.data.data || []
console.log(fileList.value)
}
} catch (err) {
console.error('获取文件列表失败:', err)
} finally {
isLoading.value = false
}
}

// 下载文件
const downloadFile = (filename) => {
const downloadUrl = `http://localhost:8080/files/download/${filename}`
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}

// 组件挂载时获取文件列表
onMounted(() => {
fetchFileList()
})

// 暴露刷新方法给父组件
defineExpose({
refresh: fetchFileList
})
</script>

<template>
<div class="card bg-base-100 shadow-xl mt-8">
<div class="card-body">
<h2 class="card-title mb-4">文件列表</h2>

<!-- 加载状态 -->
<div v-if="isLoading" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>

<!-- 文件列表 -->
<div v-else-if="fileList.length > 0" class="space-y-2">
<div v-for="filename in fileList" :key="filename"
class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="font-medium">{{ filename }}</div>
<button class="btn btn-primary btn-sm" @click="downloadFile(filename)">
下载
</button>
</div>
</div>

<!-- 空状态 -->
<div v-else class="text-center py-8 text-base-content/60">
<div class="text-4xl mb-2">📁</div>
<div>暂无文件</div>
</div>
</div>
</div>
</template>

3. 主应用重构 (App.vue)

App.vue 负责组合这两个组件,并实现它们之间的联动。

文件路径: cors-test-app/src/App.vue (修改)

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
<script setup>
import { ref } from 'vue'
import FileUpload from './components/FileUpload.vue'
import FileList from './components/FileList.vue'

const fileListRef = ref(null)

const handleUploadSuccess = (filename) => {
console.log('父组件收到上传成功事件,文件名:', filename)
// 上传成功后刷新文件列表
if (fileListRef.value) {
fileListRef.value.refresh()
}
}
</script>

<template>
<div class="container mx-auto p-8">
<h1 class="text-3xl font-bold mb-6 text-center">文件管理系统</h1>

<!-- 文件上传组件 -->
<FileUpload @upload-success="handleUploadSuccess" />

<!-- 文件列表组件 -->
<FileList ref="fileListRef" />
</div>
</template>

运行验证

  1. 重启 后端 Spring Boot 应用和前端 Vite 应用。
  2. 验证:
    • 页面现在显示文件上传和文件列表两个模块。
    • 文件列表会自动加载 D:/springboot-uploads/ 目录下的所有文件。
    • 点击“预览”可以在新标签页打开图片(由 WebConfig 的静态资源映射处理)。
    • 点击“下载”会直接下载文件(由 FileController/download 接口处理)。
    • 联动测试:上传一个新文件,成功后,文件列表会自动刷新并显示出刚刚上传的文件。

功能闭环:通过后端 Controller 逻辑、WebConfig 静态资源映射和前端组件化的协同工作,我们实现了一套功能完整、体验良好的文件管理功能,并深刻理解了不同场景下的文件处理策略。


5. [幕后探秘] HttpMessageConverter 工作原理

摘要: 在前面的实战中,我们已经看到了 @RequestBody@ResponseBody 的强大威力,它们似乎能“魔法般”地在 JSON 和 Java 对象之间进行自动转换。本章,我们将扮演一次侦探,深入 Spring MVC 的内部,揭开这个“魔法”的秘密——彻底搞懂其背后真正的功臣 HttpMessageConverter (HTTP 消息转换器) 的核心工作原理。


5.1. 核心面试题:揭秘 @RequestBody 与 @ResponseBody

在面试中,HttpMessageConverter 是一个非常高频的考点,因为它直接关系到 Spring MVC 的核心工作原理。本节,我们将通过一场模拟面试,来彻底揭开它神秘的面纱。

面试官深度剖析:HttpMessageConverter
今天 上午 10:00

在 Spring MVC 项目中,我们经常在 Controller 方法的参数上使用 @RequestBody,或者在类上使用 @RestController。当一个 JSON 请求过来,或者一个方法返回一个 Java 对象时,Spring MVC 究竟在背后做了什么,能实现这种自动转换?

面试官您好。这个自动化转换的核心,在于 Spring MVC 的 HttpMessageConverter (HTTP 消息转换器) 机制。它是一个策略接口,专门负责在 HTTP 请求体/响应体和 Java 对象之间进行序列化与反序列化。

很好。那你能具体讲讲这个 HttpMessageConverter 接口吗?它通过什么方法来判断自己是否能处理一个请求,又是通过什么方法来执行转换的?

当然。HttpMessageConverter 接口主要通过两对核心方法来工作:

第一对是 canRead()canWrite(),用于能力检测。Spring MVC 会用它们来询问转换器能否处理特定的 Java 类型(如 UserVO.class)和媒体类型(如 application/json)。

第二对是 read()write(),用于执行转换。一旦能力检测通过,就会调用这两个方法进行真正的读写操作。read() 负责将请求体转换为 Java 对象(@RequestBody 的工作),write() 负责将 Java 对象转换为响应体(@ResponseBody 的工作)。

明白了。那在一个典型的 Spring Boot Web 应用中,都注册了哪些常见的 HttpMessageConverter 实现呢?我们最常用的 JSON 转换是由哪个来完成的?

Spring Boot 会根据类路径上的依赖,自动配置一个转换器“家族”。其中最重要的几个包括:

StringHttpMessageConverter: 负责处理纯 String 类型的转换,比如我们第一章的 HelloController

FormHttpMessageConverter: 负责处理 application/x-www-form-urlencoded 类型的表单数据。

MappingJackson2HttpMessageConverter: 这是我们的绝对主力。只要项目中存在 Jackson 库,它就会被注册,并负责所有 application/json 相关的转换。我们项目中所有的 DTO/VO 与 JSON 之间的转换,都是它的功劳。

此外,还有处理二进制数据的 ByteArrayHttpMessageConverter 等,在我们第四章的文件下载功能中,它就扮演了重要角色。

非常清晰。最后一个问题:当一个请求进来,比如请求头 Acceptapplication/json,而 Controller 方法返回了一个 UserVO 对象,Spring MVC 是如何从这么多转换器中,精确地选择 MappingJackson2HttpMessageConverter 来工作的呢?

这是一个很好的问题,它涉及到了 Spring MVC 的内容协商 (Content Negotiation) 机制。简单来说,Spring MVC 会遍历所有已注册的转换器,结合客户端请求的 Accept 头和 Controller 方法返回的 Java 对象类型,调用每个转换器的 canWrite() 方法进行“招标”。在这个场景下,MappingJackson2HttpMessageConverter 会“中标”,于是框架就最终调用它的 write() 方法来完成工作。关于这个流程的更多细节,我们可以在下一节深入探讨。


5.2 工作流程:内容协商

在上一节的模拟面试中,我们提到了一个关键机制——内容协商。正是这个机制,使得 Spring MVC 能够智能地从众多 HttpMessageConverter 中,挑选出最合适的一个来处理当前的请求。

“协商”一词非常形象,它指的是客户端(浏览器、App等)和服务器端(我们的 Spring MVC 应用)之间,就资源的表现形式(即数据格式,如 JSON、XML、HTML 等)进行“商议”的过程。

1. 客户端的“诉求”:AcceptContent-Type 请求头

客户端通过两个核心的 HTTP 请求头来表达自己的“诉求”:

  • Accept: 用于响应协商。它告诉服务器:“我能理解以下几种格式的数据,请你按照这个列表的优先级,返回我能处理的一种。”
    • 例如: Accept: application/json, application/xml;q=0.9, */*;q=0.8
    • 这表示:我最希望得到 json 格式的数据。如果不行,xml 也勉强可以(权重q=0.9)。如果前两者都没有,那就随便给我一种你有的格式吧(权重q=0.8)。
  • Content-Type: 用于请求协商。它告诉服务器:“我这次发送给你的请求体,是这种格式的数据,请你按照这个格式来解析。”
    • 例如: Content-Type: application/json
    • 这表示:我用 @RequestBody 发送过来的数据是一个 JSON 字符串。

2. 服务器的“决策”:HttpMessageConverter 的选择流程

现在,我们以一个完整的响应过程为例,详细拆解 Spring MVC 的内部决策流程。

mermaid-diagram-2025-08-18-113035

场景: 客户端发起 GET /users/1 请求,其请求头中包含 Accept: application/json

  1. 请求路由: DispatcherServlet 接收到请求,并通过 HandlerMapping 找到 UserControllergetUserById 方法。
  2. 方法执行: getUserById 方法被调用,执行完毕后,返回了一个 UserVO 对象。
  3. 触发内容协商: 因为 UserController@RestController 注解(包含了 @ResponseBody),Spring MVC 知道需要将这个 UserVO 对象写入 HTTP 响应体。此时,内容协商机制正式启动。
  4. 遍历转换器: Spring MVC 获取到内部注册的所有 HttpMessageConverter 实例列表(这个列表是有优先级的,通常 MappingJackson2HttpMessageConverter 优先级较高)。
  5. “能力”检测 (canWrite): Spring MVC 开始从头到尾遍历这个列表,对每一个转换器进行“垂询”:
    • 问询 ByteArrayHttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它一看,自己只处理 byte[],处理不了 UserVO,于是返回 false
    • 问询 StringHttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它一看,自己只处理 String,处理不了 UserVO,也返回 false
    • 问询 MappingJackson2HttpMessageConverter: 调用其 canWrite(UserVO.class, MediaType.APPLICATION_JSON) 方法。它内部逻辑判断:“首先,我能处理任意的 POJO(UserVO 满足);其次,我支持 application/json 这个媒体类型。太棒了!” 于是,它返回 true
  6. 锁定并执行: Spring MVC 找到了第一个“中标”的转换器——MappingJackson2HttpMessageConverter,于是停止遍历。它立即调用该转换器的 write() 方法,将 UserVO 对象和 MediaType 传入。write() 方法内部再调用 Jackson 库,将 UserVO 对象序列化为 JSON 字符串,并写入 HTTP 响应的输出流。
  7. 响应完成: 最终,客户端收到了一个 Content-Typeapplication/json、响应体为用户 JSON 数据的 HTTP 响应。

对于 @RequestBody 的处理流程也是完全类似的,只不过 Spring MVC 依据的是请求头中的 Content-Type,并调用转换器的 canRead() 和 read() 方法。


6. [API安全] 拦截器与 Token 认证

摘要: 到目前为止,我们的 API 功能已经非常完备,但它正处于“不设防”的状态,任何人都可以随意调用所有接口。这在真实世界中是绝对不可接受的。本章,我们将聚焦于 API 的核心安全问题,引入 Spring MVC 强大的**拦截器(Interceptor)**机制,并结合 JWT (JSON Web Token) 这一现代化的认证方案,为我们的 API 构建一套专业、无状态的用户认证和权限校验体系。

6.1. 核心技术:HandlerInterceptor 详解

1. 痛点:重复的通用逻辑

随着项目发展,我们可能会遇到一些需要对多个接口生效的通用需求,例如:

  • 权限校验:某些接口(如修改、删除用户)必须在用户登录后才能调用。
  • 日志记录:需要记录每个接口的请求路径、执行耗时等信息,用于监控和性能分析。
  • 通用处理:为所有请求注入一些通用的上下文信息。

我们当然不希望在每个 Controller 的每个方法里都重复编写这些逻辑,这会造成大量的代码冗余。我们需要一种能够在请求处理流程中“切入”的机制,这正是 Spring MVC 提供的拦截器 (Interceptor)

拦截器是一种强大的 AOP (面向切面编程) 的体现,它允许我们在请求进入 Controller 方法之前、方法执行之后以及整个请求处理完毕之后这三个关键节点,执行我们自定义的通用逻辑。

2. HandlerInterceptor 接口

要创建一个拦截器,我们需要实现 org.springframework.web.servlet.HandlerInterceptor 接口。这个接口定义了三个核心方法(在 Java 8 之后,它们都是 default 方法,我们只需按需重写即可):

方法 (Method)执行时机核心作用
preHandleController 方法执行请求拦截。返回 false 可中断请求。
postHandleController 方法执行,视图渲染修改响应。可以修改 ModelAndView。(API开发中较少使用)
afterCompletion整个请求处理完毕资源清理。无论是否发生异常都会执行。

3. 实战:创建并注册一个日志拦截器

为了直观地感受拦截器的工作流程,我们先来创建一个简单的日志拦截器,用于计算并打印每个请求的处理耗时。

第一步:创建拦截器实现类

文件路径: src/main/java/com/example/springbootdemo/interceptor/LogInterceptor.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
package com.example.springbootdemo.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Slf4j
@Component
public class LogInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 记录请求开始时间
long startTime = System.currentTimeMillis();
request.setAttribute("startTime", startTime);

log.info("开始处理请求: {} {}", request.getMethod(), request.getRequestURI());

// 返回 true 表示继续执行后续的拦截器和 Controller
return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 从请求中获取开始时间
long startTime = (Long) request.getAttribute("startTime");
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;

log.info("请求处理完毕: {} {}, 耗时: {}ms", request.getMethod(), request.getRequestURI(), duration);
}
}

第二步:注册拦截器
我们需要在 WebConfig 中,将我们创建的拦截器注册到 Spring MVC 的拦截器链中。

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.springbootdemo.config;

import com.example.springbootdemo.interceptor.LogInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

private final LogInterceptor logInterceptor; // 通过构造函数注入

// ... 已有的 addResourceHandlers 和 addCorsMappings 方法 ...

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor) // 注册我们编写的日志拦截器
.addPathPatterns("/**"); // 指定拦截所有路径
}
}

代码解析:

  • addInterceptor(logInterceptor): 将我们的 LogInterceptor Bean 注册到拦截器注册表中。
  • .addPathPatterns("/**"): 指定这个拦截器要拦截的 URL 模式。/** 是一个通配符,表示拦截所有进入应用的请求。与之相对的,还有一个 .excludePathPatterns("/login") 方法,用于指定需要排除的路径。

重启应用,并使用 SpringDoc 或 cURL 调用任意一个 /users 接口(例如 GET /users/1)。然后观察您的应用控制台日志

1
2
3
... INFO com.e.s.interceptor.LogInterceptor  : 开始处理请求: GET /users/1
... (省略 Controller 和 Service 的日志)
... INFO com.e.s.interceptor.LogInterceptor : 请求处理完毕: GET /users/1, 耗时: 18ms

这证明我们的拦截器已经成功地“切入”了请求处理流程。

通过这个简单的日志拦截器,我们已经掌握了拦截器的基本创建和注册流程。preHandle 方法的 boolean 返回值是实现权限校验的关键。在下一节,我们将利用这一点,结合 JWT 技术,来构建一个真正的用户认证拦截器。


6.2. 实战:实现基于 JWT 的 Token 认证

现在我们知道了拦截器是进行权限校验的“关卡”,那么下一个问题就是:我们用什么作为“通行凭证”呢?

6.2.1. 认证方案选择:为什么是 JWT?

在传统的 Web 应用中,我们常用 Session-Cookie 机制来管理用户状态。但它在现代前后端分离、分布式、移动优先的架构下,暴露了一些弊端:

  • 服务端状态化:服务器需要为每个登录用户维护一份 Session 数据,当在线用户量巨大时,会消耗大量内存。
  • 扩展性差:Session 数据默认存储在单台服务器上。在多台服务器做负载均衡时,需要额外处理 Session 共享问题(如使用 Sticky Session 或 Session 复制),增加了架构复杂度。
  • 跨域与移动端不友好:基于 Cookie 的 Session 机制在跨域场景和非浏览器客户端(如手机 App)上处理起来较为棘手。

为了解决这些问题,基于 Token 的无状态认证 方案应运而生,而 JWT (JSON Web Token) 是其中最主流、最优秀的事实标准。

JWT 的核心思想:服务器在用户登录成功后,不再保存任何 Session 信息,而是根据用户信息生成一个加密签名的、自包含的字符串(即 Token),返还给客户端。客户端在后续的每次请求中,都需要在请求头里携带这个 Token。服务器收到请求后,只需验证 Token 签名的合法性,即可确认用户的身份,无需查询数据库或任何会话存储。

特性Session-Cookie (传统方案)JWT Token (现代方案)
状态有状态 (Stateful)无状态 (Stateless)
存储服务端存储 Session客户端存储 Token
扩展性差,依赖 Session 共享,天然支持分布式部署
适用性仅限浏览器通用,浏览器、App、小程序均适用

6.2.2. 完整的认证授权流程

一个标准的 Token 认证流程包含以下三个步骤,我们必须在脑海中建立起这个闭环:

客户端(前端)使用用户名和密码调用登录接口 (/auth/login)。

服务器验证身份成功后,使用 Hutool-JWT 生成一个包含用户信息的 Token,并将其返回给客户端。

客户端将获取到的 Token 存储起来(例如,在 localStorage 中)。在后续访问所有受保护的接口(如 /users)时,必须在 HTTP 请求头 Authorization 中携带这个 Token,格式通常为 Bearer <token>

服务器端的拦截器会捕获每一个请求,检查 Authorization 头是否存在且 Token 是否合法有效。

如果 Token 有效,则放行请求至 Controller;如果 Token 无效或不存在,则拦截请求并返回 401 Unauthorized 错误。


6.3. 实战:登录接口与 Token 校验

6.3.1. 准备工作:添加 JWT 配置

文件路径: src/main/resources/application.yml (修改)

1
2
3
4
5
6
7
# ... 已有配置 ...
app:
# ...
# JWT Settings
jwt:
# 签名密钥,必须足够复杂
secret: your-super-strong-and-long-secret-key-for-hs256-jwt

安全警告: JWT 的安全性完全依赖于密钥的保密性。在生产环境中,绝对不能 将密钥硬编码在配置文件中。最佳实践是将其配置在环境变量或专门的密钥管理服务中。

文件路径: com/example/springbootdemo/dto/auth/LoginDTO.java (新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.springbootdemo.dto.auth;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
@Schema(description = "登录数据传输对象")
public class LoginDTO {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")
private String username;

@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
}

6.3.2. 实现登录接口 (签发 Token)

我们将重构 AuthController,使用 Hutool 的 JWTUtil 来生成 Token。

文件路径: src/main/java/com/example/springbootdemo/controller/AuthController.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
package com.example.springbootdemo.controller;

import cn.hutool.jwt.JWTUtil;
import com.example.springbootdemo.common.Result;
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.dto.auth.LoginDTO;
import com.example.springbootdemo.exception.BusinessException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;

@Tag(name = "认证管理", description = "提供用户登录认证接口")
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Validated
public class AuthController {
@Value("${app.jwt.secret}")
private String jwtSecret;

@Operation(summary = "用户登录")
@PostMapping("/login")
public ResponseEntity<Result<String>> login(@Validated @RequestBody LoginDTO loginDTO) {
// 模拟验证用户名密码
if ("admin".equals(loginDTO.getUsername()) && "123456".equals(loginDTO.getPassword())) {
// 使用 Hutool 创建 Token
HashMap<String, Object> payload = new HashMap<>();
// 在 payload 中放入基本信息
payload.put("username", loginDTO.getUsername());
// 可以在此放入用户角色、权限等信息
String token = JWTUtil.createToken(payload, jwtSecret.getBytes());
return ResponseEntity.ok(
Result.success(token)
);

} else {
throw new BusinessException(ResultCode.UNAUTHORIZED);
}
}
}

6.3.3. 编写 Token 校验拦截器

现在我们来创建真正的“门卫”——AuthInterceptor

文件路径: src/main/java/com/example/springbootdemo/interceptor/AuthInterceptor.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
package com.example.springbootdemo.interceptor;

import cn.hutool.core.util.StrUtil;
import cn.hutool.jwt.JWTValidator;
import com.example.springbootdemo.common.ResultCode;
import com.example.springbootdemo.exception.BusinessException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
@Value("${app.jwt.secret}")
private String jwtSecret;

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization");
try {
if (StrUtil.isBlank(token) || !token.startsWith("Bearer ")) {
throw new BusinessException(ResultCode.UNAUTHORIZED);
}
// 固定截取 Bearer 后面的字符
token = token.substring(7);
// 2. 使用 Hutool 验证 Token 中的载荷(例如:过期时间)
// validate 方法会检查 iat, exp, nbf 等时间戳
JWTValidator.of(token).validateDate();
} catch (Exception e) {
// 捕获所有可能的异常,统一返回错误
throw new BusinessException(ResultCode.ERROR);
}
return true;
}
}

6.3.4. 注册拦截器

文件路径: src/main/java/com/example/springbootdemo/config/WebConfig.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
// ... imports ...
import com.example.springbootdemo.interceptor.AuthInterceptor;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

// 通过构造函数注入依赖
private final AuthInterceptor authInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// ... 已有的日志拦截器 ...

// 认证拦截器
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login",
"/files/**",
"/springboot-uploads/**",
"/swagger-ui/**",
"/v3/api-docs/**"
);
}
}

6.4. 联动:配置 SpringDoc 支持 JWT 认证

我们已经创建了登录接口和拦截器,但 Swagger UI 并不知道我们的 /users 接口需要一个 Authorization 请求头。因此,它既不会在接口上显示需要认证的“小锁”图标,也没有提供地方让我们输入 Token。这使得我们无法通过这个便捷的工具来测试受保护的接口。

为了解决这个问题,我们需要通过注解,明确地告诉 SpringDoc 我们项目所采用的认证方案。

1. 定义安全方案 (@SecurityScheme)

首先,我们需要定义一个全局的安全方案,告诉 SpringDoc 我们使用的是基于 HTTP 的 Bearer Token 认证。最佳实践是创建一个专门的配置类来做这件事。

文件路径: src/main/java/com/example/springbootdemo/config/SpringDocConfig.java (新增文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.springbootdemo.config;

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

@Configuration
@SecurityScheme(
name = "bearerAuth", // 这是安全方案的唯一名称,后续将通过此名称引用
type = SecuritySchemeType.HTTP, // 认证类型为 HTTP
scheme = "bearer", // 具体的认证方案为 Bearer, 表示令牌类型
bearerFormat = "JWT" // 提示 Token 的格式为 JWT
)
public class SpringDocConfig {
}

2. 应用安全方案 (@SecurityRequirement)

定义好安全方案后,我们还需要将其应用到需要保护的 Controller 上。

文件路径: src/main/java/com/example/springbootdemo/controller/UserController.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.springbootdemo.controller;

import io.swagger.v3.oas.annotations.security.SecurityRequirement;
// ... other imports

@Tag(name = "用户管理", description = "提供用户相关的CRUD接口")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
@SecurityRequirement(name = "bearerAuth") // 在类上应用名为 bearerAuth 的安全方案
public class UserController {
// ... Controller 内容保持不变 ...
}

通过在 UserController 类上添加 @SecurityRequirement(name = "bearerAuth"),我们告诉 SpringDoc,这个 Controller 下的所有接口都需要使用我们刚刚定义的 bearerAuth 认证方案。当然,这个注解也可以用在单个方法上,以实现更细粒度的控制。


7. [架构重构] 迈向企业级:Maven 多模块项目

摘要: 随着我们功能的不断累加,我们最初的单体项目正变得日益臃肿和混乱。本章,我们将进行一次意义深远的架构重构,借鉴 RuoYi 等企业级框架的设计思想,亲手将我们的项目改造为一个职责清晰、高度解耦的 Maven 多模块工程。这次重构将极大提升项目的可维护性和扩展性,为未来承载更复杂的业务打下坚实的基础。

7.1. 痛点分析与模块化思想

7.1.1. 现状:单体应用的困境

在开始重构之前,我们首先要清醒地认识到当前项目存在的“成长烦恼”。请看我们目前的项目包结构:

虽然我们遵循了 controller, service, mapper 等内部分层,但从整体工程角度看,它依然是一个单体应用。随着项目规模的扩大,这种结构逐渐暴露出一系列问题:

  • 包结构扁平化: advice, common, config, controller, converter, dto, entity… 所有这些包都挤在同一个 src 目录下。当未来我们新增“订单模块”、“产品模块”时,这个列表将变得越来越长,不同业务模块的代码会混杂在一起,难以管理。
  • 依赖关系混乱: 我们的 pom.xml 文件中,既有 spring-boot-starter-web 这样的 Web 框架依赖,也有 mybatis-plus 这样的数据持久化依赖,还有 hutool-all 这样的通用工具依赖。所有依赖都打包在一起,职责不清,我们很难说清楚哪个模块具体需要哪个依赖。
  • 高度耦合: 所有代码都在一个模块中,理论上任何一个类的改动(即使是一个工具类),都可能需要对整个项目进行重新的编译、测试和部署,模块间的边界是模糊的。
  • 复用性差: 如果我们想在另一个新项目中复用当前的 common 包或 util 包下的工具类,除了复制粘贴代码,没有更优雅的方式。

7.1.2. 蓝图:借鉴 RuoYi 的模块化设计

要解决以上问题,我们需要引入软件工程领域一个非常重要的思想——模块化,而在 Maven 项目中,实现模块化的最佳实践就是构建**多模块项目 **。

我们将借鉴 RuoYi 这类成熟企业级框架的设计精髓,将我们的单体应用“分而治之”,拆分为以下四个核心模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
📂 spring-boot-demo (父模块, pom)
├── 📄 pom.xml (统一管理版本和模块)

├── 📂 demo-common (通用工具模块, jar)
│ └── ... (存放 Result, BusinessException, JwtUtil 等)

├── 📂 demo-framework (框架核心模块, jar)
│ └── ... (存放 WebConfig, MybatisPlusConfig, GlobalExceptionHandler, 拦截器等)

├── 📂 demo-system (系统业务模块, jar)
│ └── ... (存放所有用户管理相关的 Controller, Service, Mapper, DTO, VO 等)

└── 📂 demo-admin (启动与部署模块, jar)
└── ... (仅存放 SpringBootDemoApplication.java 启动类)

各模块核心职责

模块核心职责详细说明
demo-common通用工具与核心定义存放与具体业务无关的、可被所有其他模块复用的公共类,如 Result 封装、自定义异常、Hutool 依赖、枚举、常量等。它是一个高度抽象的底层工具包。
demo-framework框架核心配置与增强存放与 Spring 框架本身相关的配置和增强功能,如 WebMvcConfigurer (CORS, 拦截器)、MybatisPlusConfigSpringDocConfig、全局异常处理器等。它为业务模块提供技术支撑。
demo-system具体业务模块存放具体业务模块的代码。目前我们只有一个“用户管理”功能,所以所有 User 相关的 Controller, Service, Mapper, DTO, VO 都会放在这里。未来新增“订单模块”时,我们会创建一个新的 demo-order 业务模块。
demo-admin应用启动入口这是一个非常轻量的“胶水”模块。它本身几乎没有代码,只包含 main 启动类。它的主要作用是通过 Maven 依赖,将 frameworksystem 等模块组装起来,最终打包成一个可运行的 Spring Boot 应用。

通过这样的拆分,我们的项目结构将变得异常清晰。每个模块都有明确的边界和单一的职责,实现了物理层面的解耦。这为我们后续的开发、测试和维护工作,奠定了坚实的企业级工程基础。


7.2. 实战:父工程改造与依赖管理

理论学习完毕,我们现在正式开始对项目进行“大手术”。第一步,也是最关键的一步,就是将我们当前的 spring-boot-demo 项目,从一个可运行的 jar 工程,改造为一个只负责管理、不包含任何代码的 pom 父工程

7.2.1. 修改 packaging 为 pom

请打开项目根目录下的 pom.xml 文件。我们需要做的第一件事,就是将 <packaging> 标签的内容,从默认的 jar 修改为 pom

文件路径: pom.xml (修改)

1
2
3
4
5
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<name>spring-boot-demo</name>

这个小小的改动,从根本上改变了 pom.xml 的性质。它告诉 Maven:“我不再是一个需要被打成 jar 包的普通应用了,我的新身份是一个父工程,我的职责是管理我的子模块们”。

修改后,您可能会发现项目中的 src 目录在 IDE 中变成了灰色或普通文件夹图标,这是完全正常的,因为 pom 类型的父工程本身不包含业务代码。

7.2.2. 统一版本:使用 <properties><dependencyManagement>

目前,我们所有的依赖都直接写在 <dependencies> 标签下,版本号散落在各处。当项目模块变多时,这会导致版本混乱和难以升级。专业的做法是使用 <dependencyManagement> 在父工程中进行统一的版本声明。

我们将进行两步操作:

  1. 使用 <properties> 标签将所有版本号提取为变量。
  2. 将整个 <dependencies> 块移动到 <dependencyManagement> 块内部。

文件路径: pom.xml (修改)

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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.4</version>
</parent>

<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-boot-demo</name>

<!-- 1. 关键修改:将打包方式改为 pom -->
<packaging>pom</packaging>

<modules>
<module>demo-common</module>
<module>demo-framework</module>
<module>demo-system</module>
<module>demo-admin</module>
</modules>

<properties>
<java.version>17</java.version>
<lombok.version>1.18.32</lombok.version>
<hutool.version>5.8.27</hutool.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<springdoc.version>2.7.0</springdoc.version>
<jjwt.version>0.11.5</jjwt.version> <!-- 建议也统一管理 jjwt 版本 -->
</properties>

<!-- 3. 最佳实践:使用 dependencyManagement 统一管理版本,不强制引入 -->
<dependencyManagement>
<dependencies>
<!-- 内部模块依赖 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-framework</artifactId>
<version>${project.version}</version>
</dependency>

<!-- 第三方库依赖 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<!-- 4. 最佳实践:移除父POM中的 build 插件,它应该在可执行的子模块中 -->
<!-- <build> ... </build> -->

</project>

代码解析

  • <properties>: 我们将所有第三方库的版本号都提取到了这里,便于未来统一升级。
  • <dependencyManagement>: 这就像一个“依赖版本仲裁中心”。在这里声明的依赖,并不会被实际引入,它只是一个“版本清单”。
  • 清空 <dependencies>: 父 pom 作为一个管理者,它本身不应该包含任何具体的实现代码,因此也不需要直接依赖任何 jar 包。

经过改造后,所有子模块未来在引入这些依赖时,只需要提供 groupIdartifactId无需再指定 version,它们会自动继承父工程中声明的版本。这保证了整个项目所有模块使用的依赖版本是高度统一的。

最后,父工程需要知道它到底管理了哪些子模块。我们在 pom.xml 的顶层(与 <properties> 同级)添加 <modules> 标签,并在其中声明我们规划好的四个子模块。


7.3. 实战:拆分核心模块

父工程的改造完成后,现在就如同我们已经画好了建筑蓝图。接下来的任务,就是按照蓝图,一砖一瓦地搭建起每个独立的模块(房间),并将我们现有的代码(家具)搬运到它们各自正确的位置。

在 IDEA 中,您可以通过右键点击项目根目录 -> New -> Module... 来创建新的 Maven 子模块。请确保在创建时,它能被正确识别为当前父工程的子模块。

7.3.1. 创建并迁移 demo-common (通用工具模块)

这个模块是我们的基础工具库,存放与具体业务无关的、可被所有其他模块复用的公共代码。

第一步:创建模块与 pom.xml

首先,在项目根目录下创建 demo-common 文件夹,并在其中创建 pom.xml 文件。

文件路径: demo-common/pom.xml (修改文件)

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>demo-common</artifactId>

<dependencies>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
</dependency>
</dependencies>

</project>

注意:在子模块的 pom.xml 中,我们引入依赖时无需再指定 <version><groupId>,因为它会从父工程的 <dependencyManagement> 中自动继承,这正是父工程的价值所在!

第二步:迁移代码

现在,我们将主项目中所有通用的代码包,整体移动到 demo-common 模块的 src/main/java/ 目录下。

迁移清单:

  • common 包 (包含 Result.java, ResultCode.java)
  • enums 包 (包含 UserStatusEnum.java)
  • exception 包 (包含 BusinessException.java)
  • util 包 (目前没有文件)

7.3.2. 创建并迁移 demo-framework (框架核心模块)

这个模块负责所有与 Spring 框架相关的配置和增强功能。

第一步:创建模块与 pom.xml

文件路径: demo-framework/pom.xml (修改文件)

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>demo-framework</artifactId>

<dependencies>
<!-- 依赖通用模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-common</artifactId>
<!--
版本号可以省略,Maven会从parent中继承。
写上 ${project.version} 也可以,效果相同。
-->
</dependency>

<!-- Spring Boot Web 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- MyBatis-Plus 数据库操作 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>

<!-- SpringDoc API文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>

<!-- Spring Boot 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>

第二步:迁移代码

迁移清单:

  • advice 包 (包含 GlobalExceptionHandler.java)
  • config 包 (包含 MybatisPlusConfig.java, SpringDocConfig.java, WebConfig.java)
  • converter 包 (包含 StringToUserStatusEnumConverter.java)
  • interceptor 包 (包含 AuthInterceptor.java, LogInterceptor.java)

7.3.3. 创建并迁移 demo-system (系统业务模块)

这个模块是我们的核心业务模块,存放所有与“用户管理”相关的代码。

最理想的结构是让 demo-system 依赖 demo-framework,而不是重复声明 spring-boot-starter-web、mybatis-plus 等依赖。这样可以形成清晰的依赖链:admin -> system -> framework -> common

第一步:创建模块与 pom.xml

文件路径: demo-system/pom.xml (新增文件)

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>demo-system</artifactId>

<dependencies>
<!-- 依赖框架层,它已经包含了 Web, MyBatis-Plus, SpringDoc 等所有基础能力 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-framework</artifactId>
</dependency>

<!-- 仅保留测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>

第二步:迁移代码

迁移清单:

  • controller 包 (所有 Controller)
  • dto 包 (所有 DTO)
  • entity 包 (所有 Entity)
  • mapper 包 (所有 Mapper)
  • service 包 (所有 Service 及其 impl)
  • validation 包 (所有校验分组)
  • vo 包 (所有 VO)

在修复代码之前,必须先让项目能正确加载。

  1. 打开根目录下的 pom.xml (spring-boot-demo/pom.xml)。
  2. 找到 <modules> 标签。
  3. 注释或删除那一行 <module>demo-admin</module>,因为这个模块还不存在。
  4. 在 IDEA 右侧的 Maven 面板中,点击刷新按钮,确保项目能无错误地加载。

步骤 1: 执行全局搜索和替换

现在,我们要把所有旧的包引用 com.example.springbootdemo.* 替换成新的包名。

  1. 按下快捷键 Ctrl + Shift + R (Windows/Linux) 或 Cmd + Shift + R (Mac)。这将打开全局搜索和替换窗口。

  2. 执行第一次替换

    • 在上面的输入框 (Find) 中,输入旧的包前缀: com.example.springbootdemo

    • 在下面的输入框 (Replace) 中,输入新的包前缀: com.example

  • 重要: 仔细检查下面的预览窗口,确保替换是你想要的结果。

    • 点击 “Replace All” 或 “Replace” 按钮。

因为我们的新包结构是 com.example.democommon, com.example.demosystem 等,而不是 com.example.springbootdemo.common。原来的所有 DTO, VO, Service 等都引用了 com.example.springbootdemo 下的类。

建议开启自动导包,对于之前结构的包都进行删除并全部自动导入的操作

img

步骤 2: 优化 Imports

替换完成后,可能还有一些多余或无效的 import 语句。

  1. 在项目根目录上右键。
  2. 选择 “Optimize Imports”。IDEA 会自动清理所有文件中未使用的导入。
  3. 你也可以使用快捷键 Ctrl + Alt + O (Windows/Linux) 或 Cmd + Option + O (Mac) 在单个文件中操作。

7.4. 实战:创建 Admin 启动模块

7.4.1. 创建 demo-admin 模块与 pom.xml

首先,请按照同样的方式,创建第四个子模块:demo-admin

这个模块的 pom.xml 非常关键,它不直接依赖具体的第三方库,而是依赖于我们自己创建的 frameworksystem 模块,像胶水一样将它们粘合在一起。同时,它需要 spring-boot-maven-plugin 插件来将整个应用打包成一个可执行的 jar

文件路径: demo-admin/pom.xml (新增文件)

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>spring-boot-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>demo-admin</artifactId>

<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>demo-framework</artifactId>
<!-- 添加版本号 -->
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>com.example</groupId>
<artifactId>demo-system</artifactId>
<!-- 添加版本号 -->
<version>${project.version}</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

7.4.2. 迁移并改造启动类

现在,我们将父工程根目录下那个“光杆司令” SpringBootDemoApplication.java 移动到 demo-admin 模块中。

同时,我们需要对它进行一个小小的改造,以确保 Spring Boot 能够扫描到我们所有子模块中的组件(Bean)。

文件路径: demo-admin/src/main/java/com/example/springbootdemo/SpringBootDemoApplication.java (迁移并修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.example.demoadmin;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
* @SpringBootApplication 注解默认扫描的是当前类所在的包及其子包。
* 由于我们的 Bean (如 Controller, Service, Config 等)现在分布在
* com.example.demoframework, com.example.demosystem 等不同的包中,
* 我们需要通过 scanBasePackages 属性,将扫描的根路径扩大到 com.example,
* 从而覆盖所有子模块。
*/
@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
public class SpringBootDemoApplication {

public static void main(String[] args) {
SpringApplication.run(SpringBootDemoApplication.class, args);
}

}

7.4.3. 清理项目

重要信息: 记得将配置文件搬迁到 demo-admin

为了让我们的项目结构达到最终的完美形态,请执行以下清理操作:

  1. 删除父工程根目录下的 src 文件夹: 因为 SpringBootDemoApplication.java 已经被移动到了 demo-admin 模块,父工程根目录下的 src 文件夹现在是多余的,可以安全删除
  2. 删除子模块中多余的启动类: 您在 demo-common, demo-frameworkdemo-system 中由 IDE 自动生成的 Demo...Application.java...Tests.java 文件是不需要的,也请一并删除,因为整个应用只有一个启动入口和一套集成的测试环境。

7.4.4. 回归测试:验证重构结果

现在,整个项目的结构已经焕然一新。请确保您在 IDE 中选择的启动目标是 demo-admin 模块中的 SpringBootDemoApplication.java

为了不影响读者的阅读和可能读者操作失误,这里提供一个整理好的仓库供读者快速Clone Spring_Mvc_Study: 教学用的SpringMVC文件

启动应用后,请打开 Swagger UI 或 Postman,尝试调用一个我们之前写好的接口,例如 GET /users/1

如果接口能够正常返回数据,那么——

恭喜您! 您已经成功地将一个单体应用,重构为了一个结构清晰、职责分明、高度解耦的企业级多模块项目!这次重构的成功,为您未来驾驭大型复杂项目奠定了坚实的工程基础。