Java微服务(三):3.0 SpringBoot - 为简化开发而生


1. [SpringBoot 启航] 快速入门与核心原理

摘要: 本章将引导您迈出 Spring Boot 开发的第一步。我们将使用官方脚手架快速搭建并运行一个 Web 服务,理解其便捷部署的原理,并深入剖析 Spring Boot 两大核心特性——启动器 (Starter)自动配置 (Auto-configuration)

1.1. Spring Boot 快速部署

在开始动手之前,我们先花一分钟了解现代 Web 应用的主流架构模式,这将帮助我们理解为何 Spring Boot 的设计如此契合当下的开发需求。

1.1.1. 现代 Web 架构:前后端分离简介

现代 Web 开发大多采用 前后端分离 的模式。在这种架构下,前端(通常由 Vue, React, Angular 等框架构建)和后端(我们的 Spring Boot 应用)是两个独立的项目。

  • 后端 (Backend): 不再负责渲染 HTML 页面。它的核心职责是提供一套 API 接口(通常是 RESTful API),处理业务逻辑,操作数据库,并以统一的数据格式(如 JSON)返回结果。
  • 前端 (Frontend): 负责用户界面的展示和交互。它通过调用后端的 API 接口获取数据,然后将数据动态地渲染到页面上。

这种模式带来了职责清晰、开发高效、技术栈灵活等诸多好处。我们的教程将完全基于这种前后端分离的模式进行。


1.1.2. [实践] 使用 Spring Initializr 创建第一个 Web 应用

现在,我们来实现第一个需求:创建一个 Web 服务,当用户访问 http://localhost:8080/hello 时,页面显示 “Hello, Spring Boot!”

创建 Spring Boot 项目最快、最标准的方式是使用官方的 Spring Initializr (俗称“脚手架”)。

1. 使用 Idea 生成项目

  • 配置选项:
    • Project: Maven
    • Language: Java
    • Spring Boot: 3.3.3 (或更高稳定版)
    • Project Metadata:
      • Group: com.example
      • Artifact: spring-boot-demo
      • Packaging: Jar
      • Java: 17 (Spring Boot 3.x 要求 JDK 17+)
    • Dependencies: 点击 “ADD DEPENDENCIES…”,搜索并添加 Spring Web

配置完成后,点击 “GENERATE” 按钮,下载项目压缩包并用 IDEA 打开。

image-20250814091918843

2. 剖析项目结构与 pom.xml

IDEA 加载完成后,您会看到一个标准的 Maven 项目结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
. 📂 spring-boot-demo
├── 📄 pom.xml <- 核心依赖管理
└── 📂 src
├── 📂 main
│ ├── 📂 java
│ │ └── 📂 com/example/springbootdemo/
│ │ └── 📄 SpringBootDemoApplication.java <- 启动类
│ └── 📂 resources
│ └── 📄 application.properties <- 核心配置文件
└── 📂 test
└── 📂 java
└── 📂 com/example/springbootdemo/
└── 📄 SpringBootDemoApplicationTests.java

我们重点关注 pom.xml 中的两个核心配置:

文件路径: spring-boot-demo/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
<?xml version="1.0" encoding="UTF-8"?>
<project ...>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.3</version>
<relativePath/>
</parent>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

1.1.3. [实践] 编写 @RestController 并运行服务

1. 编写 Controller

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

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

/**
* @RestController 是一个组合注解,相当于 @Controller + @ResponseBody
* @Controller: 声明这是一个处理 HTTP 请求的控制器 Bean
* @ResponseBody: 告诉 Spring MVC,这个类中所有方法的返回值都应直接写入 HTTP 响应体中
* (对于前后端分离项目,通常就是返回 JSON 或纯文本)
*/
@RestController
public class HelloController {

/**
* @GetMapping("/hello"): 将此方法映射到处理 GET 方式的 "/hello" 请求
*/
@GetMapping("/hello")
public String sayHello() {
return "Hello, Spring Boot!";
}
}

重要: 我们自己创建的类(如 Controller, Service)必须放在 启动类 SpringBootDemoApplication.java 所在的包或其子包 下,才能被 Spring Boot 默认的组件扫描机制发现。

2. 运行与验证

现在,找到 SpringBootDemoApplication.java,直接右键运行其 main 方法。

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

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringBootDemoApplication {
public static void main(String[] args) {
// 启动 Spring Boot 应用
SpringApplication.run(SpringBootDemoApplication.class, args);
}
}

当控制台输出 Tomcat started on port(s): 8080 (http) 时,表示服务已成功启动。

打开浏览器,访问 http://localhost: 8080/hello。


1.2. 便捷的部署与执行

Spring Boot 极大地简化了应用的部署过程,其核心就是 可执行 JAR 包

1.2.1. [实践] 打包为可执行 Fat JAR

在 IDEA 的 Maven 窗口中,执行 package 命令。

打包成功后,在 target 目录下会生成一个 spring-boot-demo-0.0.1-SNAPSHOT.jar 文件。将这个 JAR 包复制到 任何安装了 Java 17+ 环境 的机器上,在命令行中执行:

1
java -jar spring-boot-demo-0.0.1-SNAPSHOT.jar
1
2
3
4
5
6
7
8
9
10
11
12
  .   ____          _            __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v3.3.3)

... (省略日志)
...
2025-08-14T21:30:00.123Z INFO 12345 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
...

服务再次启动!这证明了 Spring Boot 应用的 可移植性,无需外部的 Tomcat 服务器。


1.2.2. Spring Boot JAR 与普通 JAR 的区别

为什么 Spring Boot 的 JAR 包可以直接运行,而普通的 JAR 包不行?

面试官深度辨析
今天 晚上 9:45

Spring Boot 打包的“Fat JAR”和我们平时用的普通 JAR,在结构上有什么本质区别?

求职者

最大的区别在于内容。普通 JAR 只包含我们自己编写的 .class 文件和资源。而 Spring Boot 的 Fat JAR 除了包含我们自己的代码,还内置了三样关键东西:

求职者

1.所有运行时依赖 : 所有的第三方 JAR 包(如 Spring MVC, Tomcat, Jackson)都被打包进来了。

求职者

2.内嵌的 : Tomcat 服务器的 JAR 包也被包含在内。

求职者

3.一个特殊的启动器类 :Spring Boot 提供了一个 JarLauncher 类,它知道如何正确地加载所有内嵌的依赖并启动应用。

非常好。所以,可执行性就是这么来的?

求职者

是的。Fat JAR 的 MANIFEST.MF 文件中,Main-Class 被指向了这个 JarLauncher。所以当我们执行 java -jar 时,实际上是 JarLauncher 在工作,它创建了一个特殊的类加载器来加载 BOOT-INF/lib/ 下的依赖,然后才调用我们自己写的 main 方法。而普通 JAR 没有这个机制。


1.3. 自动配置核心揭秘

我们已经体验到了 Spring Boot 的便捷,现在是时候揭开其背后的魔法了。

1.3.1. [面试题] SpringApplication.run() 背后发生了什么?

面试官深度剖析
昨天 上午 10:00

当我们执行 SpringApplication.run(MyApplication.class, args) 这一行代码时,Spring Boot 在背后都做了哪些核心工作?

求职者

这是一个经典的启动流程问题。概括来说,它主要做了四件大事:

求职者

1.推断应用类型 : Spring Boot 会检查类路径,判断这是一个标准的 Servlet Web 应用,还是一个响应式的 WebFlux 应用,或是一个非 Web 应用。

求职者

2.加载自动配置类 : 这是最核心的一步。它会从所有依赖的 JAR 包中,找到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,加载里面定义的所有自动配置类。

求职者

3.创建并准备IoC容器 : 创建一个 ApplicationContext 实例,然后执行一系列的准备工作,比如加载我们的主配置类 (MyApplication.class),将命令行参数 args 注册为 Bean 等。

求职者

4.刷新容器并启动组件 : “刷新” 容器是 Spring 的一个术语,它会触发所有 Bean 的实例化、依赖注入、初始化等过程。在这个过程中,之前加载的自动配置类会根据条件 (@ConditionalOn...) 判断是否生效。对于 Web 应用,此阶段还会启动内嵌的 Tomcat 服务器。


1.3.2. 魔法的起点:@SpringBootApplication 注解剖析

我们可以在 SpringBootDemoApplication 里面找到这个注解,那么这个注解的作用是什么?

SpringApplication.run() 的所有行为,都源于启动类上的 @SpringBootApplication 注解。它是一个复合注解,由三个核心注解组成:

  • @SpringBootConfiguration:
    • 它本身被 @Configuration 注解,所以 Spring Boot 的主启动类本质上就是一个 Java 配置类。这意味着我们可以在启动类中直接定义 @Bean
  • @EnableAutoConfiguration:
    • 启用自动配置。这是 Spring Boot 最神奇的地方。它会触发上面提到的“加载自动配置类”的流程。
  • @ComponentScan:
    • 启用组件扫描。它告诉 Spring 去哪里查找我们自己定义的 Bean(如 @Component, @Service, @RestController 等)。默认的扫描路径是 启动类所在的包及其所有子包

1.3.3. 按需加载:条件注解 (@ConditionalOn...) 的作用

@EnableAutoConfiguration 虽然加载了所有可能的自动配置类,但并非所有都会生效。每个自动配置类都被一系列的 条件注解 (@ConditionalOn...) 所保护。

工作原理:
Spring Boot 在处理自动配置类时,会检查其上的条件注解。只有当所有条件都满足时,这个配置类才会生效

常用条件注解:

注解作用
@ConditionalOnClass当类路径下 存在 指定的类时,配置生效。 (这是最常用的)
@ConditionalOnMissingClass当类路径下 不存在 指定的类时,配置生效。
@ConditionalOnBean当 IoC 容器中 存在 指定的 Bean 时,配置生效。
@ConditionalOnMissingBean当 IoC 容器中 不存在 指定的 Bean 时,配置生效。 (常用于提供默认 Bean)
@ConditionalOnPropertyapplication.properties存在 指定的配置项时,配置生效。
@ConditionalOnWebApplication仅当当前应用是 Web 应用 时,配置生效。

示例:
WebMvcAutoConfiguration (Spring MVC 的自动配置) 的部分源码:

1
2
3
4
5
6
7
8
// 只有当应用是 Servlet Web 应用,并且类路径下存在 Servlet, DispatcherServlet, WebMvcConfigurer 时,
// 并且容器中还没有用户自定义的 WebMvcConfigurationSupport Bean,此配置才会生效。
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class })
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class)
public class WebMvcAutoConfiguration {
// ...
}

2. [核心配置] Spring Boot 外部化配置详解

摘要: 本章将深入探讨 Spring Boot 强大而灵活的配置管理机制。我们将对比 propertiesyml 两种主流格式,并重点掌握 @Value@ConfigurationProperties 两种读取配置的方式,最后学习如何通过 Profiles 实现多环境的配置隔离。

2.1. 外部化配置的核心思想与加载顺序

2.1.1. “痛点”场景:为什么配置需要与代码分离?

想象一下,在我们刚刚完成的 Hello, World! 项目中,我们需要连接一个数据库。一个初级的做法可能是将数据库连接信息硬编码在代码里:

1
2
3
4
5
6
7
8
public class DatabaseConnector {
public void connect() {
String url = "jdbc:mysql://localhost:3306/dev_db";
String username = "root";
String password = "dev_password";
// ... 连接逻辑
}
}

这段代码存在一个致命问题:配置与代码高度耦合。当我们需要将应用从开发环境部署到测试环境,再到生产环境时,数据库的地址、用户名和密码必然会改变。难道我们每次部署都要去修改 Java 源代码,然后重新编译、打包吗?这显然是低效且极易出错的。

解决方案:
将这些易变的配置信息从代码中抽离出来,存放到代码外部的文件中。应用程序在启动时去读取这些外部文件,获取配置。这就是外部化配置的核心思想。


2.1.2. Spring Boot 外部化配置的优势

Spring Boot 将外部化配置思想发挥到了极致,带来了诸多好处:

优势说明
灵活性同一份应用程序代码,无需重新打包,即可通过切换不同的配置文件,在不同环境中运行。
易于维护配置信息集中管理,非开发人员(如运维)也可以安全地修改配置,只需重启应用即可生效。
安全性可以将数据库密码、API密钥等敏感信息存储在生产服务器的特定文件中,而不会泄露在代码仓库里。
标准化Spring Boot 提供了一套标准化的配置机制,使得配置管理变得简单且有据可循。

2.1.3. 关键:Spring Boot 配置的加载优先级顺序

Spring Boot 可以在很多不同的地方查找配置,并且有一套严格的优先级顺序。高优先级的配置会覆盖低优先级的配置。了解这个顺序对于排查“配置为何没生效”的问题至关重要。

部署结构示例

假设我们的应用打包后,部署结构如下:

1
2
3
4
5
. 📂 /opt/app/
├── 📄 my-app-1.0.0.jar # 应用程序 JAR 包
├── 📄 application.yml # JAR 包外部的配置文件
└── 📂 config/ # JAR 包外部的 config 目录
└── 📄 application.yml # config 目录下的配置文件

在这个结构中,my-app-1.0.0.jar 内部的 resources/ 目录下也包含一个 application.yml 文件。

配置加载优先级

  1. jar 包内部的 application.propertiesapplication.yml (优先级最低)
  2. jar 包外部的 application.propertiesapplication.yml (与 jar 包同级目录下)
  3. jar 包外部的 config/ 目录下的 application.propertiesapplication.yml
  4. 操作系统环境变量
  5. Java 系统属性 (-Dkey=value)
  6. 通过命令行参数传入的配置 (--key=value) (优先级最高)

核心理念: 这个设计允许运维人员在不触碰任何代码包的情况下,通过命令行参数外部配置文件来覆盖应用打包时的默认配置,实现了完美的“运维友好”。


2.2. 主流配置方式:.properties vs .yml

Spring Boot 主要支持两种格式的配置文件:.properties.yml

2.2.1. application.properties 语法与用法

这是 Java 中传统的配置文件格式,以简单的键值对形式存在。

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

1
2
3
4
5
6
# 服务器端口
server.port=8080

# 数据库连接信息
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root

2.2.2. application.yml 的层级结构与语法优势

YAML (.yml) 是一种对人类阅读更友好的数据序列化语言。它通过缩进来表示层级关系,结构非常清晰。

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

1
2
3
4
5
6
7
8
9
# 服务器配置
server:
port: 8080

# 数据库连接信息
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: root

YAML 语法关键:

  1. 使用空格进行缩进,严禁使用 Tab 键
  2. : 冒号后面必须至少有一个空格

2.2.3. 两种格式的优先级与选择建议

特性.properties 文件.yml 文件
格式扁平的键值对 (spring.datasource.url=...)层级的树状结构,更清晰
可读性配置多时可读性差非常适合描述复杂的、有层级的配置数据
优先级高于 .yml低于 .properties
建议推荐使用 .yml,因其结构化能力远超 .properties仅在需要覆盖 .yml 中某个特定值时少量使用

resources 目录下同时存在 application.propertiesapplication.yml 时,Spring Boot 会两个都加载。如果两个文件中有相同的配置项,.properties 文件中的值会覆盖 .yml 文件中的值。


2.3. 读取配置:@Value vs @ConfigurationProperties

“痛点”场景:

好了,我们已经在 application.yml 中定义了配置,但我们的 Java 代码如何才能获取到这些值呢?

Spring Boot 提供了两种主流的注解来解决这个问题:@Value@ConfigurationProperties

2.3.1. [实践] 使用 @Value 读取单个配置 (含默认值)

@Value 注解非常适合用于注入单个简单的配置值。

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

1
2
3
4
app:
name: "My Awesome App"
owner: "Prorise"
# 我们故意不设置 port

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

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class AppInfo {

// 使用 ${...} 占位符来引用配置文件中的 key
@Value("${app.name}")
private String name;

@Value("${app.owner}")
private String owner;

// 使用冒号 ":" 来提供一个默认值
// 如果配置文件中找不到 app.port,则会使用 8080
@Value("${app.port:8080}")
private Integer port;

@Override
public String toString() {
return "AppInfo{" +
"name='" + name + '\'' +
", owner='" + owner + '\'' +
", port=" + port +
'}';
}
}

测试代码:

1
2
3
4
5
6
7
8
9
10
@SpringBootTest
class ConfigTest {
@Autowired
private AppInfo appInfo;

@Test
void testValueAnnotation() {
System.out.println(appInfo);
}
}
1
AppInfo{name='My Awesome App', owner='Prorise', port=8080}

2.3.2. [推荐] 使用 @ConfigurationProperties 进行类型安全的属性绑定

“痛点”场景:

@Value 注解虽然简单,但如果要注入的配置项非常多(比如一个数据源有十几个配置),在类中写十几个 @Value 注解会显得非常繁琐和重复。而且,它对复杂的嵌套结构或集合配置支持不佳。

解决方案:
@ConfigurationProperties 注解为此而生。它允许我们将配置文件中一个前缀下的所有属性,整体地、类型安全地绑定到一个 Java 对象(POJO)上。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
datasource:
mysql:
url: jdbc:mysql://localhost:3306/prod
username: prod_user
password: prod_password
# 集合配置
connection-properties:
- characterEncoding=utf8
- useSSL=false
# 嵌套对象配置
pool-options:
max-active: 20
min-idle: 5

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

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;

@Data // Lombok 注解,自动生成 Getter/Setter
@Component
// 关键:将此类与配置文件中 "datasource.mysql" 为前缀的属性进行绑定
@ConfigurationProperties(prefix = "datasource.mysql")
public class MySQLProperties {

private String url;
private String username;
private String password;
private List<String> connectionProperties; // 对应 YAML 中的列表
private PoolOptions poolOptions; // 对应 YAML 中的嵌套对象

@Data
public static class PoolOptions { // 内部静态类用于映射嵌套对象
private int maxActive;
private int minIdle;
}
}

测试代码:

注意: 记得将上一节的ApiInfo删除,因为我们已经修改了yml文件,不删除会找不到bean的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.springbootdemo;
import com.example.springbootdemo.config.MySQLProperties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class SpringBootDemoApplicationTests {


@Autowired
private MySQLProperties mySQLProperties;

@Test
void testConfigProperties() {
System.out.println(mySQLProperties);
}

}

1
2
3
MySQLProperties(url=jdbc:mysql://localhost:3306/prod, username=prod_user, 
password=prod_password, connectionProperties=[characterEncoding=utf8, useSSL=false],
poolOptions=MySQLProperties.PoolOptions(maxActive=20, minIdle=5))

2.3.3. 启用属性类:@Component vs @EnableConfigurationProperties

“痛点”场景:

我们已经创建了一个 MySQLProperties 类,并用 @ConfigurationProperties 标注了它。但是,Spring Boot 默认并不会知道这个普通 Java 类的存在。我们必须通过某种方式告诉 Spring:“嘿,这是一个需要你来管理的 Bean,请在容器启动时创建它,并把配置文件里的值绑定进去!”。那么,我们有几种方式来“激活”或“启用”这个属性类呢?

Spring Boot 提供了两种主流的激活方式。

方案一:使用 @Component (简单、直接)

这是最直接的方式。我们只需在属性类上添加 @Component 注解,它就会被 Spring Boot 的组件扫描 (@ComponentScan) 机制自动发现,并注册为一个 Bean。

文件路径: src/main/java/com/example/springbootdemo/config/MySQLProperties.java

1
2
3
4
5
6
7
8
9
10
11
package com.example.springbootdemo.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; // 1. 引入 Component 注解

@Data
@Component // 2. 将此类声明为一个通用的 Spring 组件
@ConfigurationProperties(prefix = "datasource.mysql")
public class MySQLProperties {
// ... 属性不变
}
  • 优点: 简单明了,所有配置都集中在一个类上。
  • 缺点: 属性类 (MySQLProperties) 与 Spring 框架的 @Component 注解产生了耦合。对于我们自己项目内部的配置类,这通常是可以接受的。

方案二:使用 @EnableConfigurationProperties (集中、解耦)

“进阶痛点”:

如果 MySQLProperties 这个类来源于一个我们无法修改的第三方 jar 包呢?或者,在一个大型项目中,我们希望将所有的配置属性类在一个地方进行集中式的、统一的管理,而不是让它们散落在各个角落,我们该怎么做?

解决方案:
这就是 @EnableConfigurationProperties 的用武之地。它允许我们在一个集中的配置类(通常是我们自己创建的,被 @Configuration 标注的类)中,明确地列出所有需要被激活的属性类。

第一步:移除属性类上的 @Component
MySQLProperties 变回一个纯粹的、不依赖 Spring 框架的 POJO。
文件路径: src/main/java/com/example/springbootdemo/config/MySQLProperties.java (修改)

1
2
3
4
5
6
7
8
9
10
package com.example.springbootdemo.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties(prefix = "datasource.mysql")
// 移除了 @Component 注解
public class MySQLProperties {
// ... 属性不变
}

第二步:创建集中的配置管理类
文件路径: src/main/java/com/example/springbootdemo/config/AppConfig.java (新增文件)

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

import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

// 1. @Configuration 声明这是一个配置管理类
@Configuration
// 2. @EnableConfigurationProperties 明确告诉 Spring Boot
// 去激活并注册 MySQLProperties.class 这个属性类
@EnableConfigurationProperties(MySQLProperties.class)
public class AppConfig {
// 这个类可以是空的,它的主要作用就是通过注解来集中管理配置。
// 当然,我们后续还可以在这里定义 @Bean 等。
}

对比总结

特性方式一: @Component方式二: @EnableConfigurationProperties
使用方式直接在属性类上添加 @Component在一个 @Configuration 配置类上添加 @EnableConfigurationProperties
优点简单、直接、自包含集中管理与框架解耦(属性类本身可以是纯 POJO)
适用场景项目内部自己定义的属性类1. 启用第三方的属性类<br>2. 在项目中集中管理所有属性配置

2.4. 多环境配置管理 (Profiles)

“痛点”场景:

我们的应用开发完成,即将上线。但在部署时遇到了一个典型问题:开发环境用的是本地数据库,测试环境用的是测试库,而生产环境则需要连接生产主库。难道我们每次部署到不同环境,都要手动去修改 application.yml 里的数据库连接信息,然后再重新打包吗?这不仅效率低下,而且极易引发“配错环境”的生产事故。

解决方案:
Spring Profiles 是专门为解决这一问题而设计的。它允许我们为不同的环境创建各自独立的配置文件,在应用启动时,只需通过一个简单的开关,就能激活指定环境的配置,实现“一次打包,多环境灵活部署”。

2.4.1. [实践] application-{profile}.yml 的约定与使用

Spring Boot 约定,特定环境的配置文件命名格式为 application-{profile}.yml (或 .properties),其中 {profile} 就是你自定义的环境名(如 dev, test, prod)。

现在,我们在 src/main/resources 目录下进行如下改造:

  1. application.yml: 作为主配置文件,存放所有环境共享的配置,并指定默认激活的环境。
  2. application-dev.yml: 存放开发环境特有的配置。
  3. application-prod.yml: 存放生产环境特有的配置。

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

1
2
3
4
5
6
7
8
9
10
11
# --------------------
# 主配置文件 (共享配置)
# --------------------
spring:
application:
name: my-app # 比如应用名称,是所有环境共享的

# 关键:指定默认激活的环境为 'dev'
# 如果不通过其他更高优先级的方式指定,应用将默认加载 application-dev.yml
profiles:
active: dev

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

1
2
3
4
5
6
7
8
# --------------------
# 开发环境 (dev)
# --------------------
server:
port: 8080 # 开发时使用 8080 端口
app:
owner: Prorise # 作者名
env: Development Environment

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

1
2
3
4
5
6
7
8
# --------------------
# 生产环境 (prod)
# --------------------
server:
port: 80 # 生产环境通常使用 80 端口
app:
owner: Prorise
env: Production Environment

工作原理: Spring Boot 启动时,会总是先加载主配置文件 application.yml,然后再根据激活的 profile (比如 dev),去加载对应的 application-dev.yml。后加载的配置会覆盖先加载的同名配置。


2.4.2. [实践] 激活特定 Profile 的多种方式

我们有多种方式来激活一个 Profile,它们的优先级各不相同。

方式一:在主配置文件中指定 (开发默认)

这是我们在 application.yml 中已经做过的方式,通过 spring.profiles.active 属性来设置。它通常用于指定本地开发时的默认环境。

1
2
3
spring:
profiles:
active: dev

方式二:通过命令行参数 (生产部署首选)

这是优先级更高生产环境部署最常用的方式。它可以在不修改任何打包文件的情况下,在启动时动态指定环境。

1
2
# 激活生产环境配置来启动应用
java -jar my-app.jar --spring.profiles.active=prod

实践验证

为了验证 Profile 是否生效,我们创建一个简单的 Bean 来读取配置。

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

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

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Data
@Component
public class AppInfo {
@Value("${app.owner}")
private String owner;

@Value("${app.env}")
private String env;
}

文件路径: src/test/java/com/example/springbootdemo/SpringBootDemoApplicationTests.java (添加新测试)

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

import com.example.springbootdemo.config.AppInfo;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class SpringBootDemoApplicationTests {

@Autowired
private AppInfo appInfo;

@Test
void testProfile() {
// 打印注入的配置,验证当前激活的是哪个环境
System.out.println(appInfo);
}
}

1. 默认环境测试
直接在 IDEA 中运行 testProfile() 测试。

1
AppInfo(owner=Prorise, env=Development Environment)

2. 切换环境测试
在 IDEA 中,我们可以模拟命令行参数来切换 Profile:

  • 点击 Edit Configurations...
  • Program arguments 中输入 --spring.profiles.active=prod
    再次运行 testProfile() 测试。
1
AppInfo(owner=Prorise, env=Production Environment)

结论: 实验证明,通过激活不同的 Profile,我们成功地让应用加载了不同的配置,而无需修改任何代码。


3. [AOP] 面向切面编程

摘要: 在我们的项目中,日志记录、事务管理、权限校验等通用逻辑散布在各个业务方法中,造成了代码冗余。本章我们将学习 Spring AOP,这是一种强大的编程范式,它能将这些横切关注点从主业务逻辑中优雅地分离出来。我们将通过实战,创建一个自定义的日志切面,并最终实现一个基于注解的声明式操作权限校验。

前置知识要求: 在深入学习本章之前,我们强烈建议您回顾或先行学习我们的 《Java微服务(二):3.0 SpringMVC - 前后端交互核心内核 章节,因为本章中的许多概念,如拦截器 (Interceptor),都与 AOP 的思想一脉相承。理解 Spring MVC 的请求处理流程将极大地帮助您 grasp(掌握)AOP 的核心精髓。

为了不影响读者的阅读和,这里提供一个整理好的仓库供读者快速Clone Spring_Mvc_Study: 教学用的SpringMVC文件 我们后续还会在这个项目的基础上加以改进

3.1. AOP 核心概念入门

3.1.1. 痛点:什么是横切关注点?(Why AOP)

让我们回顾一下之前的代码。在 UserServiceImpl 中,我们可能希望在每个公开的业务方法(如 findUserById, saveUser)执行前后都打印日志,用于追踪和调试。一个朴素的实现方式可能是这样的:

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
@Service
public class UserServiceImpl implements UserService {

// 模拟一个简单的 Map 作为缓存
private final Map<Long, UserVO> userCache = new ConcurrentHashMap<>();

@Override
public UserVO findUserById(Long id) {
// 1. 先从缓存查找
if (userCache.containsKey(id)) {
log.info("命中缓存: {}", id);
return userCache.get(id);
}

// 2. 缓存未命中,查询数据库 (核心业务逻辑)
User user = userMapper.selectById(id);
if (user == null) {
return null;
}
UserVO userVO = convertToVO(user);

// 3. 将结果放入缓存
userCache.put(id, userVO);
log.info("查询数据库并写入缓存: {}", id);

return userVO;
}
}

这种写法暴露了严重的问题:

核心逻辑混杂:缓存处理的代码(从缓存读、写入缓存)与真正的业务逻辑(查询数据库、对象转换)紧紧地耦合在一起,使得代码难以阅读和维护。

代码重复:如果未来 findProductByIdfindOrderById 等方法也需要缓存,我们就必须在每个方法里都重复编写这套缓存逻辑。

像日志、事务、权限校验、性能监控这类需要“横向”地应用到多个业务模块中的功能,我们就称之为 横切关注点

AOP (Aspect-Oriented Programming, 面向切面编程) 的核心目标,就是将这些横切关注点从主业务逻辑中优雅地剥离出来,使它们模块化,从而降低代码的耦合度,提升系统的可维护性和可扩展性。


3.1.2. 核心术语:构建 AOP 的“积木”

要理解 AOP 是如何工作的,我们必须先掌握它的几个核心术语。您可以将它们想象成一套用于构建“切面”的乐高积木。

核心概念作用/比喻简明解释
连接点 (Join Point)所有可能的时机程序执行过程中可以插入切面的点,如方法调用或执行。
切点 (Pointcut)选定的具体时机一个或多个连接点的集合,它精确定义了通知将在哪里执行。
通知 (Advice)要做的具体事情在切点匹配的连接点上执行的代码,例如记录日志。
切面 (Aspect)事情和时机的组合切点通知的结合体,封装了一个完整的横切关注点功能。

关系总结

  • 切面 (Aspect) = 切点 (Pointcut) + 通知 (Advice)
  • 通知 (Advice) 被应用到由 切点 (Pointcut) 筛选出的一系列 连接点 (Join Point) 上。

1. 连接点 (Join Point)

定义: 程序执行过程中的一个明确的点,例如方法的调用、异常的抛出等。在 Spring AOP 中,连接点总是指代方法的执行

您可以把它想象成程序流程中的一个个“可以插入逻辑”的“时机点”。


2. 切点 (Pointcut)

定义: 一个谓词或表达式,它用于匹配和选中一组感兴趣的连接点。

如果说连接点是程序中所有可能插入逻辑的点,那么切点就是我们的“筛选器”,它精确地定义了我们到底要在哪些方法上应用我们的横切逻辑。例如,我们可以定义一个切点来选中 UserController 中所有以 get 开头的方法。


3. 通知 (Advice)

定义: 在切点所匹配的连接点上具体要执行的操作

通知定义了我们的横切逻辑“做什么”以及“什么时候做”。Spring AOP 提供了五种标准的通知类型:

  • @Before: 在目标方法执行之前执行。
  • @AfterReturning: 在目标方法成功返回之后执行。
  • @AfterThrowing: 在目标方法抛出异常之后执行。
  • @After: 无论目标方法是成功返回还是抛出异常,在它之后都会执行(类似于 finally 块)。
  • @Around: 环绕通知。这是最强大的通知类型,它能完全包裹目标方法的执行,我们可以在方法执行前后自定义逻辑,甚至可以决定是否执行目标方法。

4. 切面 (Aspect)

定义: 通知 (Advice)切点 (Pointcut) 的一个模块化组合。

一个切面将“在哪里做(切点)”和“做什么(通知)”这两件事有机地结合在了一起,形成了一个完整的横切关注点模块。例如,我们可以创建一个“日志切面”,它包含一个匹配所有 Service 层方法的切点,以及一个在方法执行前后打印日志的环绕通知。


3.2. [实战] 创建声明式缓存切面

现在,我们将亲手实践 AOP 的真正威力:创建一个自定义的 @SimpleCache 注解和一个配套的缓存切面。最终实现的效果是,任何方法只要加上 @SimpleCache 注解,就自动具备了专业的、带过期时间的缓存能力。

3.2.1. 引入 AOP Starter 依赖

首先,请确保 demo-framework 模块的 pom.xml 中已添加 spring-boot-starter-aop 依赖。

文件路径: demo-framework/pom.xml

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

3.2.2. 创建自定义缓存注解

我们在 demo-common 模块中创建 @SimpleCache 注解。

文件路径: demo-common/src/main/java/com/example/democommon/annotation/SimpleCache.java (新增文件)

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

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD) // 注解作用于方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时保留,AOP才能读取到
public @interface SimpleCache {
/**
* 缓存的键名前缀
*/
String key();

/**
* 缓存过期时间, 单位秒, 默认1小时
*/
long timeoutSeconds() default 3600;
}

3.2.3. 技术选型:Hutool-Cache 简介

在实现切面之前,我们先来了解一下即将使用的强大工具——Hutool-cache。它提供了几种成熟的缓存策略,让我们可以轻松应对不同场景。

缓存策略核心思想淘汰机制容量限制
FIFOCache先进先出 (First In, First Out)缓存满时,淘汰最先存入的数据
LFUCache最少使用 (Least Frequently Used)缓存满时,淘汰使用频率最低的数据
LRUCache最近最久未使用 (Least Recently Used)缓存满时,淘汰最长时间未被访问的数据
TimedCache定时过期 (Time-based Expiration)数据达到设定的超时时间后自动过期

补充说明:

  • FIFOLFULRU 这三种策略都是容量驱动的缓存。它们的核心目标是在缓存达到容量上限时,决定应该淘汰哪些数据来为新数据腾出空间。
  • TimedCache时间驱动的缓存。它不关心容量是否已满,只关心数据是否“新鲜”,一旦数据过期就会被清理。这与我们 @SimpleCache 注解中的 timeoutSeconds 属性完美契合,因此是本次实战的最佳选择

3.2.4. 实现缓存切面

现在,我们来创建 SimpleCacheAspect,并使用 Hutool 的 TimedCache 来实现专业的缓存逻辑。

文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/SimpleCacheAspect.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
package com.example.demoframework.aspect;

import cn.hutool.cache.Cache;
import cn.hutool.cache.CacheUtil;
import cn.hutool.core.util.StrUtil;
import com.example.democommon.annotation.SimpleCache;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Aspect // 1.声明这个类是一个切面。
@Component
@Slf4j
public class SimpleCacheAspect {
// 使用 Hutool 创建一个定时缓存,默认过期时间为1小时
// `CacheUtil.newTimedCache(...)`: 我们创建了一个 `TimedCache` 实例来存储我们的缓存数据。
private final Cache<String, Object> cache = CacheUtil.newTimedCache(3600 * 1000);

/**
* 定义一个切点,匹配所有被 @SimpleCache 注解标记的方法
*/
@Pointcut("@annotation(com.example.democommon.annotation.SimpleCache)")
public void cachePointcut() {
}

// `@Around`: 我们使用环绕通知,因为它能完全控制方法的执行流程:
// 先查缓存,如果缓存没有,再执行原方法 `joinPoint.proceed()`,最后将结果放入缓存。
@Around("cachePointcut()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
// 1. 获取注解和方法信息 在我们的这次调试中出发是的就是
// UserVO com.example.demosystem.service.impl.UserServiceImpl.findUserById(Long)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
// 方法:
// public com.example.demosystem.vo.UserVO com.example.demosystem.service.impl.UserServiceImpl.findUserById(java.lang.Long)
Method method = signature.getMethod();
// 通过反射获取我们方法上的注解
SimpleCache cacheAnnotation = method.getAnnotation(SimpleCache.class);
// 2. 根据方法和参数生成动态的缓存 Key
String keyPrefix = cacheAnnotation.key();
String argsString = StrUtil.join(",", joinPoint.getArgs());
// 获取我们请求中传入的UserID的值与方法参数名进行拼接,例如 user:1
String key = keyPrefix + ":" + argsString;

// 3. 检查缓存 (Hutool 的 get 方法会自动处理过期)
// `cache.get(key)`: Hutool 缓存的核心方法。如果键存在且未过期,它会返回值;
// 否则返回 `null`。这一个方法就代替了 `containsKey` 和 `get` 两步操作。
Object cachedResult = cache.get(key);
if (cachedResult != null) {
log.info("命中缓存: {}", key);
return cachedResult;
}
// 4. 缓存未命中,执行目标方法
log.info("查询数据库并写入缓存: {}", key);
Object actualResult = joinPoint.proceed();

// 5. 将结果放入缓存,并设置注解中指定的过期时间
if (actualResult != null) {
long timeoutMillis = cacheAnnotation.timeoutSeconds() * 1000;
// `cache.put(key, actualResult, timeoutMillis)`:
// 将数据存入缓存,并动态地为其指定一个从注解中读取的、毫秒级的过期时间。
cache.put(key, actualResult, timeoutMillis);
}
return actualResult;
}
}


3.3. 应用与测试

3.3.1. 在 Service 中应用注解

现在,我们回到 UserServiceImpl,移除之前手写的缓存代码,并换上我们崭新的 @SimpleCache 注解。

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

import com.example.democommon.annotation.SimpleCache;
import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.service.UserService;
import com.example.demosystem.vo.UserVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
// 为方法加上注解,设置 key 前缀和 10 秒的过期时间
@SimpleCache(key = "user", timeoutSeconds = 10)
public UserVO findUserById(Long id) {
}

// ... 其他方法保持不变 ...
}

3.3.2. 回归测试:验证缓存与过期效果

重启您的 demo-admin 模块。

  1. 重复调用请求: 使用 Swagger UI 调用 GET /users/1。观察控制台日志,您会看到:

image-20250819103200837

您会发现,这次没有打印“正在执行 findUserById 核心业务逻辑…”,证明原方法根本没有被执行,数据直接从缓存返回。

在十秒后重新请求,可以看到耗时又变回去了,证明缓存条目已经因过期而被自动清除,程序重新执行了数据库查询。


3.3.3. AOP 与拦截器的对比总结

现在,我们可以清晰地总结 AOP 和拦截器的区别了,这对于选择正确的技术至关重要。

对比维度拦截器 (Interceptor)Spring AOP (Aspect)
作用层面Web 层,与 HttpServletRequest 强绑定Spring Bean 的方法执行层面,与 Web 无关
粒度粗粒度,拦截所有匹配的 HTTP 请求细粒度,可精确到具体类的具体方法
能力可获取和修改 HTTP 请求和响应对象可获取和修改方法参数、返回值;可决定是否执行原方法
典型场景用户认证全局日志、CORS、解决重复提交事务管理缓存权限校验、性能监控等业务横切点

3.4. [实战] 使用 execution 监控 Service 层性能

在之前的缓存案例中,我们使用了 @annotation 来精确地“定点”增强某一个方法。现在,我们将学习如何使用 execution 来进行“范围”增强,实现一个对整个 Service 层所有公共方法进行性能监控的切面。

我们的目标:自动记录 demo-system 模块下,service 包及其子包内所有 public 方法的执行耗时,而无需修改任何 Service 代码。

3.4.1. 创建性能监控切面

文件路径: demo-framework/src/main/java/com/example/demoframework/aspect/PerformanceAspect.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
package com.example.demoframework.aspect;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
@Slf4j
public class PerformanceAspect {

// * 这是本节的核心。我们使用 `execution` 指示符,精确地描述了一个“范围”。
// * `public *`: 匹配所有 `public` 方法,返回任意类型。
// * `com.example.demosystem.service..`: 匹配 `service` 包 及其所有子包。这里的 `..` 至关重要。
// * `*.*(..)`: 匹配所有类的所有方法,接受任意参数。
@Pointcut("execution(public * com.example.demosystem.service..*.*(..))")
public void serviceMethods() {}

@Around("serviceMethods()")
public Object profile(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
// 获取被拦截的目标对象的类名。
String className = pjp.getTarget().getClass().getSimpleName();
// 获取被拦截的方法名。
String methodName = pjp.getSignature().getName();

// 执行原始方法
Object result = pjp.proceed();
long end = System.currentTimeMillis();
log.info("PERFMON: {}.{} 执行耗时: {} ms", className, methodName, (end - start));
return result;
}
}


3.4.2 回归测试

无需做任何额外配置!因为这个切面已经被注册为 Spring Bean,并且它的切点会自动匹配所有符合条件的 Service 方法。

  1. 重启 demo-admin 应用。
  2. 使用 API 工具调用任意一个会触发 Service 层方法的接口,例如 GET /usersPOST /users
  3. 观察控制台日志,您会看到除了我们之前的 Web 日志,现在还多出了性能监控日志:

image-20250819105608908

结论execution 指示符为我们提供了一种极其强大的、基于包和方法签名进行“范围扫描”的能力。它与 @annotation 的“定点精确打击”形成了完美互补,两者结合,可以让我们随心所欲地将 AOP 的能力应用到项目的任何一个角落。

解惑:@Around 是否能替代所有其他通知?

从技术上讲,@Around 环绕通知确实是功能最强大的,它可以完全模拟 @Before, @AfterReturning, @AfterThrowing@After 的所有功能。

既然如此,为什么还需要其他通知注解呢?

答案是:为了代码的简洁性、可读性和意图的清晰性。

我们可以把这看作是“瑞士军刀”和“专用工具”的区别:

  • @Around (瑞士军刀): 功能万能,但使用起来也最复杂。您必须手动管理目标方法的执行(通过调用 pjp.proceed()),并且需要自己处理异常。如果忘记调用 pjp.proceed(),原始方法将永远不会被执行,这可能是一个难以发现的严重 bug。

  • @Before, @AfterReturning 等 (专用工具): 功能专一,但使用起来非常简单安全。它们清晰地表达了您的意图,并且您无需关心如何以及何时执行原始方法,Spring 框架会为您处理好一切。

最佳实践建议:

当您只想…最佳选择为什么?
在方法执行做些事@Before最简单,意图最明确,不会意外影响方法执行。
在方法成功返回后做些事@AfterReturning可直接获取返回值,代码简洁。
只在方法抛出异常时做些事@AfterThrowing专门的异常处理通道,逻辑清晰。
必须在方法执行前后都操作,或需要控制/改变方法执行流程时@Around只有在这种复杂场景下,才动用功能最强大的工具。

4. [数据深化] 事务、缓存与外部调用

摘要: CRUD 功能的实现只是数据操作的起点。本章我们将深入持久层的两大核心——事务管理性能优化。我们将学习如何通过 @Transactional 注解优雅地保证数据一致性,并引入 Spring Cache 与 Redis,为我们的应用插上缓存的翅膀,最后探讨在生产环境中配置与监控多数据源的最佳实践。

4.1. [核心] 声明式事务管理 (@Transactional)

承前启后: 在上一章,我们学习了 AOP 如何将通用逻辑(横切关注点)从业务代码中分离。Spring 的声明式事务管理正是 AOP 最经典、最成功的应用之一。您将亲眼见证,一个简单的 @Transactional 注解背后,蕴含着多么强大的切面技术。

4.1.1. 痛点:为什么需要事务?

想象一个简化的银行转账业务:账户 A 向账户 B 转账 100 元。这个操作至少需要两个数据库步骤:

  1. UPDATE: 将账户 A 的余额减去 100。
  2. UPDATE: 将账户 B 的余额增加 100。

现在,设想一种极端情况:在成功执行第一步后,数据库突然宕机或应用崩溃,导致第二步未能执行。结果将是灾难性的:A 的钱被扣了,B 却没有收到,凭空蒸发了 100 元。

事务 (Transaction) 正是为了解决这类问题而生。它将一组数据库操作捆绑成一个不可分割的原子单元。在这个单元内,所有操作要么全部成功,要么全部失败回滚到操作前的状态,从而确保数据的绝对一致性。

在 Spring 中,我们无需手动编写 try-catch-finallycommit/rollback 代码,只需一个注解就能实现这一切。

1. 实践准备:构建转账业务场景

为了演示事务,我们首先需要创建一个 t_account 表,并为其构建相应的业务代码。

请在您的数据库中执行以下 SQL:

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `t_account` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int DEFAULT NULL COMMENT '用户ID',
`balance` decimal(10,2) DEFAULT NULL COMMENT '账户余额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;

-- 插入两条测试数据
INSERT INTO `t_account` (user_id, balance) VALUES (1, 1000.00);
INSERT INTO `t_account` (user_id, balance) VALUES (2, 1000.00);

接下来,创建相应的 Entity, Mapper 和 Service:

文件路径: demo-system/src/main/java/com/example/demosystem/entity/Account.java (新增)

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

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

@Data
@TableName("t_account")
public class Account {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private Integer userId;
private BigDecimal balance;
}

文件路径: demo-system/src/main/java/com/example/demosystem/mapper/AccountMapper.java (新增)

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

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demosystem.entity.Account;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;

import java.math.BigDecimal;

public interface AccountMapper extends BaseMapper<Account> {
@Update("update t_account set balance = balance - #{amount} where user_id = #{userId}")
void decrease(@Param("userId") Integer userId, @Param("amount") BigDecimal amount);

@Update("update t_account set balance = balance + #{amount} where user_id = #{userId}")
void increase(@Param("userId") Integer userId, @Param("amount") BigDecimal amount);
}

文件路径: demo-system/src/main/java/com/example/demosystem/service/AccountService.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.demosystem.service;

import com.example.demosystem.mapper.AccountMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
public class AccountService {

private final AccountMapper accountMapper;

@Transactional // 关键注解:声明此方法内的所有数据库操作都受事务管理
public void transfer(Integer fromUserId, Integer toUserId, BigDecimal amount) {
// 1. 扣减转出方余额
accountMapper.decrease(fromUserId, amount);

// 2. 模拟一个异常
if (true) {
throw new RuntimeException("模拟转账过程中发生意外...");
}

// 3. 增加转入方余额
accountMapper.increase(toUserId, amount);
}
}

文件路径: demo-system/src/main/java/com/example/demosystem/controller/AccountController.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.demosystem.controller;

import com.example.democommon.common.Result;
import com.example.demosystem.service.AccountService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@Tag(name = "账户管理", description = "演示事务")
@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class AccountController {

private final AccountService accountService;

@Operation(summary = "模拟转账")
@PostMapping("/transfer")
public ResponseEntity<Result<Void>> transfer(@RequestParam Integer from, @RequestParam Integer to, @RequestParam BigDecimal amount) {
accountService.transfer(from, to, amount);
return ResponseEntity.ok(Result.success());
}
}

2. 验证事务效果

  1. 重启 demo-admin 应用。
  2. 调用转账接口: 使用 API 工具调用 POST http://localhost:8080/account/transfer?from=1&to=2&amount=100
  3. 观察结果: 您会收到一个由全局异常处理器捕获并返回的 500 错误(因为我们抛出了 RuntimeException)。
  4. 检查数据库: 查询 t_account 表,您会发现用户 12 的余额都还是 1000.00,没有任何变化。

结论: 因为 transfer 方法被 @Transactional 注解标记,当方法内抛出异常时,Spring AOP 切面捕获了它,并自动触发了事务回滚 (Rollback)。已经执行的第一步 decrease 操作被撤销,从而保证了数据的绝对一致性。


4.1.2. 事务的传播行为

“痛点”: 当一个已经开启了事务的方法(外部方法)去调用另一个同样需要事务的方法(内部方法)时,内部方法的事务应该如何表现?是加入外部方法的现有事务,还是自己开启一个全新的事务?

这就是事务传播行为 要解决的问题。它定义了事务在方法调用链中的传递规则。

传播行为核心作用场景说明
— 最常用 —
REQUIRED (默认)加入或新建事务如果当前存在事务,就加入;如果不存在,就创建一个新的。这是保证业务完整性的首选。
REQUIRES_NEW总是新建事务无论是否存在事务,都创建一个独立的新事务。常用于日志记录等需要与主业务隔离的操作。
— 其他 —
SUPPORTS支持当前事务有事务就用,没有就以非事务方式执行。
NOT_SUPPORTED以非事务方式执行总是挂起当前事务(如果存在),以非事务方式运行。
MANDATORY强制要求有事务必须在已有事务中执行,否则抛出异常。
NEVER强制要求无事务必须在非事务环境中执行,否则抛出异常。
NESTED嵌套事务在已有事务中创建一个保存点(Savepoint),可以独立回滚而不影响外部事务。

4.1.3. @Transactional 失效的典型场景

@Transactional 虽好,但它依赖于 Spring AOP 的代理机制,在某些情况下会“悄无声息”地失效,这是面试高频题和生产环境中的大坑。

核心原因: Spring AOP 是通过代理对象来织入事务切面的。只有当调用是通过代理对象发起的,事务才会生效。任何绕过代理的调用都会导致事务失效。

1. 方法非 public

@Transactional 只能用于 public 方法。如果用在 protected, privatedefault 可见性的方法上,事务将不会生效。

2. 方法内部调用 (最常见)

在一个类中,一个未被 @Transactional 注解的方法 a() 去调用同一个类中被注解的方法 b()b() 的事务会失效。

1
2
3
4
5
6
7
8
9
10
11
@Service
public class MyService {
public void methodA() {
this.methodB(); // this 调用,绕过了代理,事务失效!
}

@Transactional
public void methodB() {
// ...
}
}

原因: methodA() 是通过 this 关键字直接调用的 methodB(),它调用的是原始对象的方法,而不是 Spring 创建的代理对象。

解决方案: 将 methodB() 移到另一个独立的 Service 类中,通过注入的代理对象来调用。

3. 异常被 catch

如果在事务方法内部 catch 了异常并且没有重新抛出,Spring 的事务切面将无法感知到异常的发生,从而不会触发回滚。

1
2
3
4
5
6
7
8
9
10
@Transactional
public void methodC() {
try {
// ... 数据库操作 ...
throw new RuntimeException();
} catch (Exception e) {
// 异常被“吞”了,事务无法回滚
log.error("发生异常", e);
}
}

4. 默认只对 RuntimeException 回滚

Spring 默认只在遇到 RuntimeExceptionError 时才会触发事务回滚。如果方法抛出的是一个受检异常(Checked Exception,如 IOException),事务默认不会回滚。

解决方案: 使用 rollbackFor 属性,我们将在下一节详细讲解。


4.1.4. rollbackForisolation 属性详解

1. rollbackFor: 精确控制回滚时机

rollbackFor 属性允许我们指定一个或多个异常类,当方法抛出这些类型的异常时,即使它们是受检异常,事务也依然会回滚。

语法:
@Transactional(rollbackFor = Exception.class)

最佳实践: 通常建议将 rollbackFor 设置为 Exception.class,这表示任何类型的异常都会触发回滚,更符合大多数业务场景的预期。

2. isolation: 定义事务的隔离级别

isolation 属性用于定义事务的隔离级别,以解决并发访问时可能出现的脏读、不可重复读、幻读等问题。

隔离级别脏读不可重复读幻读
READ_UNCOMMITTED可能可能可能
READ_COMMITTED不会可能可能
REPEATABLE_READ (MySQL默认)不会不会可能
SERIALIZABLE不会不会不会

语法:
@Transactional(isolation = Isolation.REPEATABLE_READ)

最佳实践: 绝大多数情况下,我们无需手动设置隔离级别,直接使用数据库的默认级别(如 MySQL 的 REPEATABLE_READ)即可。只有在特定业务场景需要解决并发问题时,才考虑调整它。


4.2. [性能] 整合 Redis 实现分布式缓存

“痛点”:在我们的项目中,findUserById 这样的查询操作可能会被频繁调用。每次调用都去查询数据库,不仅响应速度受限于磁盘 I/O,而且当并发量巨大时,会给数据库带来沉重的压力。

解决方案:引入缓存。对于那些不经常变化但频繁访问的数据(“读多写少”),我们可以将其第一次查询的结果存储在一个读取速度更快的介质中(如内存)。后续的请求将直接从缓存中获取数据,避免了对数据库的访问,从而极大地提升了应用的性能和吞吐量。

Spring 框架提供了一套名为 Spring Cache 的缓存抽象,它允许我们通过几个简单的注解,以一种“无侵入”的方式为方法增加缓存功能,而无需在业务代码中编写任何缓存相关的逻辑。


4.2.1. Spring Cache 核心注解

Spring Cache 的核心就是它的“注解三剑客”,它们分别对应了缓存的读、写、删操作。

注解核心作用适用场景
@Cacheable读/写缓存:方法执行前,先检查缓存。如果命中,直接返回缓存数据,不执行方法体。如果未命中,则执行方法体,并将返回值放入缓存。查询操作 (SELECT)
@CachePut更新缓存总是执行方法体,然后将方法的返回值更新到缓存中。修改操作 (UPDATE)
@CacheEvict删除缓存:方法执行后,从缓存中移除指定的条目。删除操作 (DELETE)

这些注解都共享一些通用属性,最核心的是:

  • cacheNames (或 value): 指定缓存的名称(可以理解为缓存的分组或命名空间)。
  • key: 指定缓存条目的键 (Key)。这是缓存的唯一标识,支持强大的 SpEL 表达式。

4.2.2. 配置 Spring Boot Data Redis 作为缓存后端

Spring Cache 只是一个标准接口,我们需要为它提供一个具体的缓存实现。我们将使用 Redis 这个业界最主流的分布式缓存方案。

1. 添加依赖

我们需要在 demo-framework 模块中添加 spring-boot-starter-data-redis 依赖,它已经包含了 Spring Cache 所需的依赖。

首先,在父 pom.xml<properties> 中统一版本:

文件路径: pom.xml (根目录)

1
2
3
<properties>
<redis.version>3.3.0</redis.version>
</properties>

然后,在父 pom.xml<dependencyManagement> 中声明依赖:

文件路径: pom.xml (根目录)

1
2
3
4
5
6
7
8
9
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>${redis.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

最后,在 demo-framework 模块中引入依赖:

文件路径: demo-framework/pom.xml

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

2. 启用缓存与配置序列化

第一步:启用缓存
我们需要在 demo-admin 启动模块的一个配置类上添加 @EnableCaching 注解,来正式开启 Spring 的缓存功能。

文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java (修改)

1
2
3
4
5
6
7
8
9
// ... imports ...
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
@EnableCaching // 启用 Spring 缓存功能
public class SpringBootDemoApplication {
// ... main method ...
}

第二步:配置 Redis 连接
demo-admin 的配置文件中,添加 Redis 的连接信息。

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

1
2
3
4
5
6
7
8
spring:
# ... datasource config ...
data:
redis:
host: localhost
port: 6379
# password: your_password
database: 0

第三步:配置 JSON 序列化 (关键步骤)
Spring Boot 默认使用 JDK 序列化将 Java 对象存入 Redis,这会产生难以阅读的二进制乱码,且存在兼容性问题。最佳实践是配置使用 JSON 格式进行序列化。

我们在 demo-framework 模块下创建 CacheConfig

文件路径: demo-framework/src/main/java/com/example/demoframework/config/CacheConfig.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
package com.example.demoframework.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;

/**
* Spring Cache 配置类,用于将缓存存储到 Redis。
*/
@Configuration
public class CacheConfig {

/**
* 配置并创建 CacheManager Bean,Spring 将使用它来管理缓存。
*
* @param factory Spring 自动注入的 Redis 连接工厂。
* @return 配置好的 RedisCacheManager。
*/
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// 1. 创建 Redis Key 和 Value 的序列化器
// Key 使用字符串序列化器
StringRedisSerializer redisSerializer = new StringRedisSerializer();
// Value 使用自定义的 Jackson JSON 序列化器
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = createJacksonSerializer();

// 2. 配置默认的缓存规则
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 设置缓存默认过期时间为 1 小时
.entryTtl(Duration.ofHours(1))
// 设置 Key 的序列化方式
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
// 设置 Value 的序列化方式
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))
// 禁止缓存 null 值
.disableCachingNullValues();

// 3. 根据以上配置创建 CacheManager
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}

/**
* 创建一个自定义配置的 Jackson 序列化器。
* @return Jackson2JsonRedisSerializer 实例
*/
private Jackson2JsonRedisSerializer<Object> createJacksonSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
// 允许 Jackson 序列化所有字段(包括 private)
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
// 在序列化的 JSON 中存储对象的类型信息,以便在反序列化时能恢复为原始类型
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);

return new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
}
}

4.2.3. 实战:为 UserService 添加缓存

现在,我们为 UserServiceImpl 中的 CRUD 方法添加缓存注解。

文件路径: demo-system/src/main/java/com/example/demosystem/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
// ... imports ...
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;

@Override
@Cacheable(cacheNames = "users", key = "#id")
public UserVO findUserById(Long id) {
log.info("执行数据库查询: findUserById, ID: {}", id);
User user = userMapper.selectById(id);
if (user == null) { return null; }
return convertToVO(user);
}

@Override
// updateUser 方法需要返回更新后的对象,才能被 @CachePut 放入缓存
@CachePut(cacheNames = "users", key = "#dto.id")
public UserVO updateUser(UserEditDTO dto) {
// ... (省略校验逻辑) ...
User user = Convert.convert(User.class, dto);
userMapper.updateById(user);
// 返回更新后的VO,以便@CachePut更新缓存
return findUserById(dto.getId());
}

@Override
@CacheEvict(cacheNames = "users", key = "#id")
public void deleteUserById(Long id) {
log.info("从数据库删除用户, ID: {}", id);
userMapper.deleteById(id);
}

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

4.2.4. 验证缓存效果

  1. 重启 demo-admin 应用。

  2. 进行查询: 调用 GET /users/1

    • 应用日志: 您会看到 "执行数据库查询: findUserById, ID: 1"

    • Redis: 使用 redis-cli 执行 GET "users::1" (双冒号是默认分隔符),您会看到 UserVO 的 JSON 字符串。

      1
      2
      > GET "users::1"
      "{\"@class\":\"com.example.demosystem.vo.UserVO\",\"id\":1,\"userName\":\"张三\",...}"

image-20250819194150849


4.3. [新特性] 现代 HTTP 调用:RestClient

“痛点”:我们的应用(demo-system)不会是一个孤岛。在真实的业务场景中,它经常需要与外部的、第三方的 HTTP API 或内部的其他微服务进行通信。例如:

  • 调用支付网关完成支付。
  • 请求天气服务获取实时天气。
  • 在微服务架构中,调用订单服务查询订单详情。

Spring 长期以来提供了 RestTemplate 来满足这一需求,但它的 API 设计已略显陈旧。从 Spring Boot 3.2 开始,官方推荐使用一个全新的、更现代化的同步 HTTP 客户端——RestClient

4.3.1. 对比 RestTemplate,理解 RestClient 的优势

在深入实践之前,我们先通过一个简单的对比,来理解为什么 RestClient 是未来的方向。

场景:我们需要调用一个 API GET /posts/1,并将返回的 JSON 转换为 PostDTO 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 实例化 RestTemplate
RestTemplate restTemplate = new RestTemplate();
String url = "https://jsonplaceholder.typicode.com/posts/1";

try {
// 2. 发起调用,手动处理可能抛出的异常
ResponseEntity<PostDTO> response = restTemplate.getForEntity(url, PostDTO.class);
PostDTO post = response.getBody();
// ... 处理 post ...
} catch (HttpClientErrorException e) {
// 3. 需要用 try-catch 块来处理 4xx, 5xx 等错误
log.error("API 调用失败: {}", e.getStatusCode());
}

痛点

  • API 较为笨重,URL 和参数拼接繁琐。
  • 错误处理依赖于传统的 try-catch 异常机制,不够优雅。
  • 官方已将其置于 维护模式,不再推荐用于新项目。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 通过构建器创建 RestClient
RestClient restClient = RestClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.build();

// 2. 使用流式 (Fluent) API 构建和执行请求
PostDTO post = restClient.get()
.uri("/posts/{id}", 1) // URI 模板和参数,更安全清晰
.retrieve() // 发起请求并获取响应体
// 3. 链式处理特定状态码,无需 try-catch
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
log.error("客户端错误: {}", response.getStatusCode());
// 可以抛出自定义异常
throw new MyCustomException("API Client Error");
})
.body(PostDTO.class); // 将响应体自动转换为 DTO

优势

  • 流式 API:代码如行云流水,可读性极高。
  • 优雅的错误处理:通过 .onStatus() 方法,可以链式地、精准地处理不同类型的 HTTP 错误,代码更整洁。
  • 官方推荐:作为 RestTemplate 的继任者,是 Spring Boot 3.2+ 中同步 HTTP 调用的 首选

4.3.2. 实战:使用 RestClient 调用公开 API

现在,我们为项目增加一个新功能:通过用户 ID,调用一个公开的 JSONPlaceholder API,获取该用户发布的第一篇文章。

1. 准备工作:创建 DTO 与配置 Bean

第一步:创建 PostDTO 并添加 SpringDoc 注解
这个 DTO 用于承载从外部 API 返回的文章数据。我们为其属性添加 @Schema 注解,以便在 Swagger UI 中清晰展示。

文件路径: demo-system/src/main/java/com/example/demosystem/dto/external/PostDTO.java (新增包和文件)

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

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

@Data
@Schema(description = "外部文章数据传输对象")
public class PostDTO {
@Schema(description = "关联的用户ID", example = "1")
private Integer userId;

@Schema(description = "文章ID", example = "1")
private Integer id;

@Schema(description = "文章标题")
private String title;

@Schema(description = "文章内容")
private String body;
}

第二步:配置 RestClient Bean
最佳实践是在配置类中预先创建一个 RestClient Bean。

文件路径: demo-framework/src/main/java/com/example/demoframework/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
31
32
33
34
35
36
37
38
39
package com.example.demoframework.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
// ... 其他 import ...

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

// ... 已有的拦截器等配置 ...

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor) // 注册我们编写的日志拦截器
.addPathPatterns("/**"); // 指定拦截所有路径

// 认证拦截器
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/auth/login",
"/external/**", // !!!注意这里 我们添加了外部接口无需验证我们的TOken
"/files/**",
"/springboot-uploads/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/error" // 添加错误页面排除
);
}

@Bean
public RestClient restClient() {
return RestClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.build();
}
}

2. 实现 Service 与 Controller

文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.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
package com.example.demosystem.service;

import cn.hutool.core.lang.Assert;
import com.example.democommon.common.ResultCode;
import com.example.democommon.exception.BusinessException;
import com.example.demosystem.dto.external.PostDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class ExternalApiService {

private final RestClient restClient;

public PostDTO getUserFirstPost(Integer userId) {
// 1. 调用外部 API 获取指定用户的所有文章列表
List<PostDTO> posts = restClient.get()
.uri("/posts?userId={userId}", userId)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
log.error("调用外部 API 客户端错误: 状态码={}, 响应体={}", resp.getStatusCode(), resp.getBody().toString());
throw new BusinessException(ResultCode.BAD_REQUEST);
})
.body(new ParameterizedTypeReference<>() {
});

// 2. 使用 Hutool Assert 进行业务断言
Assert.notEmpty(posts, "该用户尚未发布任何文章");

// 3. 业务处理:返回第一篇文章
return posts.get(0);
}
}

文件路径: demo-system/src/main/java/com/example/demosystem/controller/ExternalApiController.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.demosystem.controller;

import com.example.democommon.common.Result;
import com.example.demosystem.dto.external.PostDTO;
import com.example.demosystem.service.ExternalApiService;
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.*;

@Tag(name = "外部API调用", description = "演示RestClient")
@RestController
@RequestMapping("/external")
@RequiredArgsConstructor
public class ExternalApiController {

private final ExternalApiService externalApiService;

@Operation(summary = "获取用户的首篇文章", description = "调用JSONPlaceholder API")
@GetMapping("/users/{userId}/first-post")
public ResponseEntity<Result<PostDTO>> getUserFirstPost(
@Parameter(description = "目标用户的ID", required = true, example = "1") @PathVariable Integer userId) {
PostDTO post = externalApiService.getUserFirstPost(userId);
return ResponseEntity.ok(Result.success(post));
}
}

3. 回归测试

  1. 重启 demo-admin 应用。
  2. 访问 Swagger UI (http://localhost:8080/swagger-ui.html)。

预期响应:
您将成功获取到用户 ID 为 1 的第一篇文章的 JSON 数据,并被包装在我们的标准 Result 结构中。

1
2
3
4
5
6
7
8
9
10
{
"code": 200,
"message": "操作成功",
"data": {
"userId": 1,
"id": 1,
"title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
"body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}
}

通过重构,我们不仅学习了 RestClient 的现代化用法,还将其完美地融入了我们现有的技术体系:使用 SpringDoc 为新 API 生成了专业的文档,并利用 Hutool Assert 增强了业务代码的健壮性。在下一节,我们将学习如何为这种可能失败的外部调用增加自动重试的能力。


4.4. [弹性设计] 使用 Spring Retry 实现精准重试

4.3 节,我们掌握了 RestClient 这一强大的工具。但一个残酷的现实是:任何网络调用都可能失败。外部服务可能暂时过载、正在重启,或者网络出现瞬时抖动。如果我们的应用在第一次调用失败后就直接放弃,那么系统的健壮性(即弹性)将非常脆弱。

Spring Retry 提供了一套优雅的、声明式的解决方案,让我们可以通过注解,为可能失败的操作自动增加重试能力。

4.4.1. 弹性设计的核心:区分瞬时与永久性故障

在引入代码之前,我们必须先建立一个由您提出的、至关重要的心智模型:并非所有失败都值得重试

将失败分为两类,是构建专业重试策略的基石:

  • 永久性故障: 这类失败表明请求本身存在根本性问题,无论重试多少次,结果都将是相同的。例如,请求了一个不存在的资源 (404) 或因参数错误导致请求无效 (400)。
  • 瞬时故障: 这类失败通常是由服务端临时性问题或网络波动引起的。服务可能只是暂时不可用或过载,稍后重试有可能会成功。例如,服务不可用 (503) 或网关超时 (504)。

我们的目标就是:只对瞬时故障进行重试,而对永久性故障执行快速失败

故障类型HTTP 状态码 / 异常我们的策略原因
永久性故障400, 401, 403, 404快速失败 (Fail Fast)请求本身存在问题,重试是浪费资源。
瞬时故障502, 503, 504, TimeoutException重试 (Retry)服务端或网络临时问题,重试可能成功。

4.4.2. 准备工作:引入依赖与启用重试

1. 添加 Maven 依赖

Spring Retry 是一个独立的 Spring 项目,需要我们手动添加依赖。

首先,在父 pom.xml<dependencyManagement> 中声明依赖:

文件路径: pom.xml (根目录)

1
2
3
4
5
6
7
8
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
<version>2.0.8</version> </dependency>
</dependencies>
</dependencyManagement>

然后,在 demo-framework 模块中引入依赖:

Spring Retry 依赖于 AOP。由于我们的 demo-framework 已经引入了 spring-boot-starter-aop,所以此处无需重复添加。

文件路径: demo-framework/pom.xml

1
2
3
4
5
<!--Spring Boot 重试-->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>

2. 启用重试功能

我们在 demo-admin 的主启动类上添加 @EnableRetry 注解,全局激活重试能力。

文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java (修改)

1
2
3
4
5
6
7
8
9
10
// ... imports ...
import org.springframework.retry.annotation.EnableRetry;

@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
@EnableCaching
@EnableRetry // 启用 Spring Retry 功能
public class SpringBootDemoApplication {
// ... main method ...
}

4.4.3. 实战:实现对 5xx 错误码的精准重试

现在,我们将改造 ExternalApiService,使其能够智能地区分 4xx5xx 错误,并只对后者进行重试。

1. 创建自定义的可重试异常

为了让 @Retryable 注解能够精准识别目标,我们先创建一个专门用于标识瞬时故障的异常。

文件路径: demo-common/src/main/java/com/example/democommon/exception/RetryableException.java (新增)

1
2
3
4
5
6
7
8
9
package com.example.democommon.exception;

// 这是一个简单的标记异常,继承自 RuntimeException
// 它的唯一作用就是告诉 Spring Retry:“嘿,看到我这个类型的异常时,你应该发起重试!”
public class RetryableException extends RuntimeException {
public RetryableException(String message) {
super(message);
}
}

2. 改造 ExternalApiService

文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.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.demosystem.service;

import cn.hutool.core.lang.Assert;
import com.example.democommon.common.ResultCode;
import com.example.democommon.exception.BusinessException;
import com.example.democommon.exception.RetryableException; // 导入新异常
import com.example.demosystem.dto.external.PostDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatusCode;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Collections;
import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class ExternalApiService {

private final RestClient restClient;

@Retryable(
include = RetryableException.class, // 只对我们自定义的 RetryableException 异常进行重试
maxAttempts = 3, // 最多重试3次(包括第一次调用)
backoff = @Backoff(delay = 2000, multiplier = 2) // 退避策略:第一次重试等2秒,第二次等4秒
)
public PostDTO getUserFirstPost(Integer userId) {
log.info("正在尝试调用外部 API, userId: {}", userId);

List<PostDTO> posts = restClient.get()
.uri("/posts?userId={userId}", userId)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
// 遇到 4xx 错误,抛出非重试的 BusinessException
throw new BusinessException(ResultCode.BAD_REQUEST);
})
.onStatus(HttpStatusCode::is5xxServerError, (req, resp) -> {
// 遇到 5xx 错误,抛出可重试的 RetryableException
throw new RetryableException("外部服务暂时不可用, 状态码: " + resp.getStatusCode());
})
.body(new ParameterizedTypeReference<>() {});

Assert.notEmpty(posts, "该用户尚未发布任何文章");

return posts.get(0);
}

@Recover
public PostDTO recover(RetryableException e, Integer userId) {
// 当所有重试都失败后,此方法将被调用
log.error("调用外部 API 达到最大重试次数后仍然失败, userId: {}. 异常信息: {}", userId, e.getMessage());
// 可以在此执行降级逻辑,例如返回一个默认的 PostDTO 对象或 null
// throw new BusinessException("外部服务持续不可用,请稍后再试");
return null; // 此处我们选择返回 null
}
}

当然!使用像 httpbin.org 这样的专业测试服务是验证重试和熔断逻辑的最佳实践。您的建议非常好,这比我之前提出的修改 URI 的方式要严谨得多。

我将完全按照您的思路,重写测试小节,引导用户修改 RestClientbaseUrl,并调用 httpbin.org 来精准地模拟 5xx4xx 失败场景。


4.4.4. 回归测试:使用 httpbin.org 模拟故障

要完美地测试我们的精准重试逻辑,我们需要一个能按需返回指定 HTTP 状态码的 API 端点。为此,我们将使用 httpbin.org 这个强大的公开测试服务。

第一步:临时修改 RestClient 配置

为了让我们的 ExternalApiService 调用 httpbin.org,我们需要暂时修改 RestClient Bean 的 baseUrl

文件路径: demo-framework/src/main/java/com/example/demoframework/config/WebConfig.java (修改)

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

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

// ... 已有的拦截器等配置 ...

@Bean
public RestClient restClient() {
return RestClient.builder()
// 暂时将 baseUrl 修改为 httpbin.org
.baseUrl("https://httpbin.org")
.build();
}
}

重要提示: 请记住,这只是为了测试目的。测试完成后,我们需要将 baseUrl 改回 https://jsonplaceholder.typicode.com

第二步:模拟 5xx 瞬时故障 (验证重试)

  1. 修改代码: 暂时将 ExternalApiService 中的 .uri(...) 调用修改为指向 httpbin.org503 状态码端点。

    文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java (修改)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ...
    @Retryable(...)
    public PostDTO getUserFirstPost(Integer userId) {
    log.info("正在尝试调用外部 API, userId: {}", userId);

    // 暂时修改 URI 来触发 503 错误
    List<PostDTO> posts = restClient.get()
    .uri("/status/503") // 硬编码到 503 错误端点
    .retrieve()
    // ... onStatus 处理器保持不变 ...
    // ...
  2. 重启 demo-admin 应用。

  3. 调用接口: GET /external/users/1

  4. 观察日志:

    • 您会看到日志 “正在尝试调用外部 API…” 打印了 3 次
    • 第一次和第二次调用之间,有 2 秒 的延迟。
    • 第二次和第三次调用之间,有 4 秒 的延迟。
    • 最终,您会看到 @Recover 方法中的错误日志 “调用外部 API 达到最大重试次数后仍然失败…”。
    • 前端收到的最终响应是 data: null

第三步:模拟 4xx 永久性故障 (验证快速失败)

  1. 修改代码: 再次修改 ExternalApiService 中的 .uri(...) 调用,这次指向 httpbin.org404 状态码端点。

    文件路径: demo-system/src/main/java/com/example/demosystem/service/ExternalApiService.java (修改)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ...
    @Retryable(...)
    public PostDTO getUserFirstPost(Integer userId) {
    log.info("正在尝试调用外部 API, userId: {}", userId);

    // 暂时修改 URI 来触发 404 错误
    List<PostDTO> posts = restClient.get()
    .uri("/status/404") // 硬编码到 404 错误端点
    .retrieve()
    // ... onStatus 处理器保持不变 ...
    // ...
  2. 重启 demo-admin 应用。

  3. 调用接口: GET /external/users/1

  4. 观察日志:

    • 您会看到日志 “正在尝试调用外部 API…” 只打印了 1 次
    • 没有任何重试相关的日志
    • 全局异常处理器捕获了 BusinessException,并立即向前端返回了 400 Bad RequestResult 错误响应。

测试完成后,请务必WebConfig.java 中的 baseUrlExternalApiService.java 中的 .uri() 调用恢复原状!通过本次重构和精准测试,我们的应用在面对外部服务故障时,变得无比“聪明”和“坚韧”,这正是企业级弹性设计的核心体现。


5. [并发与调度] 异步任务和虚拟线程

摘要: 并非所有任务都需要在一次 HTTP 请求的生命周期内同步完成。本章我们将深入 Spring Boot 的并发编程模型,学习如何将耗时操作(如发送邮件、生成报表)异步化以提升用户体验。我们将掌握传统的 @Async 异步任务和 @Scheduled 定时任务,并重点探索 Spring Boot 3.2+ 对 JDK 21+ 虚拟线程 (Virtual Threads) 的革命性支持,体验新一代高并发编程的魅力。

开始本节前的准备工作:清理认证代码

为了让我们的学习焦点更集中,我们将暂时移除之前添加的 Token 认证逻辑。请按照以下清单,删除对应的文件和代码:

1. 删除以下文件:

  • 文件路径: demo-framework/src/main/java/com/example/demoframework/interceptor/AuthInterceptor.java
  • 文件路径: demo-framework/src/main/java/com/example/demoframework/config/SpringDocConfig.java
  • 文件路径: demo-system/src/main/java/com/example/demosystem/controller/AuthController.java
  • 文件路径: demo-system/src/main/java/com/example/demosystem/dto/auth/LoginDTO.java

2. 修改 WebConfig.java:

打开 WebConfig.java 文件,删除注释掉注册 AuthInterceptor 的相关代码。

文件路径: demo-framework/src/main/java/com/example/demoframework/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
31
32
33
34
35
36
37
38
39
package com.example.demoframework.config;

// 移除 import com.example.demoframework.interceptor.AuthInterceptor;
import com.example.demoframework.interceptor.LogInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
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;
// 移除 private final AuthInterceptor authInterceptor;

// ... 已有的其他 Bean 和方法 ...

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(logInterceptor)
.addPathPatterns("/**");

/*
* ↓↓↓ 删除下面这段注册 AuthInterceptor 的代码 ↓↓↓
*/
// registry.addInterceptor(authInterceptor)
// .addPathPatterns("/**")
// .excludePathPatterns(
// "/auth/login",
// "/files/**",
// "/springboot-uploads/**",
// "/swagger-ui/**",
// "/v3/api-docs/**"
// );
}
}

完成以上清理工作后,请重新加载 Maven 项目,确保项目可以正常编译和启动。

5.1. 经典异步编程:@Async 与自定义线程池

5.1.1. 痛点分析与异步启用

场景故事 (痛点)

想象一下我们 UserService 中的 saveUser 方法。在成功创建用户后,产品经理提出了一个新需求:需要给新用户发送一封欢迎邮件。如果我们直接在 saveUser 方法里加入邮件发送逻辑,而这个邮件服务可能因为网络等原因需要耗时3秒,那么用户在前端点击“注册”按钮后,必须在页面上“转圈”等待3秒以上,才能收到“注册成功”的提示。这是一种糟糕的用户体验。

对于这类非核心、可延迟处理的耗时任务,最佳解决方案就是将其异步化:主线程(处理 HTTP 请求的线程)在触发邮件发送任务后,不等待其完成,而是立即返回响应,将真正的发送操作交给后台的另一个线程去处理。@Async 正是 Spring 为此提供的优雅实现。

第一步:启用异步 (@EnableAsync)

首先,我们需要在项目中显式地开启对异步任务的支持。最佳实践是在 demo-framework 模块中创建一个专门的配置类。

文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.java (新增)

1
2
3
4
5
6
7
8
9
package com.example.demoframework.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync // 关键注解:全局开启 Spring 的异步方法执行功能
public class AsyncConfig {
}

5.1.2. 实战:创建并调用异步方法

第二步:创建异步服务

我们在 demo-system 模块中创建一个专门处理通知的服务,并在其中定义一个异步方法。

文件路径: demo-system/src/main/java/com/example/demosystem/service/NotificationService.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.demosystem.service;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class NotificationService {

/**
* @Async 注解表明这是一个异步方法。
* Spring 会在调用此方法时,从一个线程池中获取一个线程来执行它,
* 而不会阻塞原始的调用方线程。
*/
@Async
@SneakyThrows // Lombok注解,用于优雅地处理受检异常(这里是InterruptedException)
public void sendWelcomeEmailAsync(String username) {
log.info("[TID:{}] [Async] 开始为用户 '{}' 发送欢迎邮件...", Thread.currentThread().getId(), username);
// 模拟耗时的邮件发送过程
Thread.sleep(3000);
log.info("[TID:{}] [Async] 欢迎邮件发送成功, 用户: '{}'", Thread.currentThread().getId(), username);
}
}

第三步:集成到业务流程

现在,我们在 UserServiceImpl 中调用这个新的异步方法。

文件路径: demo-system/src/main/java/com/example/demosystem/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
// ... imports ...
import com.example.demosystem.service.NotificationService; // 导入新服务

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

private final UserMapper userMapper;
private final NotificationService notificationService; // 通过构造函数注入

@Override
@Transactional(rollbackFor = Exception.class)
public Long saveUser(UserEditDTO dto) {
// ... 已有的业务校验 ...
User user = Convert.convert(User.class, dto);
user.setStatus(1);
user.setCreateTime(LocalDateTime.now());
userMapper.insert(user);

// 触发异步邮件发送,主流程不等待
notificationService.sendWelcomeEmailAsync(dto.getUsername());

log.info("[TID:{}] 'saveUser' 方法执行完毕,即将返回响应", Thread.currentThread().getId());
return user.getId();
}

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

第四步:验证效果

  1. 重启 demo-admin 应用。
  2. 调用 POST /users 接口新增一个用户。
  3. 观察现象:
    • API 响应: 您会发现接口几乎是立即返回了 201 Created 的成功响应。
    • 控制台日志: 日志的打印顺序将完美地展示异步执行流程。
1
2
3
4
5
6
7
8
9
// Tomcat HTTP 线程(例如 TID:48)立即完成并返回响应
[TID:48] 'saveUser' 方法执行完毕,即将返回响应
// 后台线程池中的一个线程(例如 TID:77)开始执行耗时任务
[TID:77] [Async] 开始为用户 'newUserFromAsyncTest' 发送欢迎邮件...

--- (此处会暂停约 3 秒钟) ---

// 3秒后,后台线程完成任务
[TID:77] [Async] 欢迎邮件发送成功, 用户: 'newUserFromAsyncTest'

结论: @Async 成功地将耗时3秒的邮件发送任务从主请求流程中剥离,极大地优化了用户体验。


5.1.3. 生产级配置:自定义线程池

严重警告: Spring Boot 在默认情况下使用的 SimpleAsyncTaskExecutor 是一个极其危险的线程池。它不会复用线程,而是为每一个 @Async 调用都创建一个全新的线程。在高并发场景下,这将迅速耗尽服务器的内存和线程资源,导致应用崩溃。在生产环境中,必须自定义线程池!

我们在 AsyncConfig 中创建一个 ThreadPoolTaskExecutor Bean 来覆盖默认配置。

文件路径: demo-framework/src/main/java/com/example/demoframework/config/AsyncConfig.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.demoframework.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

/**
* 自定义一个线程池 Bean。
* Spring 在查找 @Async 使用的线程池时,会默认寻找名为 "taskExecutor" 的 Bean。
* @return Executor
*/
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数:CPU核心数
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
// 最大线程数:CPU核心数 * 2
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 2);
// 任务队列容量
executor.setQueueCapacity(256);
// 线程名称前缀,便于日志追踪
executor.setThreadNamePrefix("MyAsync-");
// 初始化线程池
executor.initialize();
return executor;
}
}

重启应用并再次测试,您会发现异步任务日志中的线程名已经变成了 MyAsync-1,证明我们的自定义线程池已成功生效。


5.1.4. [进阶] 处理异步方法的返回值

如果我们需要获取异步任务的执行结果怎么办?@Async 方法可以通过返回 java.util.concurrent.CompletableFuture; 的实现类来做到这一点。

实践步骤

  1. 修改 NotificationService:
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
package com.example.demosystem.service;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
@Slf4j
public class NotificationService {
/**
* 使用 CompletableFuture 作为异步方法的返回值。
* 这是目前Spring官方推荐的最佳实践。
* @param username 用户名
* @return 一个 CompletableFuture 对象,它将在任务完成时包含结果字符串
*/
@Async
@SneakyThrows
public CompletableFuture<String> sendWelcomeEmailAsync(String username) {
log.info("[TID:{}] [Async] 开始为用户 '{}' 发送欢迎邮件...", Thread.currentThread().getId(), username);

// 模拟耗时的邮件发送过程
Thread.sleep(3000);

String result = "欢迎邮件已成功发送给 " + username;
log.info("[TID:{}] [Async] 欢迎邮件发送成功, 用户: '{}'", Thread.currentThread().getId(), username);

// 使用 CompletableFuture.completedFuture() 创建一个已完成的 CompletableFuture
// 这等同于之前 new AsyncResult<>(result) 的作用
return CompletableFuture.completedFuture(result);
}
}

5.1.4. [进阶] 处理异步方法的返回值

如果我们需要获取异步任务的执行结果怎么办?@Async 方法可以通过返回 java.util.concurrent.Future 的实现类来做到这一点。

实践步骤

  1. 修改 NotificationService:
1
2
3
4
5
6
7
8
9
10
11
12
13
// NotificationService.java
import org.springframework.scheduling.annotation.AsyncResult; // Spring 提供的 Future 实现
import java.util.concurrent.Future;

// ...
@Async
@SneakyThrows
public Future<String> getAsyncTaskResult(String taskName) {
log.info("开始执行异步任务: {}", taskName);
Thread.sleep(2000);
log.info("异步任务 '{}' 执行完毕", taskName);
return new AsyncResult<>("Result of " + taskName);
}
  1. 创建测试 Controller:

文件路径: demo-system/src/main/java/com/example/demosystem/controller/AsyncTestController.java (新增)

重要信息: 注意,为了方便测试,我们在SpringMVC定义的Token验证的

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

import com.example.demosystem.service.NotificationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.CompletableFuture; // 导入 CompletableFuture

@RestController
@RequiredArgsConstructor
@Slf4j
@Tag(name = "异步任务测试")
public class AsyncTestController {

private final NotificationService notificationService;

@GetMapping("/async-result")
@Operation(summary = "测试异步任务结果(非阻塞方式)")
public CompletableFuture<String> testAsyncResult() {
log.info("[Request Thread TID:{}] Controller 开始调用异步方法...", Thread.currentThread().getId());

CompletableFuture<String> futureResult = notificationService.getAsyncTaskResult("Task1");

log.info("[Request Thread TID:{}] Controller 调用异步方法完毕,立即返回CompletableFuture,请求线程被释放。", Thread.currentThread().getId());

// 直接返回 CompletableFuture 对象
// Spring MVC会接管它,等待它完成,然后将结果写入响应
// 我们可以链式地添加一些处理逻辑
return futureResult.thenApply(result -> {
// 这个 .thenApply 里的代码会在异步任务完成时执行,通常在另一个线程中
log.info("[Callback Thread TID:{}] 异步任务成功,准备返回结果: {}", Thread.currentThread().getId(), result);
return "成功获取到异步任务的结果: " + result;
});
}
}

验证效果: 调用 GET /async-result 接口。您会观察到,浏览器会等待约2秒后才收到响应。控制台日志会显示,Controller 先打印了“调用完毕”,然后才在调用 thenApply() 后打印“成功获取结果”,这清晰地展示了主线程被阻塞以等待异步结果的过程。


5.2. 定时任务调度:@Scheduled

场景故事 (痛点)

随着应用的运行,数据库中的操作日志表越来越大,影响查询性能。运维团队希望我们能开发一个功能,在每天流量最低的凌晨3点,自动将30天前的旧日志数据迁移到归档表中。这就需要一个无需人工干预、能按预定时间自动触发的机制。@Scheduled 注解正是 Spring 提供的解决此类需求的标准方案。

5.2.1. 核心语法与启用

在动手之前,我们首先需要掌握 @Scheduled 注解的几种核心调度策略。

属性核心作用计时基准
fixedRate固定速率执行从上一次任务的开始时间计算
fixedDelay固定延迟执行从上一次任务的结束时间计算
cronCron 表达式指定的时间点执行由表达式定义(如每天凌晨3点)

启用调度 (@EnableScheduling)

@EnableAsync 类似,我们需要先在项目中开启对定时任务的支持。

文件路径: demo-admin/src/main/java/com/example/demoadmin/SpringBootDemoApplication.java (修改)

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

// ... imports ...
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(scanBasePackages = "com.example")
@MapperScan("com.example.*.mapper")
@EnableCaching
@EnableRetry
@EnableScheduling // 启用 Spring 定时任务功能
public class SpringBootDemoApplication {
// ... main method ...
}

5.2.2. 实战:创建定时任务

现在,我们在 demo-system 模块中创建一个专门存放定时任务的组件,并一次性实现多种调度策略。

文件路径: demo-system/src/main/java/com/example/demosystem/tasks/SystemTasks.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.demosystem.tasks;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Component
@Slf4j
public class SystemTasks {

private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss");

/**
* fixedRate: 固定速率执行。
* 每隔 5 秒执行一次。计时是从任务的【开始时间】算起的。
*/
@Scheduled(fixedRate = 5000)
@SneakyThrows
public void runAtFixedRate() {
log.info("FixedRate Task - 开始 @ {}", formatter.format(LocalDateTime.now()));
Thread.sleep(2000); // 模拟耗时2秒
log.info("FixedRate Task - 结束 @ {}", formatter.format(LocalDateTime.now()));
}

/**
* fixedDelay: 固定延迟执行。
* 在上一次任务【执行完毕】后,延迟 3 秒再执行下一次。
*/
@Scheduled(fixedDelay = 3000)
@SneakyThrows
public void runAtFixedDelay() {
log.info("FixedDelay Task - 开始 @ {}", formatter.format(LocalDateTime.now()));
Thread.sleep(2000); // 模拟耗时2秒
log.info("FixedDelay Task - 结束 @ {}", formatter.format(LocalDateTime.now()));
}

/**
* cron 表达式,用于在特定时间点执行任务。
* 这个表达式表示 "在每天的凌晨 3 点 0 分 0 秒执行"。
* 我不建议去记忆Cron表达式的语法,使用场景单一,需要的时候用AI生成一个即可
*/
@Scheduled(cron = "0 0 3 * * ?")
public void archiveOldLogs() {
log.info("CRON Task - 开始执行日志归档任务 @ {}", LocalDateTime.now());
// ... 在此执行真实的日志归档数据库操作 ...
}
}

5.2.3. 验证与对比

重启应用并观察控制台日志,您会清晰地看到 fixedRatefixedDelay 的区别。

fixedRate (固定速率) 验证

fixedRate 强调的是按固定频率触发。它以任务的上一次开始执行的时间点为基准,来计算下一次的开始时间。

1
2
3
4
5
6
7
// 以下任务代码,其执行日志如下所示
@Scheduled(fixedRate = 5000)
public void runAtFixedRate() {
log.info("FixedRate Task - 开始 @ {}", ...);
Thread.sleep(2000);
log.info("FixedRate Task - 结束 @ {}", ...);
}
1
2
3
4
5
6
FixedRate Task - 开始 @ 11:56:02
FixedRate Task - 结束 @ 11:56:04
FixedRate Task - 开始 @ 11:56:07 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:09
FixedRate Task - 开始 @ 11:56:12 <-- 距离上一次【开始】正好 5 秒
FixedRate Task - 结束 @ 11:56:14

fixedDelay (固定延迟) 验证

fixedDelay 强调的是在上一次任务结束后,再等待一个固定的延迟时间。

1
2
3
4
5
6
7
// 以下任务代码,其执行日志如下所示
@Scheduled(fixedDelay = 3000)
public void runAtFixedDelay() {
log.info("FixedDelay Task - 开始 @ {}", ...);
Thread.sleep(2000);
log.info("FixedDelay Task - 结束 @ {}", ...);
}
1
2
3
4
5
6
FixedDelay Task - 开始 @ 11:56:02
FixedDelay Task - 结束 @ 11:56:04
FixedDelay Task - 开始 @ 11:56:07 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:09
FixedDelay Task - 开始 @ 11:56:12 <-- 距离上一次【结束】正好 3 秒
FixedDelay Task - 结束 @ 11:56:14

总结: fixedRate 适合对执行频率要求严格的任务(如每分钟的心跳检测),而 fixedDelay 适合不希望任务并发执行、需要保证执行间隔的场景(如轮询数据库)。


5.3. [前沿] JDK 21+ 虚拟线程:新时代的并发模型

5.3.1. 痛点:传统平台线程的瓶颈分析

5.1 节,我们配置了自定义线程池,但其底层使用的仍是传统的 平台线程。平台线程是 Java 线程的经典实现,其核心特征是与操作系统内核线程存在一对一的直接映射关系。

这种 1:1 的映射模型带来了两个固有的限制:

  1. 资源成本高: 每个平台线程都对应一个操作系统内核线程。创建和管理内核线程对操作系统而言是高成本操作,需要分配独立的栈内存并涉及昂贵的上下文切换。
  2. 数量有限: 由于其资源消耗,一台服务器能同时有效运行的平台线程数量通常被限制在几百到几千的规模。

在 Spring Boot 的传统阻塞式 Web 模型中,每个 HTTP 请求在处理期间都会独占一个平台线程。如果该请求中包含一个耗时 200ms 的数据库查询,那么在这 200ms 的 I/O 等待期间,这个平台线程会进入阻塞 (Blocking) 状态。尽管此时 CPU 可能处于空闲,但该线程资源被完全占用,无法执行任何其他任务。

核心瓶颈

当大量并发请求涌入,特别是当这些请求大多是 I/O 密集型(如等待数据库、外部 API 响应)时,有限的平台线程池会迅速被占满。后续的请求不得不进入队列等待,导致应用程序的吞吐量(TPS)达到上限,无法进一步扩展。


解决方案:虚拟线程

虚拟线程 是 JDK 引入的一种轻量级线程实现,由 JVM 直接调度和管理,而非操作系统。

它与平台线程的核心区别在于,虚拟线程与操作系统内核线程之间不再是 1:1 的映射关系。相反,大量的虚拟线程可以运行在一个由少量平台线程构成的池(这些平台线程被称为载体线程, Carrier Threads)之上。

虚拟线程的革命性机制在于其处理阻塞 I/O 的方式:

当一个虚拟线程执行一个阻塞操作时,JVM 会自动将其挂起,并从其载体平台线程上**“卸载” (unmount)**。这使得该平台线程可以立即被释放,去执行其他准备就绪的虚拟线程。当 I/O 操作完成后,JVM 会将原来的虚拟线程重新调度到任意一个可用的载体线程上继续执行。

这种机制带来了显著优势:

  1. 创建成本极低: 虚拟线程是 JVM 管理的轻量级实体,本质上是 Java 堆上的对象,不直接消耗宝贵的操作系统线程资源。
  2. 支持海量并发: 由于成本低廉,单个 JVM 实例可以轻松创建和管理数百万个虚拟线程。

通过这种高效的协作和调度机制,极少数的平台线程便能支撑起海量虚拟线程的并发执行。对于 I/O 密集型应用而言,这意味着线程不再是瓶颈,应用的吞吐能力和资源利用率得到极大提升。


5.3.2. [实践] 一键开启:在 Spring Boot 中启用虚拟线程

Spring Boot 3.2+ 对 JDK 21+ 的虚拟线程提供了无与伦比的、一等公民级的支持。开启它,简单到令人难以置信。
前置条件:

  1. 确保您的项目使用 JDK 21 或更高版本
  2. 确保您的 Spring Boot 版本为 3.2 或更高

由于我们的设置版本是17,这里就不测试了,核心也只是一个配置项而已

1. 修改配置文件

我们只需在 application.yml 中添加一行配置。

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

1
2
3
4
5
spring:
# ... 其他配置 ...
threads:
virtual:
enabled: true # 开启虚拟线程

就这么简单! 加上这行配置后,Spring Boot 会自动将内部的 Tomcat Web 服务器切换到使用虚拟线程来处理每一个进来的 HTTP 请求。


5.3.3. 性能对比:虚拟线程 vs. 传统线程池的适用场景

既然虚拟线程如此强大且易于开启,是否意味着我们之前配置的 @Async 传统线程池就过时了呢?答案是:并非如此。它们是为解决不同问题而设计的。

对比维度平台线程 (我们自定义的线程池)虚拟线程 (Spring Boot 自动管理)
核心本质珍贵的操作系统内核线程的直接映射JVM 管理的、轻量级的用户态线程
资源成本 (创建和上下文切换开销大)极低 (几乎没有额外开销)
最佳场景CPU 密集型任务 (如:复杂计算、图像处理、数据加密)I/O 密集型任务 (如:等待数据库、调用外部API、读写文件)
数量有限 (通常几十到几百)海量 (可轻松达到数百万)
使用方式通过 @Async 注解,用于后台异步计算通过配置开启,用于处理海量并发的 Web 请求
面试官深度辨析
今天 下午 3:00

既然 Spring Boot 3.2+ 开启虚拟线程如此简单,那我们为 @Async 自定义的 ThreadPoolTaskExecutor 线程池还有存在的必要吗?

求职者

非常有必要!它们解决的是不同维度的问题。

求职者

虚拟线程 的核心优势是解决海量并发 I/O 密集型 任务的等待问题,比如成千上万的用户同时请求我们的 API。它的设计目标是“不阻塞平台线程”,从而提高系统吞吐量。

求职者

而我们为 @Async 自定义的 平台线程池,其核心优势是处理后台的 CPU 密集型 任务。这类任务需要持续占用 CPU 进行计算,我们通过一个固定大小(比如等于 CPU 核心数)的线程池来执行它们,可以避免频繁的线程创建和销毁,并防止过多的线程因争抢 CPU 而导致性能下降。

明白了。所以可以总结为:用虚拟线程处理前端进来的海量 I/O 请求,用平台线程池处理后台的重量级计算任务。

求职者

完全正确。这正是 Spring Boot 3.2+ 所倡导的现代并发编程模型,两者相得益彰,而非互相替代。

总结: 虚拟线程是 Java 和 Spring Boot 在高并发领域的一项革命性进步。它以极低的成本,极大地提升了传统 I/O 密集型应用的性能和可伸缩性。在您的下一个项目中,如果使用了 JDK 21+ 和 Spring Boot 3.2+,请毫不犹豫地开启它。


6. [质量保障] Spring Test:构建可靠的应用

6.1. 基础构筑:测试环境与核心理念

在编写任何测试代码之前,我们必须先确保两件事:一是我们的“工具箱”是齐全的,二是我们的“指导思想”是正确的。本节将为您铺平这两条道路。

6.1.1. 依赖先行:检查我们的“测试工具箱”

一个好消息是,Spring Initializr 已经为我们准备好了一切。当您创建项目时,pom.xml 中会自动包含一个名为 spring-boot-starter-test 的依赖,它就是我们的“测试工具箱”。

文件路径: demo-system/pom.xml

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

这个工具箱里包含了四件宝物,让我们来逐一认识它们:

工具它的角色通俗解释
JUnit 5测试的“裁判员”它是执行我们测试代码的框架,负责运行测试、收集结果,并最终告诉我们测试是通过了 (Pass) 还是失败了 (Fail)。
Spring TestSpring世界的“翻译官”它是一座桥梁,让 JUnit 5 能够理解 Spring 的应用上下文(Application Context),使我们可以在测试中获取和使用 Spring 管理的 Bean。
AssertJ结果的“鉴定师”它提供了一套非常流畅、易读的 API 来验证我们的代码结果是否符合预期。例如 assertThat(name).isEqualTo("张三"); 读起来就像一句自然语言。
Mockito专业的“特技演员”它是最重要的工具之一。当我们的代码依赖其他复杂组件时,Mockito 可以创建一个“假的”替代品(称为 Mock),让我们能隔离地测试当前的代码。

6.1.2. 核心理念:为什么要“隔离”?

想象一下我们要测试一辆汽车的发动机。

  • 集成测试: 把发动机装进完整的汽车里,打着火,开上路跑一圈。这种方式很真实,能测试所有部件的协同工作,但如果车子没启动,你很难立刻知道问题是出在发动机、变速箱还是电路系统上。而且,每次测试都要开动整辆车,成本很高,速度也很慢。

  • 单元测试: 把发动机拆下来,放到一个专用的测试台上。我们用模拟的油管、电路和传动轴连接它,然后启动。如果发动机正常运转,我们就知道发动机本身是好的。这个过程非常快,而且能精准定位问题。

在我们的软件中,UserServiceImpl 就是“发动机”,而它依赖的 UserMapper 就是“变速箱”。纯单元测试的目标,就是把 UserServiceImpl 这台“发动机”单独拿出来测试,用 Mockito 创造一个假的“变速箱”(UserMapper)来配合它,从而确保 UserServiceImpl 自身的业务逻辑是绝对正确的。


6.1.3. 两种核心测试模式的澄清

基于以上理念,Spring Boot 的测试主要分为两种泾渭分明的模式。混淆这两种模式,是导致测试失败和混乱的根源

目标:快、准、狠地测试单个类
这种模式完全不涉及 Spring 容器,是我们测试 Service 层和工具类的首选。

  • 核心工具

    1. @ExtendWith(MockitoExtension.class):告诉 JUnit 5:“这场测试由 Mockito 负责!”
    2. @InjectMocks:标记我们要测试的“发动机”(例如 UserServiceImpl)。
    3. @Mock:标记需要被模拟的“变速箱”和其他依赖(例如 UserMapper)。
  • 特征

    • 不启动 Spring 容器。
    • 执行速度极快,以毫秒计。
    • 测试代码中绝对不会出现 @SpringBootTest@Autowired

目标:测试组件间的真实协作
当我们需要测试像 Controller、数据库交互这类依赖 Spring 框架功能的场景时,就需要启动一个真实的 Spring 容器。

  • 核心工具

    1. @SpringBootTest 或测试切片(如 @WebMvcTest):告诉 Spring:“请为我启动一个测试用的应用环境!”
    2. @Autowired:从 Spring 容器中获取真实的 Bean 实例。
    3. @MockBean:当我们需要在 Spring 容器中,用一个“假的”Bean 替换掉一个“真的”Bean 时使用。
  • 特征

    • 启动一个真实的 Spring 容器。
    • 执行速度相对较慢。
    • 用于测试跨层调用或框架集成点。

6.2. [核心实践] 纯单元测试:快如闪电的业务逻辑验证

我们实践的第一个、也是最重要的测试类型,就是纯单元测试。它的核心是隔离——把我们的“发动机”(UserServiceImpl)单独拿出来,用一个假的“变速箱”(UserMapper)来配合,以此验证“发动机”本身的逻辑是否正确。

核心工具: 本节我们将只使用 @ExtendWith(MockitoExtension.class), @InjectMocks, 和 @Mock。请注意,全程不会出现 @SpringBootTest


6.2.1. 场景设定:测试 UserServiceImpl

  • 被测试对象: UserServiceImpl
  • 被模拟的依赖: UserMapper
  • 被测试方法: findUserById(Long id)
  • 核心验证逻辑:
    1. UserMapper 返回一个 User 实体时,UserServiceImpl 能否正确地将其转换为 UserVO
    2. UserMapper 返回 null 时,UserServiceImpl 能否同样返回 null

6.2.2. 编写纯单元测试

现在,我们来创建测试文件。按照 Maven 的标准约定,测试代码应该放在 src/test/java 目录下,并且包结构与主代码(src/main/java)保持一致。

我们遵循遵循的是 BDD(行为驱动开发)标准,强调从用户行为和需求出发,通过 Given - When - Then 这种结构化方式来描述和验证软件功能

Given 是设定测试初始条件,像准备好输入数据等;

When 是执行要测试的方法或操作;

Then 则是验证操作后的输出结果是否符合预期 。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
package com.example.demosystem.service.impl;

import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

// 【关键】@ExtendWith(MockitoExtension.class): 告诉 JUnit 5 使用 Mockito 扩展。
// 它会负责初始化被 @Mock 和 @InjectMocks 注解的字段。
@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务纯单元测试")
class UserServiceImplTest {

// @InjectMocks: 告诉 Mockito 创建 UserServiceImpl 的一个真实实例。
// 然后,Mockito 会查找所有被 @Mock 标注的字段,并自动将它们注入到这个实例中。
// 这就是我们的“发动机”。
@InjectMocks
private UserServiceImpl userService;

// @Mock: 告诉 Mockito 创建一个 UserMapper 接口的模拟实现(一个“假的”对象)。
// 所有对这个 userMapper 实例的方法调用都会被 Mockito 拦截,而不会执行任何真实代码。
// 这就是我们的“假变速箱”。
@Mock
private UserMapper userMapper;

@Test
@DisplayName("当用户存在时,根据ID应能成功查询到用户信息")
void testFindUserById_whenUserExists() {
// --- GIVEN (给定一个预设条件) ---
// 1. 我们虚构一个 User 实体对象,作为 userMapper 的预期返回值。
User mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setNickname("测试用户");
mockUser.setStatus(1);

// 2. 定义模拟对象的行为(“打桩”):
// 当调用 userMapper.selectById(1L) 方法时,我们并不执行真实的 SQL,
// 而是让 Mockito 直接返回上面创建的 mockUser 对象。
when(userMapper.selectById(1L)).thenReturn(mockUser);

// --- WHEN (执行我们要测试的动作) ---
// 调用我们真正要测试的 service 方法。
UserVO resultVO = userService.findUserById(1L);

// --- THEN (验证结果是否符合预期) ---
// 使用 AssertJ 进行流式断言,验证 service 的转换逻辑是否正确。
assertThat(resultVO).isNotNull();
assertThat(resultVO.getId()).isEqualTo(1L);
assertThat(resultVO.getUserName()).isEqualTo("testuser");
assertThat(resultVO.getNickname()).isEqualTo("测试用户");
}

@Test
@DisplayName("当用户不存在时,根据ID查询应返回null")
void testFindUserById_whenUserNotExists() {
// --- GIVEN ---
// 定义行为:当调用 userMapper.selectById 一个不存在的ID (如99L) 时,返回 null。
when(userMapper.selectById(99L)).thenReturn(null);

// --- WHEN ---
UserVO resultVO = userService.findUserById(99L);

// --- THEN ---
// 验证当 mapper 返回 null 时,service 是否也正确地返回了 null。
assertThat(resultVO).isNull();
}
}

执行与观察:
您可以直接在 IDEA 中运行这个测试类或单个测试方法。您会发现测试几乎是瞬时完成的。

核心结论: 我们成功地、完全隔离地验证了 UserServiceImpl 的内部业务逻辑,而整个过程完全没有启动 Spring Boot 应用,也没有连接数据库。这正是单元测试强大且高效的魅力所在。


6.2.3. [深入] 行为验证:verify 的使用

痛点:如何测试 void 方法?

6.2.2 中,我们测试的 findUserById 方法有返回值,所以我们可以通过断言返回值来判断方法是否正确。但如果一个方法没有返回值(void),比如 deleteUser,我们该如何测试它呢?

1
2
3
4
5
6
7
8
9
// UserService.java
void deleteUserById(Long id);

// UserServiceImpl.java
@Override
public void deleteUserById(Long id) {
// 核心逻辑就是调用 mapper
userMapper.deleteById(id);
}

我们无法断言返回值,但我们的测试目标是:确保 userService.deleteUserById(1L) 在被调用时,其内部的 userMapper.deleteById(1L) 方法也必须被正确地调用了。

这就是行为验证的用武之地,而 Mockito.verify() 正是实现这一目标的核心工具。


编写行为验证测试

我们回到 UserServiceImplTest.java,为 deleteUser 方法添加一个新的测试用例。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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 and existing class structure) ...
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

// ... (within UserServiceImplTest class) ...

@Test
@DisplayName("删除用户时,应正确调用Mapper的deleteById方法")
void testDeleteUser_shouldCallMapperCorrectly() {
// --- GIVEN ---
// 对于 void 方法,通常我们不需要"打桩",因为没有返回值需要我们去模拟。
// 当然,如果 `userMapper.deleteById` 会抛出异常,我们也可以用 when(...).thenThrow(...) 来打桩,
// 这将在 6.2.5 节讲解。
// 注意,如果这里测试失败的话是我们之前的service使用Hutool进行了判断,他和mock并不是很兼容需要删除掉判断检测是否已存在
Long userIdToDelete = 1L;

// --- WHEN ---
// 执行我们要测试的 void 方法
userService.deleteUserById(userIdToDelete);

// --- THEN ---
// 【关键】使用 Mockito.verify() 来验证行为
// 这行代码的意思是:“请验证 userMapper 这个模拟对象,
// 它的 deleteById 方法是否被调用了,并且调用次数是否恰好为 1 次,
// 且传入的参数是否就是 userIdToDelete (1L)?”
// 如果上述任一条件不满足(例如,没被调用、被调用了2次、或参数是2L),测试将失败。
verify(userMapper, times(1)).deleteById(userIdToDelete);
}

verify 的更多用法

verify 是一个非常灵活的工具,它还有很多强大的验证模式:

  • 验证从未被调用:

    1
    2
    // 验证 userMapper 的 insert 方法在本次测试中从未被调用过
    verify(userMapper, never()).insert(any(User.class));
  • 验证至少/至多调用次数:

    1
    2
    3
    4
    // 验证至少被调用了一次
    verify(userMapper, atLeast(1)).deleteById(1L);
    // 验证至多被调用了五次
    verify(userMapper, atMost(5)).deleteById(1L);
  • 使用参数匹配器: 当我们不关心传入的具体参数值,只关心类型时:

    1
    2
    // 验证 deleteById 方法被调用过,且参数是任意的 Long 类型值
    verify(userMapper).deleteById(anyLong());

核心结论:
when(...).thenReturn(...) 用于设定(Given)模拟对象的返回值
verify(...) 用于验证(Then)模拟对象的行为是否发生。

对于 void 方法,行为验证 (verify) 是我们最主要的、有时也是唯一的测试手段。它确保了被测试单元和其协作者之间的“交互约定”是正确的。


6.2.4. [深入] 深入测试:验证交互、细节与异常

在真实的业务场景中,一个方法通常不只是简单的数据转换。它包含了前置校验、与多个依赖组件的交互、内部状态处理以及异常情况的抛出。一个专业的单元测试必须能够全面地覆盖这些复杂的逻辑。

现在,更完整的 UserServiceImpl 中的 saveUser 方法为目标,来编写一套能体现专业水准的单元测试。

被测试方法源码回顾:
文件路径: demo-system/src/main/java/com/example/demosystem/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
@Override
public Long saveUser(UserEditDTO dto) {
// 1. 业务校验:断言用户名不存在
User existingUser = userMapper.selectOne(
new QueryWrapper<User>().lambda().eq(User::getUsername, dto.getUsername()));
if (existingUser != null) {
// 如果用户已存在,抛出自定义业务异常
throw new BusinessException(ResultCode.UserAlreadyExists);
}

// 2. DTO -> PO: 使用Convert一步到位
User user = Convert.convert(User.class, dto);

// 3. 与另一个服务交互
notificationService.sendWelcomeEmailAsync(dto.getUsername());

// 4. 设置内部默认值
user.setStatus(1);
user.setCreateTime(LocalDateTime.now());

// 5. 核心持久化操作
userMapper.insert(user);

return user.getId();
}

测试“成功路径”:verifyArgumentCaptor 的组合拳

ArgumentCaptor 是 Mockito 框架中的一个工具类,主要用于在单元测试中捕获方法调用时的参数值。在测试过程中,我们经常需要验证某个方法是否被调用,以及调用时传入的参数值是否符合预期。ArgumentCaptor 提供了一种便捷的方式来捕获和断言这些参数值。使用 ArgumentCaptor,您可以:

  • 捕获特定方法的参数。
  • 对捕获的参数进行断言,确保它们符合测试的预期。
  • 在测试中重用捕获的参数值。

目标: 验证当用户名不存在时,saveUser 方法能否正确执行所有预期操作。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
package com.example.demosystem.service.impl;

import com.example.demosystem.dto.user.UserEditDTO;
import com.example.demosystem.entity.User;
import com.example.demosystem.mapper.UserMapper;
import com.example.demosystem.service.NotificationService;
import com.example.demosystem.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;

// 【关键】@ExtendWith(MockitoExtension.class): 告诉 JUnit 5 使用 Mockito 扩展。
// 它会负责初始化被 @Mock 和 @InjectMocks 注解的字段。
@ExtendWith(MockitoExtension.class)
@DisplayName("用户服务纯单元测试")
class UserServiceImplTest {

// @InjectMocks: 告诉 Mockito 创建 UserServiceImpl 的一个真实实例。
// 然后,Mockito 会查找所有被 @Mock 标注的字段,并自动将它们注入到这个实例中。
// 这就是我们的“发动机”。
@InjectMocks
private UserServiceImpl userService;

// @Mock: 告诉 Mockito 创建一个 UserMapper 接口的模拟实现(一个“假的”对象)。
// 所有对这个 userMapper 实例的方法调用都会被 Mockito 拦截,而不会执行任何真实代码。
// 这就是我们的“假变速箱”。
@Mock
private UserMapper userMapper;

@Mock
private NotificationService notificationService;

@Test
@DisplayName("保存用户成功路径:应设置默认值、调用邮件服务和插入方法")
void testSaveUser_happyPath() {
// --- GIVEN ---
// 1. 准备输入数据
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("newUser");
newUserDTO.setEmail("newUser@example.com");

// 2. 设定前置校验的行为:模拟用户不存在
// any() 是一个参数匹配器,表示我们不关心具体的查询条件是什么
when(userMapper.selectOne(any())).thenReturn(null);


// 3. 准备参数捕获器,用于捕获传递给 insert 方法的 User 对象
ArgumentCaptor<User> userArgumentCaptor = ArgumentCaptor.forClass(User.class);

// --- WHEN ---
userService.saveUser(newUserDTO);


// --- THEN ---
// 1. 验证与 NotificationService 的交互
// 验证 notificationService.sendWelcomeEmailAsync 方法被调用了1次,
// 且传入的参数是 "newUser"
verify(notificationService, times(1)).sendWelcomeEmailAsync("newUser");

// 2. 验证与 UserMapper 的交互,并捕获参数
// 验证 userMapper.insert 方法被调用了1次,并捕获传入的 User 对象
verify(userMapper, times(1)).insert(userArgumentCaptor.capture());

// 3. 对捕获的参数进行详细断言
User capturedUser = userArgumentCaptor.getValue();
// User(id=null, username=newUser, password=null, email=newUser@example.com, status=1, createTime=2025-08-20T19:35:55.725441800)
System.out.println(capturedUser);
}
}

测试“失败路径”:验证异常与防御性编程

目标: 验证当用户名已存在时,saveUser 方法能否如期抛出 BusinessException,并且不会执行后续的任何操作。

文件路径: demo-system/src/test/java/com/example/demosystem/service/impl/UserServiceImplTest.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
// ... (imports and existing class structure) ...
import static org.assertj.core.api.Assertions.assertThatThrownBy;

// ... (within UserServiceImplTest class) ...

@Test
@DisplayName("保存用户失败路径:当用户名已存在时,应抛出业务异常")
void testSaveUser_whenUserExists_shouldThrowException() {
// --- GIVEN ---
// 1. 准备输入数据
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("existingUser");

// 2. 设定前置校验的行为:模拟用户已存在
when(userMapper.selectOne(any())).thenReturn(new User());

// --- WHEN & THEN ---
// 3. 使用 AssertJ 的 assertThatThrownBy 来验证异常
assertThatThrownBy(() -> {
// 将会抛出异常的调用放在这里
userService.saveUser(newUserDTO);
})
// 断言抛出的异常是 BusinessException 类型
.isInstanceOf(BusinessException.class)
// 并且,断言异常的消息与预期的错误码消息一致
.hasMessage(ResultCode.UserAlreadyExists.getMessage());

// 4. 【关键】防御性验证:确保在校验失败后,后续的交互从未发生
verify(notificationService, never()).sendWelcomeEmailAsync(any());
verify(userMapper, never()).insert(any(User.class));
}

核心结论:
通过组合使用 when (设定条件)、verify (验证交互)、ArgumentCaptor (捕获细节) 和 assertThatThrownBy (验证异常),我们为 saveUser 这个相对复杂的业务方法构建了一套全面而健壮的单元测试防护网。它不仅能验证成功时的结果,更能确保失败时的处理逻辑也如我们预期般稳固。


6.3. [核心实践] 集成测试:验证组件间的协同工作

6.2 节,我们像是在测试台上测试一台独立的“发动机”(UserServiceImpl)。现在,我们要把“发动机”装回“车身”,并连接上“仪表盘”和“控制电路”(Controller 和 Spring MVC 框架),然后测试这辆“汽车”作为一个整体系统能否正确响应我们的操作。这就是集成测试

6.3.1. 为何需要集成测试?(灵魂拷问:Postman 不香吗?)

在学习本节前,相信很多读者(包括正在阅读的您)心中都会有一个巨大的疑问:

“为什么我要学习一套这么复杂的测试框架?我用 Postman 或其他 API 工具,对着启动好的程序发一个真实的 POST 请求,然后看看返回结果,不也是测试吗?那样不是更简单、更真实吗?”

您能提出这个问题,说明您已经思考到了测试策略的核心。这个问题的答案,正是区分“手动测试”与“自动化质量保障”的关键。

对比维度您的直觉 (如 Postman)我们正在学的方法 (MockMvc)
测试目标验证一个完整、正在运行的系统验证开发中的 Controller 代码是否正确
运行环境需要手动启动整个 Spring Boot 应用、数据库、Redis…无需启动应用,在内存中模拟 Web 环境
执行速度 (秒级甚至分钟级) (毫秒级)
自动化难以集成到自动化构建流程(CI/CD)极易集成,是 CI/CD 的核心环节
稳定性 (测试结果易受网络、数据库数据变化的影响) (依赖被 Mock,每次运行结果都一样,可重复)
最佳用途开发完成后、部署前的手动探索性测试系统功能验收开发过程中,作为代码提交前的自动化质量卡点

核心价值:
您用 Postman 的方式,是一次性的功能验证。而我们学习 MockMvc,是为了构建一套可重复的、自动化的安全网

在团队协作中,这套安全网可以确保任何人的任何一次代码提交,都不会意外地破坏掉您或其他同事编写的 API 接口。它将“质量保障”从一件靠人力和自觉性的事情,变成了一个由机器自动执行的、可靠的工程流程。这,就是它虽然复杂,但却无可替代的理由。


6.3.2. 前置准备:解决多模块的“上下文”难题

现在,我们正式开始集成测试的准备工作。首先,必须解决那个在多模块项目中必定会遇到的“大坑”。

问题: 直接在 demo-system 这样的子模块中运行集成测试(如 @WebMvcTest),会因为找不到主启动类而失败,抛出 Unable to find a @SpringBootConfiguration 异常。

解决方案:为 demo-system 模块的测试环境创建一个专门的、轻量级的启动类。

  1. demo-system 模块的 src/test/java 目录下,创建一个与主代码平行的包,例如 com.example.demosystem
  2. 在这个包里创建一个新的 Java 类 TestApplication.java

文件路径: demo-system/src/test/java/com/example/demosystem/TestApplication.java (新增)

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

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

/**
* 这个类是专门为 demo-system 模块的测试环境提供 Spring Boot 上下文。
* 它本身可以是一个空壳,注解会完成所有工作。
*/
@SpringBootApplication
// 如果你的其他模块(如 demo-framework)的 Bean 也需要被扫描到,
// 可以加上 @ComponentScan 注解来扩大扫描范围。
// 假设你的所有模块包都以 com.example 开头
@ComponentScan("com.example")
@MapperScan("com.example.demosystem.mapper")
public class TestApplication {
}

完成了这个简单的准备工作,我们就为后续所有集成测试铺平了道路。


6.3.3. [实战] 编写精准的 Controller 集成测试

在完成了前置的环境准备后,我们现在可以正式为 UserController 编写集成测试。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.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
package com.example.demosystem.controller;

import com.example.democommon.common.ResultCode;
import com.example.demosystem.service.UserService;
import com.example.demosystem.vo.UserVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;


// @WebMvcTest 会创建一个只包含 Web 层组件的、轻量级的 Spring 测试上下文。
// 由于我们创建了 TestApplication.java, 它会作为这个上下文的入口。
@WebMvcTest(controllers = UserController.class)
@DisplayName("用户控制器Web层测试")
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

// 我们只需要 Mock 掉 Controller 直接依赖的 Service 即可。
// 如果测试启动时因为其他 @Configuration (如 CacheConfig) 而失败,
// 才需要考虑使用 @ComponentScan.Filter 进行排除。
@MockBean
private UserService userService;

@MockBean
private CacheManager manager;

@Test
@DisplayName("GET /users/{id} - 当用户存在时,应返回成功和正确的用户信息")
void testGetUserById_whenUserExists() throws Exception {
// --- GIVEN ---
UserVO mockUserVO = new UserVO();
mockUserVO.setId(1L);
// 根据 UserVO 的真实字段名,我们应该设置 name 属性
mockUserVO.setName("testuser");

when(userService.findUserById(1L)).thenReturn(mockUserVO);

// --- WHEN & THEN ---
mockMvc.perform(get("/users/1"))
// 验证 HTTP 状态码为 200
.andExpect(status().isOk())
// 验证返回的 JSON 内容
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
// 验证 JSON 内容的 code 属性
.andExpect(jsonPath("$.code").value(200))
// 验证 JSON 内容的 message 属性
.andExpect(jsonPath("$.data.id").value(1L))
// 验证 JSON 内容的 name 属性
// 根据 @JsonProperty("user_name"),正确的 jsonPath 表达式应为 user_name
.andExpect(jsonPath("$.data.user_name").value("testuser"));
}

@Test
@DisplayName("GET /users/{id} - 当用户不存在时,应返回错误信息")
void testGetUserById_whenUserNotExists() throws Exception {
// --- GIVEN ---
// 模拟 Service 层返回 null
when(userService.findUserById(99L)).thenReturn(null);

// --- WHEN & THEN ---
// 根据 UserController 的真实逻辑,当用户不存在时,它会返回一个包含错误信息的 Result 对象
mockMvc.perform(get("/users/99"))
.andExpect(status().isOk()) // HTTP 状态码依然是 200
.andExpect(jsonPath("$.code").value(ResultCode.ERROR.getCode())) // 业务码是错误码
.andExpect(jsonPath("$.message").value("用户不存在")) // 验证错误信息
.andExpect(jsonPath("$.data").isEmpty()); // data 字段应为空
}
}

6.3.4. [实战] 深入 MockMvc:测试 POST 请求与请求体

我们已经成功地测试了 GET 请求,确保了“读”操作的正确性。接下来,我们将更进一步,学习如何测试“写”操作,这需要我们向 Controller 发送一个带有 JSON 请求体(Request Body)的 POST 请求。

目标: 为 UserController 中的 POST /users (新增用户) 接口编写集成测试,确保它能正确接收、处理 DTO,并返回预期的创建成功响应。

我们将继续在 UserControllerTest.java 中添加新的测试方法。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.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

package com.example.demosystem.controller;
import com.example.demosystem.dto.user.UserEditDTO;
import com.example.demosystem.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.cache.CacheManager;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
* 用户控制器 Web 层测试
*/
@WebMvcTest(controllers = UserController.class)
@DisplayName("用户控制器Web层测试")
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@MockBean
private UserService userService;

@MockBean
private CacheManager cacheManager;

@Autowired
private ObjectMapper jacksonObjectMapper;

@Test
@DisplayName("POST /users - 使用合法的DTO应能成功创建用户")
void testSaveUser_withValidDTO_shouldSucceed() throws Exception {
// --- GIVEN ---
// 1. 准备一个用于创建用户的 DTO 对象
UserEditDTO newUserDTO = new UserEditDTO();
newUserDTO.setUsername("newUser");
newUserDTO.setEmail("newUser@example.com");
newUserDTO.setPassword("password123");

// 2. "打桩" Service 层:
// 告诉 Mockito,当 userService.saveUser 方法被以任何 UserEditDTO 对象调用时,
// 都应该返回一个虚构的新用户 ID,例如 100L。
// 我们使用 any() 是因为 Service 层的详细逻辑已在单元测试中验证过,
// 在这里我们只关心 Controller 和框架的集成。
when(userService.saveUser(any(UserEditDTO.class))).thenReturn(100L);


// --- WHEN & THEN ---
mockMvc.perform(post("/users") // 1. 模拟一个 POST 请求
// 2. 【关键】设置请求头的 Content-Type 为 application/json
// 这告诉 Controller 我们发送的是 JSON 数据
.contentType(MediaType.APPLICATION_JSON)
// 3. 【关键】使用 objectMapper 将我们的 DTO 对象序列化为 JSON 字符串
// 并将其作为请求体发送
.content(jacksonObjectMapper.writeValueAsString(newUserDTO)))
// 4. 对响应进行断言
// 根据 UserController 的实现,成功创建应返回 HTTP 201 Created
.andExpect(status().isCreated())
// 验证我们自定义响应体中的业务码和数据
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data").value(100L));
}
}

核心结论:
通过这个测试,我们掌握了模拟带有请求体的 POST 请求的关键步骤:

  1. 使用 @AutowiredObjectMapper 来将 Java 对象转换为 JSON 字符串。
  2. perform() 中使用 .contentType() 来声明请求体格式。
  3. 使用 .content() 来承载 JSON 字符串作为请求体。
  4. 使用 status().isCreated() 来断言 RESTful 风格的创建成功状态码。

至此,您已经掌握了测试 Controller 中最核心的“读”(GET)和“写”(POST)操作的能力。


6.3.5. [进阶] 深入 MockMvc:测试校验失败与异常处理

到目前为止,我们测试的都是“成功路径”(Happy Path)。但在真实世界中,代码的健壮性更多地体现在它如何优雅地处理错误。本节,我们将学习如何使用 MockMvc 来验证两种最常见的失败场景:输入校验失败业务异常抛出

场景一:测试 Bean Validation 校验失败

我们的 UserControllersaveUser 方法上使用了 @Validated 注解,它会根据 UserEditDTO 中定义的规则(如 @NotBlank)进行输入校验。如果校验失败,我们的 GlobalExceptionHandler 会捕获 MethodArgumentNotValidException 并返回一个 HTTP 400 响应。现在,我们就来测试这个流程。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java (添加新方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("POST /users - 当用户名已存在时,应返回用户已存在的错误")
@SneakyThrows
void testSaveUser_whenUsernameExists_shouldReturnBusinessError() {
// --- GIVEN ---
// 1. 准备一个合法的 DTO
UserEditDTO userEditDTO = new UserEditDTO();
userEditDTO.setUsername("TestUser");
userEditDTO.setEmail("test@example.com");

// 2. 【关键】"打桩" Service 层,让它在被调用时直接抛出我们预设的业务异常
when(userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException(ResultCode.UserAlreadyExists));

// --- WHEN & THEN ---
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(jacksonObjectMapper.writeValueAsString(userEditDTO)))
.andExpect(status().is4xxClientError())
.andExpect(jsonPath("$.code").value(ResultCode.UserAlreadyExists.getCode()));
}

场景二:测试 Service 层抛出的业务异常

Controller 的职责之一就是调用 Service。如果 Service 抛出了一个业务异常(BusinessException),Controller 并不直接处理,而是交由 GlobalExceptionHandler 来捕获并转换为统一的 JSON 响应。我们就来测试这个完整的“异常传递与处理”链路。

文件路径: demo-system/src/test/java/com/example/demosystem/controller/UserControllerTest.java (添加新方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("POST /users - 当用户名已存在时,应返回用户已存在的错误")
@SneakyThrows
void testSaveUser_whenUsernameExists_shouldReturnBusinessError() {
// --- GIVEN ---
// 1. 准备一个合法的 DTO
UserEditDTO userEditDTO = new UserEditDTO();
userEditDTO.setUsername("TestUser");
userEditDTO.setEmail("test@example.com");
userEditDTO.setPassword("123456");

// 2. 【关键】"打桩" Service 层,让它在被调用时直接抛出我们预设的业务异常
when(userService.saveUser(any(UserEditDTO.class))).thenThrow(new BusinessException(ResultCode.UserAlreadyExists));

// --- WHEN & THEN ---
mockMvc.perform(post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(jacksonObjectMapper.writeValueAsString(userEditDTO)))
.andExpect(jsonPath("$.code").value(ResultCode.UserAlreadyExists.getCode()));
}

摘要: 恭喜您!坚持学习到这里,您已经走完了从 Spring Boot 基础到核心实践的关键一步。我们一起从零开始,搭建项目、管理配置、实践 AOP、操作数据库、实现事务与缓存、调用外部 API,并最终为我们的代码构建了一套专业、自动化的测试安全网。您现在掌握的,不仅仅是 Spring Boot 的使用方法,更是一套符合现代软件工程标准的开发思想与流程。

我们所学的,是构建任何一个坚实系统的“地基”。UserService 虽然简单,但“麻雀虽小,五脏俱全”,它身上凝聚了我们对配置、分层、数据处理、接口设计和质量保障的全部心血。

但这仅仅是开始。一个真正强大的、企业级的分布式系统,还需要在更多维度上进行深化和扩展。接下来的学习路线,将为您揭开这幅宏伟蓝图的全貌。