Java(六):6.0 [精写] Java核心开发库


第一章:Hutool:“为简化开发而生”

摘要: 在本章中,我们将深入探索 Hutool 这个强大且小而全的 Java 工具类库。我们将聚焦于其核心价值——“避免重复造轮子”,并通过“速查表 + 综合实战”的方式,展示如何在日常开发中优雅、高效地处理字符串、集合、日期、IO 等常见任务,最终显著提升我们的代码质量与开发幸福感。

我们首先需要理解,Hutool 为我们解决了什么核心痛点。在原生 Java 开发中,处理一些基础任务,如日期格式化、文件读写、字符串校验等,代码往往显得冗长且需要处理各种异常。Hutool 的哲学是“大道至简”,它将这些常用操作封装为简单、易用的静态方法,让 Java 开发能“像用脚本语言一样简单”。它就像一把“瑞士军刀”,能极大提升我们的开发效率。


1.1. 引入与配置

要在我们的项目中使用 Hutool,第一步就是通过构建工具(如 Maven 或 Gradle)将其引入。

在 Maven 项目中,我们只需在 pom.xml 文件中添加以下依赖即可。通常,我们直接引入 hutool-all,它包含了 Hutool 的所有模块,方便我们使用其全部功能。

1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.29</version>
</dependency>

hutool-all 的大小仅为 7MB 左右,对于绝大多数现代项目来说,这点体积是完全可以接受的。


1.2. 核心工具集

在引入 Hutool 之后,我们最先接触和使用的,必然是它的核心工具集。这部分工具覆盖了 Java 开发中超过 80%的重复性工作,是 Hutool 的基石,由于这是一个庞大的数据库,我仅列举日常开发中我最常用的几个方法,更加具体的方法可以访问 简介 | Hutool

1.2.1. 字符串处理 (StrUtil)

背景: 字符串操作是所有业务逻辑的基础。无论是用户输入校验、动态消息拼接,还是数据格式转换,我们都需要大量处理字符串。JDK 原生的 String 类功能有限,且对 null 的处理常常导致 NullPointerException,而 StringBuilder 的拼接方式也较为繁琐。

解决方案: Hutool 的 StrUtil 提供了一套静态方法,优雅地解决了这些痛点。它的方法不仅考虑了 null 安全,而且功能极其丰富,比如它的 isBlank 就比 JDK 的 isEmpty 更符合业务直觉。

核心 API 速查表

方法功能描述
isBlank(主力) 判空增强,能识别 null、空字符串、纯空白符。
isNotBlankisBlank 的反义,推荐在业务判断中使用。
format模板格式化,使用 {} 占位符,可读性远超 + 拼接。
split高效分割字符串,并对结果自动执行 trim
join使用指定分隔符连接数组或集合。
removePrefix移除字符串前缀,提供忽略大小写版本。
removeSuffix移除字符串后缀,提供忽略大小写版本。
toCamelCase将下划线命名的字符串转为驼峰命名。
toUnderlineCase将驼峰命名的字符串转为下划线命名。

实战场景:规范化 API 参数与生成动态响应

场景: 我们正在编写一个后端服务,需要接收来自不同渠道的商品编码(可能带有 "SKU_""sku_" 前缀),并需要将 Java 风格的字段名(productName)转换为数据库风格(product_name)进行查询,最后根据结果生成一条动态的、面向用户的提示信息。

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;

import cn.hutool.core.util.StrUtil;

public class Main {
public static void main(String[] args) {
// 1. 规范化输入:无论前缀大小写,都统一移除
String rawSku1 = "SKU_A-001";
String rawSku2 = "sku_B-002";
String normalizedSku1 = StrUtil.removePrefixIgnoreCase(rawSku1, "SKU_");
String normalizedSku2 = StrUtil.removePrefixIgnoreCase(rawSku2, "SKU_");
System.out.println(StrUtil.format("规范化前缀: '{}' -> '{}'", rawSku1, normalizedSku1));
System.out.println(StrUtil.format("规范化前缀: '{}' -> '{}'", rawSku2, normalizedSku2));


System.out.println("---");

// 2. 转换命名风格:适配数据库查询
String javaField = "productName";
String dbColumn = StrUtil.toUnderlineCase(javaField);
System.out.println(StrUtil.format("Java字段: '{}' -> '{}'", javaField, dbColumn));

System.out.println("---");

// 3. 校验与动态响应:检查用户输入并生成提示
String userInput = " ";
if (StrUtil.isBlank(userInput)) {
// 使用 format 生成动态错误提示
System.out.println(StrUtil.format("输入无效,用户名不能为空,当前输入:'{}'", userInput));
}
}
}

小结: StrUtil是 Hutool 中使用频率最高的工具类之一。它通过提供丰富的、null 安全的方法,极大地简化了日常的字符串处理工作,提升了代码的可读性和健壮性。


1.2.2. 万能类型转换器 (Convert)

背景: 从 HTTP 请求、配置文件、数据库等来源获取的数据,其类型往往是 StringObject。我们需要将它们转换为业务逻辑中需要的强类型,如 Integer, Date, List 等。JDK 原生的转换方式(如 Integer.parseInt)在遇到 null 或格式错误时会直接抛出异常,迫使我们编写大量的 try-catchif-else 来防御。

解决方案: Convert 类是 Hutool 中解决此类问题的“银弹”。它提供了一整套 toXXX 方法,可以实现任意类型到目标类型的转换。其核心优势在于 健壮性:当转换失败时,它不会抛出异常,而是返回一个我们指定的默认值,让代码逻辑更加平滑。

核心 API 速查表

方法功能描述
toInt(主力) 转为 int,失败或 null 则返回默认值。
toLong转为 long,提供默认值。
toStr转为 String,提供默认值。
toBool转为 Boolean,提供默认值。
toDate智能转换任意对象(字符串、时间戳)为 Date
toList将任意对象(如集合、数组)转为指定泛型的 List
toSBC / toDBC全角/半角转换,常用于处理用户输入。

实战场景:安全地加载多样化的配置项

场景: 我们的应用需要从一个 Map(或 .properties 文件)中加载配置。这些配置项的格式可能五花八门:有的是数字,有的可能是 null,有的可能是格式错误的字符串。我们需要安全地将它们加载到程序变量中。

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;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;

import java.util.Date;
import java.util.Map;

public class Main {
public static void main(String[] args) {
// 模拟从配置文件读取的原始数据,使用 MapUtil.builder() 创建允许 null 值的 Map
Map<Object, Object> configMap = MapUtil.builder()
.put("server.port", "808a") // 格式错误的端口号
.put("server.timeout", 30000L) // 正确的 Long 类型
.put("feature.enabled", "true") // 布尔字符串
.put("app.name", "My Awesome App")
.put("db.connection", null) // 一个 null 值
.put("event.startTime", "2025-01-01") // 日期字符串
.build();

// 使用 Convert 进行安全的加载
Integer port = Convert.toInt(configMap.get("server.port"));
long timeout = Convert.toLong(configMap.get("server.timeout"), 60000L);
boolean isFeatureEnabled = Convert.toBool(configMap.get("feature.enabled"), false);
String appName = Convert.toStr(configMap.get("app.name"), "Default App");
String dbConnection = Convert.toStr(configMap.get("db.connection"), "jdbc:default:connection");
Date startTime = Convert.toDate(configMap.get("event.startTime"));

System.out.println(StrUtil.format("端口号 (仅提取出数字位): {}", port)); // 端口号 (仅提取出数字位): 808
System.out.println(StrUtil.format("超时时间 (Long转Long): {}", timeout)); // 超时时间 (Long 转 Long): 30000
System.out.println(StrUtil.format("特性开关 (字符串转布尔): {}", isFeatureEnabled)); // 特性开关 (字符串转布尔): true
System.out.println(StrUtil.format("应用名称 (正常转换): '{}'", appName)); // 应用名称 (正常转换): 'My Awesome App'
System.out.println(StrUtil.format("数据库连接 (null转String,使用默认值): '{}'", dbConnection)); // 数据库连接 (null 转 String,使用默认值): 'jdbc:default:connection'
System.out.println(StrUtil.format("活动开始时间 (字符串转Date): {}", startTime)); // 活动开始时间 (字符串转 Date): Wed Jan 01 00:00:00 CST 2025
}
}

小结: Convert通过提供默认值机制,极大地简化了错误处理逻辑,让我们的代码从繁琐的 if-null-elsetry-catch 中解放出来,变得更加简洁和健壮。


1.2.3. 集合操作 (CollUtil & ListUtil)

背景: Java 的集合框架非常强大,但对于一些日常的、简单的操作,我们仍然需要编写不少代码。例如,判断集合是否为 null 或空、从集合中过滤指定条件的元素、将对象列表转换为某个属性的列表等,使用原生 API 或 Stream 流有时会显得“杀鸡用牛刀”。

解决方案: Hutool 的 CollUtil(针对所有 Collection)和 ListUtil(针对 List)提供了大量便利的方法,让集合操作变得极其简单和直观。这些工具方法不仅 null 安全,而且在很多场景下比我们手写循环或使用 Stream API 更加简洁。

核心 API 速查表

方法功能描述
isEmpty / isNotEmpty(主力) null 安全地判断集合是否为空。
join将集合元素用指定分隔符连接成字符串,常用于日志或 SQL 查询。
filter(常用) 根据自定义规则过滤集合元素,返回新集合。
map(常用) 将集合中的每个元素转换为另一种类型,返回新集合。
newArrayList / of便捷地创建 List 实例,避免 new ArrayList<>() 的写法。
split(ListUtil) 将一个大列表按指定大小拆分成多个小列表。
page(ListUtil) 对 List 进行物理分页,处理内存数据分页很方便。

实战场景:处理员工数据并进行分组汇报

场景: 假设我们从数据库获取了一个包含所有员工信息的 List,现在需要筛选出所有“技术部”的在职员工,提取出他们的姓名,并生成一份用逗号分隔的汇报名单。

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

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

// 定义一个简单的员工类用于演示
@Data
@AllArgsConstructor
@NoArgsConstructor
class Employee {
private String name;
private String department;
private boolean active;
}

public class Main {
public static void main(String[] args) {
// 1. 准备数据:使用 CollUtil.newArrayList 快速创建列表
List<Employee> allEmployees = CollUtil.newArrayList(
new Employee("张三", "技术部", true),
new Employee("李四", "市场部", true),
new Employee("王五", "技术部", false), // 已离职
new Employee("赵六", "技术部", true)
);

// 2. 过滤:使用 CollUtil.filter 筛选出“技术部”的“在职”员工
List<Employee> activeTechEmployees = CollUtil.filter(allEmployees,
(Employee e) -> "技术部".equals(e.getDepartment()) && e.isActive()
);

// 3. 映射:使用 CollUtil.map 从员工对象列表中提取出姓名列表
List<String> names = CollUtil.map(activeTechEmployees, Employee::getName, true);// true 表示忽略空值
String reportNames = CollUtil.join(names, ",");
System.out.println(StrUtil.format("技术部在职员工汇报名单: {}", reportNames));
}
}
// 输出:
// 技术部在职员工汇报名单: 张三, 赵六

小结: CollUtilListUtil 是处理集合数据的利器。相较于 Java Stream API,它们在处理一些简单直接的过滤、转换、拼接任务时,代码往往更短,意图更明确。


1.2.4. Map 操作 (MapUtil)

背景: Map 是另一种极其常用的数据结构,尤其在处理 JSON 数据、API 参数时。原生 Map 的操作同样存在判空、遍历、拼接等不便之处。

解决方案: MapUtil 提供了一系列静态方法来简化 Map 的操作,其设计思想与 CollUtil 类似,旨在提升开发的便捷性和代码的可读性。

核心 API 速查表

方法功能描述
isEmpty / isNotEmptynull 安全地判断 Map 是否为空。
newHashMap便捷地创建 HashMap 实例。
filter根据自定义规则(可针对 key、value 或 entry)过滤 Map
joinMap 的键值对按指定规则连接成字符串。
sortJoin(常用) 先按键(key)对 Map 排序,再进行连接,常用于 API 签名。
getStr / getInt…Map 中安全地获取指定类型的值,并支持默认值。

实战场景:生成 API 请求签名

场景: 在调用需要签名的 API(如支付接口)时,通常要求将所有请求参数(除了 sign 本身)按 key 的字典序升序排列,然后拼接成 key1=value1&key2=value2 的格式用于加密。MapUtil.sortJoin 完美地解决了这个痛点。

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;

import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.MD5;

import java.util.Map;

public class Main {
public static void main(String[] args) {
// 1. 准备 API 请求参数,顺序是混乱的
Map<String, Object> params = MapUtil.newHashMap();
params.put("amount", 100);
params.put("timestamp", "1672502400");
params.put("app_id", "xyz789");
params.put("nonce_str", "bu_ying_gai_bei_bao_han"); // 假设这个字段不参与签名

// 2. 过滤掉不需要参与签名的字段
Map<String, Object> filteredParams = MapUtil.filter(params,
(Map.Entry<String, Object> entry) -> !"nonce_str".equals(entry.getKey())
);

// 3. 使用 sortJoin 直接生成“key 正序排列并用&和 = 拼接”的待签名字符串
// 参数 1: map
// 参数 2: 键值对之间的连接符
// 参数 3: 键和值之间的连接符
String toSign = MapUtil.sortJoin(filteredParams, "&", "=", false);
System.out.println(StrUtil.format("待签名字符串: {}", toSign));
// 接下来就可以用这个 toSign 字符串去进行 MD5 或 HMAC 加密了
String md5_sign = SecureUtil.md5(toSign);
System.out.println(StrUtil.format("MD5 加密结果: {}", md5_sign));
}
}
// 输出:
//待签名字符串: amount = 100&app_id = xyz789&timestamp = 1672502400
//MD5 加密结果: eb035d28beaae11764e663e5318ce0b1

小结: MapUtil 在处理 Map 的日常操作,特别是需要排序拼接的 API 签名场景中,表现非常出色。它将复杂的“排序-遍历-拼接”逻辑封装成一个方法,极大提高了代码质量。


1.2.5. JavaBean 操作 (BeanUtil)

背景: 在现代分层架构(如 MVC)中,我们经常需要在不同的 JavaBean 对象之间传递数据。

例如,Controller 层接收前端数据的 DTO(数据传输对象),Service 层处理业务逻辑使用 Entity(实体对象),最后返回给前端 VO(视图对象)。这些对象的字段往往大量重叠,手动编写 userVo.setName(userEntity.getName()) 这样的代码不仅枯燥、易出错,而且难以维护。

解决方案: BeanUtil 是专门解决这一问题的利器。它能够高效、智能地在不同的 JavaBean 对象之间(甚至在 Bean 和 Map 之间)复制同名、同类型的属性,一行代码即可替代数十行手动的 setter/getter 调用。

核心 API 速查表

方法功能描述
copyProperties(核心) 将源对象(Bean 或 Map)的属性值拷贝到目标 Bean。
beanToMap将一个 JavaBean 对象转换为 Map,key 为属性名。
mapToBeanMap 中的数据填充到一个 JavaBean 实例中。
getProperty使用表达式(如 user.friends[0].name)安全地获取深层嵌套属性。
copyToList将一个集合中的所有 Bean,复制到另一个类型的 Bean 集合中。

实战场景:分层架构中的对象转换

场景: 用户注册后,我们需要将 UserDTO(来自前端)转换为 UserEntity(用于存入数据库),之后再将 UserEntity 转换为 UserVO(返回给前端,隐藏密码等敏感信息)。

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
package com.example;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.crypto.SecureUtil;
import lombok.Data;
import lombok.ToString;

import java.util.List;

// 1. DTO: 数据传输对象,来自前端
@Data
class UserDTO {
private String username;
private String password;
}

// 2. Entity: 实体对象,与数据库表对应
@Data
class UserEntity {
private Long id;
private String username;
private String hashedPassword; // 注意字段名不同
}

// 3. VO: 视图对象,返回给前端
@Data
@ToString
class UserVO {
private Long id;
private String username;
}

public class Main {
public static void main(String[] args) {
// --- 模拟用户注册流程 ---
// a. 接收到前端传来的 DTO
UserDTO userDto = new UserDTO();
userDto.setUsername("admin");
userDto.setPassword("123456");

// b. 将 DTO 转换为 Entity,并处理业务逻辑(如加密密码)
UserEntity userEntity = new UserEntity();
BeanUtil.copyProperties(userDto, userEntity); // 自动复制 username 属性
// 加密密码
String hash_password = SecureUtil.md5(userDto.getPassword());
userEntity.setHashedPassword(hash_password);
userEntity.setId(1L); // 模拟存入数据库后获得 ID

// c. 将 Entity 转换为 VO,用于返回给前端
UserVO userVo = BeanUtil.toBean(userEntity, UserVO.class);
System.out.println("返回给前端的VO: " + userVo);

// --- 批量转换场景 ---
UserEntity entity2 = new UserEntity();
entity2.setId(2L);
entity2.setUsername("guest");
List<UserEntity> entityList = List.of(userEntity, entity2);

List<UserVO> voList = BeanUtil.copyToList(entityList, UserVO.class);
System.out.println("返回给前端的批量VO列表: " + voList);

}
}
// 输出:
//返回给前端的 VO: UserVO(id = 1, username = admin)
//返回给前端的批量 VO 列表: [UserVO(id = 1, username = admin), UserVO(id = 2, username = guest)]

小结: BeanUtil 是构建清晰分层架构的基石。通过自动化属性复制,它将我们从繁琐的“胶水代码”中解放出来,让数据在 DTO、Entity、VO 之间的流转变得轻松、可靠。


1.3. 文件与 IO 工具集

背景: Java 的原生 IO 操作一直以来都因其复杂性而备受诟病:API 繁琐、需要手动处理流的关闭、以及恼人的检查性异常 IOException。这些都使得简单的文件读写任务变得代码冗长且容易出错。

解决方案: Hutool 提供了一套强大的文件与 IO 工具集,包括 FileUtilIoUtil 等,它们将复杂的操作封装为简单的单行静态方法,并自动处理了资源关闭等细节,让 IO 操作回归其本质的简单。

1.3.1. 文件与流操作 (FileUtil & IoUtil)

核心 API 速查表

工具类方法功能描述
FileUtiltouch创建文件,若父目录不存在会自动创建,类似 mkdir -p
del(危险) 强力删除文件或目录,会递归删除且不提示
copy拷贝文件或目录到指定位置。
readUtf8String(常用) 以 UTF-8 编码快捷读取文件全部内容为字符串。
appendUtf8String以 UTF-8 编码将字符串 追加 到文件末尾。
IoUtilcopy(常用) 在输入流和输出流之间拷贝数据,自动管理缓存。
readUtf8InputStream 中读取 UTF-8 编码的字符串。
write将数据(如字符串)以指定编码写入 OutputStream

1.4. 其他高频模块速览

Hutool 的功能远不止于此,它是一个庞大的工具库。下面我们以表格形式快速了解其他几个在特定领域极具价值的模块,当您遇到相关需求时,可以优先考虑使用它们。

模块名 (部分基于 hutool-extra)核心功能简介
Hutool-http一个功能强大且使用简单的 HTTP 客户端,极大地简化了发送 GET/POST 请求、文件上传下载、Cookie 管理等网络操作。
Hutool-crypto(高频) 提供了全面的加密解密工具集,支持对称加密(AES)、非对称加密(RSA)、摘要算法(MD5, SHA 系列)等。
Hutool-json(高频) 一个高性能的 JSON 库,可以非常方便地在 JSON 字符串、JavaBean、Map 之间进行转换,是处理 JSON 数据的利器。
Hutool-poi(高频) 专用于操作 Office 文档,让读写 Excel(.xls, .xlsx)和 Word(.docx)文件变得异常简单,无需关心底层复杂 API。
Hutool-cron一个轻量级的定时任务框架,可以用类似于 Linux Cron 的表达式(如 */5 * * * * *)来定义和管理周期性执行的任务。
Hutool-captcha图形验证码生成工具,可以快速生成各种样式的验证码图片(如线条、扭曲、GIF),用于防止机器人提交。
Hutool-jwt提供了创建、解析和验证 JWT(JSON Web Token)的完整支持,是现代 API 身份认证的必备工具。

更加具体的方法和更丰富的模块,我们强烈建议您访问官方文档进行探索:Hutool 官方文档


第二章: SLF4J + Logback:专业的日志体系

一个不产生日志的应用程序就像一艘在黑夜中无声航行的船,一旦出现问题,我们便无从追查。直接使用 System.out.println 打印信息是一种极其业余的做法,它无法分级、无法控制输出目的地、无法持久化,并且在高并发下会带来性能问题。因此,学习并使用专业的日志框架是每一位 Java 开发者的必修课。

2.1. 核心理念:为何需要专业的日志

背景: 在项目初期,我们可能会用 System.out.println 来调试。但随着项目变得复杂,这种方式的弊端会暴露无遗:满屏无用的信息、无法区分是普通流程还是严重错误、生产环境上无法关闭… 这时,我们就需要一个专业的日志系统。

解决方案: 在 Java 世界里,日志框架的选型最终沉淀为一个经典的设计模式——“门面 (Facade) + 实现 (Implementation)”。

SLF4J (Simple Logging Facade for Java):它是一个 日志门面,定义了一套标准的、通用的日志 API 接口。我们的应用程序代码 应该且只应该 依赖于 SLF4J。

这就像在 Java 中我们编程操作数据库时,代码依赖的是 JDBC 接口(如 java.sql.Connection),而不是某个具体的数据库驱动(如 MySQL 驱动)。SLF4J 就是日志界的“JDBC”。

Logback:它是一个 日志实现。它是 SLF4J 创始人开发的、作为 SLF4J 官方推荐的默认实现框架。它性能卓越,配置灵活,负责真正地将日志信息格式化,并写入到控制台、文件、数据库或网络服务中。

Logback 就是日志界的“MySQL 驱动”或“PostgreSQL 驱动”。

核心思想:解耦
通过这种“门面+实现”的模式,我们的应用程序与底层的具体日志框架实现了完全解耦。这意味着,今天我们使用 Logback,未来如果因为项目需求想换成性能更好的 Log4j2,我们 不需要修改任何一行 Java 业务代码,只需要在 pom.xml 中更换依赖包即可。这就是面向接口编程带来的巨大优势。


2.2. 引入与最佳实践

1. 引入方式

在现代 Java 开发中,引入日志框架非常简单。

  • 在 Spring Boot 环境中:我们通常 无需任何操作。因为 spring-boot-starter-webspring-boot-starter 等核心启动器,已经默认包含了 spring-boot-starter-logging,而它内部已经为我们完美地集成了 SLF4JLogback

  • 在非 Spring Boot 的标准 Maven 项目中:我们只需要添加 logback-classic 依赖即可,它会自动将 slf4j-apilogback-core 一同引入。

    1
    2
    3
    4
    5
    <dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.5.6</version>
    </dependency>

2. 最佳实践

获取 Logger 实例

  • 传统方式

    1
    2
    3
    4
    5
    6
    7
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;

    public class MyService {
    private static final Logger log = LoggerFactory.getLogger(MyService.class);
    // ...
    }
  • 最佳实践 (Lombok):我们强烈推荐使用 Lombok 的 @Slf4j 注解,它能在编译期自动生成上述样板代码,让类更简洁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import lombok.extern.slf4j.Slf4j;

    @Slf4j
    public class MyService {
    // Lombok 会在编译期自动为你生成:
    // private static final Logger log = LoggerFactory.getLogger(MyService.class);

    public void doSomething() {
    log.info("Doing something...");
    }
    }

使用日志级别
日志级别从高到低依次为:ERROR > WARN > INFO > DEBUG > TRACE

级别场景描述
ERROR严重错误,导致系统部分或全部功能不可用,需立即处理。
WARN警告信息,出现潜在问题或非预期情况,但不影响当前功能。
INFO(常用) 普通信息,记录系统运行状态、关键业务流程节点。
DEBUG调试信息,用于开发过程中追踪程序运行细节,生产应关闭
TRACE追踪信息,比 DEBUG 更细粒度,用于追踪代码执行路径。

{} 占位符的威力(性能关键)
这是 SLF4J API 最优秀的设计之一,也是区分专业与否的一个重要标志。

错误用法: log.debug("Found user with ID: " + user.getId());
原因: 无论 DEBUG 级别是否开启,"Found user..."user.getId() 的字符串拼接 总是会执行,造成不必要的性能开销。

正确用法: log.debug("Found user with ID: {}", user.getId());
原因: SLF4J 会先检查 DEBUG 级别是否开启。如果未开启,则 直接跳过,后面的 user.getId() 方法和字符串格式化 完全不会执行,从而在生产环境中避免了大量无效的计算和对象创建。


2.3. 生产级配置:logback-spring.xml

日志系统的强大之处在于其灵活的配置。在 Spring Boot 项目中,我们推荐在 src/main/resources 目录下创建 logback-spring.xml 文件,Spring Boot 会优先加载它作为日志配置。

三大核心组件

  1. <appender> (输出目的地):定义日志要输出到哪里。最常用的有两种:

    • ConsoleAppender:输出到控制台。
    • RollingFileAppender:输出到文件,并根据策略(时间和大小)进行 滚动归档,这是生产环境的标配。
  2. <logger> (日志记录器):为指定的 Java 包或类配置日志级别。这允许我们进行精细化控制,比如让我们自己的业务代码(com.example)打印 DEBUG 级的日志,而让 Spring 框架本身(org.springframework)只打印 INFO 级的日志以减少噪音。

  3. <root> (根记录器):全局默认的日志配置。如果某个类没有在 <logger> 中找到特定配置,就会使用 <root> 的配置。

完整 logback-spring.xml 配置示例

这是一个可以直接用于生产的、注释详细的配置模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<property name="LOG_HOME" value="logs" />
<property name="APP_NAME" value="my-application" />

<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/history/${APP_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
<encoder>
<pattern>${FILE_LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<logger name="com.example" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>

<logger name="org.springframework" level="INFO"/>
<logger name="org.hibernate" level="WARN"/>

<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>

</configuration>

第三章: Lombok:告别样板代码的“魔法”

摘要: 在本章中,我们将一同领略 Lombok 的魅力。它是一个功能强大的 Java 库,通过简单的注解,旨在 消除 Java 代码中的冗长与重复。我们将学习如何通过 @Data, @Builder, @Slf4j 等核心注解,在 编译期 自动生成那些“样板代码”,让我们的源码保持极致的清爽和简洁,从而能更专注于业务逻辑本身。

3.1. 核心理念与安装配置

核心用途与理念

我们回顾一下在编写一个普通的 JavaBean(如 User 类)时,通常需要做什么?我们需要手动为每个字段添加 gettersetter,重写 equals()hashCode()toString() 方法,可能还需要好几个不同参数的构造函数。这些代码技术含量不高,却占据了大量的开发时间和代码行数,我们称之为“样板代码”。

Lombok 的核心理念就是“代码生成自动化”。它在编译期介入,像一个智能助手,根据我们在类或字段上添加的注解,自动生成对应的方法。最终编译出的 .class 文件包含了所有必需的方法,但我们的 .java 源文件却得以保持清爽,只保留最核心的属性定义。

Maven 依赖

在 Maven 项目的 pom.xml 文件中,我们需要添加 Lombok 的依赖。请注意,它的 scope 通常设置为 provided,因为 Lombok 仅在编译期起作用,运行时并不需要它的库。

1
2
3
4
5
6
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>

IDE 插件安装

关键步骤: 仅仅引入依赖是 不够的

因为 Lombok 的“魔法”发生在编译期,我们的 IDE(如 IntelliJ IDEA, Eclipse)默认并不知道这些注解会自动生成方法。如果不安装插件,IDE 会因为找不到 getter/setter 等方法而满篇报错,代码提示和导航功能也会完全失效。

在 IntelliJ IDEA 中安装:

  1. 打开 File -> Settings -> Plugins
  2. Marketplace 标签页中搜索 Lombok
  3. 点击 Install 安装插件,然后按提示重启 IDEA。
  4. (推荐)确保开启注解处理:File -> Settings -> Build, Execution, Deployment -> Compiler -> Annotation Processors,勾选 Enable annotation processing

3.2. 核心注解实战

Lombok 提供了丰富的注解,我们来学习其中最常用、最有价值的几个。

组合注解 (@Data & @Value)

这是我们最常使用的注解,一个注解可以替代多个基础注解。@Data 是创建 可变类(标准的 JavaBean)的首选,它相当于 @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor() 的集合。而 @Value 则用于创建 不可变类,它会自动将所有字段设为 private final,并生成 getter 和全参构造器,但 不会生成 setter

实战场景: 使用 @Data 定义一个可变的 DTO,使用 @Value 定义一个线程安全的、不可变的配置类。

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

import lombok.Data;
import lombok.Value;

// 场景 1: 使用@Data 定义一个用于数据传输的可变 DTO
@Data
class UserDTO {
private Long id;
private String username;
private String email;
}

// 场景 2: 使用@Value 定义一个不可变的数据库配置类
@Value
class DbConfig {
String jdbcUrl;
String username;
String password;
// @Value 会自动生成包含这三个 final 字段的构造函数
}


public class Main {
public static void main(String[] args) {
// @Data 示例
UserDTO user = new UserDTO();
user.setId(1L); // 可以调用 setter
user.setUsername("admin");
System.out.println(user); // toString()已自动生成

// @Value 示例
DbConfig config = new DbConfig("jdbc:mysql://localhost:3306/mydb", "root", "password");
System.out.println("数据库URL: " + config.getJdbcUrl()); // 可以调用 getter
// config.setUsername("new_user"); // 这行代码会编译失败,因为没有 setter 方法
}
}
// 输出:
// UserDTO(id = 1, username = admin, email = null)
// 数据库 URL: jdbc:mysql://localhost: 3306/mydb

构造器注解 (@NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor)

@NoArgsConstructor 就是生成个啥参数都没有的构造函数,方便对象创建。

@AllArgsConstructor 把类里所有字段都当作参数,生成构造函数,一次把所有属性值都能设置。

@RequiredArgsConstructor 只对那些必须初始化的(final 或 @NonNull 标记)字段生成构造函数,在需要特定参数来创建对象,做依赖注入这些场景很实用,比如你只关心几个关键参数来创建对象时就用它

实战场景: 为一个 Service 类实现基于 final 字段的依赖注入。

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;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; // 假设在 Spring 环境

// 定义依赖接口
interface UserRepository {}
interface EmailService {}

// 使用@RequiredArgsConstructor 实现构造器注入
@Service
@RequiredArgsConstructor
public class UserService {

// final 字段是“必需”的依赖
private final UserRepository userRepository;
private final EmailService emailService;

// Lombok 会自动生成以下构造函数:
// public UserService(UserRepository userRepository, EmailService emailService) {
// this.userRepository = userRepository;
// this.emailService = emailService;
// }

public void registerUser() {
System.out.println("正在使用 " + userRepository.getClass().getSimpleName() + " 和 "
+ emailService.getClass().getSimpleName() + " 注册用户...");
}
}

建造者模式 (@Builder)

当一个对象有大量可选属性时,使用构造函数会变得非常笨重,而 @Builder 注解则让实现 建造者模式 变得不费吹灰之力。

它会自动生成一套流式 API 来构建对象。

实战场景: 构建一个具有多个可选条件的复杂搜索查询对象。

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

import lombok.Builder;
import lombok.ToString;

@Builder
@ToString
public class ProductQuery {
private String keyword; // 关键词 (可选)
private Long categoryId; // 分类 ID (可选)
private Double minPrice; // 最低价 (可选)

@Builder.Default // 可以为字段设置默认值
private String sortBy = "default_sort";

@Builder.Default
private int pageSize = 10;
}


public class Main {
public static void main(String[] args) {
// 使用 Builder 模式创建对象,可读性极高
ProductQuery query = ProductQuery.builder()
.keyword("笔记本电脑")
.categoryId(101L)
.minPrice(5000.0)
// sortBy 和 pageSize 使用默认值
.build();

System.out.println(query);
}
}
// 输出:
// ProductQuery(keyword = 笔记本电脑, categoryId = 101, minPrice = 5000.0, sortBy = default_sort, pageSize = 10)

日志注入 (@Slf4j)

这个注解可以完美衔接我们上一章学习的日志框架,让我们彻底告别手动声明 Logger 的样板代码。

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;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class Main {
// Lombok 已自动为我们注入了:
// private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Main.class);

public static void main(String[] args) {
String username = "testUser";

// 我们可以直接使用 log 变量
log.info("用户 '{}' 正在尝试登录...", username);

try {
int result = 1 / 0;
} catch (Exception e) {
log.error("计算出错,用户: {}", username, e); // 同时记录异常堆栈
}
}
}

3.3. Lombok 是如何工作的?

Lombok 的便捷性让一些开发者担心其性能和原理。我们需要明确一点:Lombok 没有任何运行时性能损耗

它的“魔法”完全发生在 编译期。Lombok 遵循了 JSR 269 Pluggable Annotation Processing API(可插拔式注解处理 API)规范,作为一个“注解处理器”运行。

其工作流程大致如下:

  1. 编译触发:当 Java 编译器(javac)开始编译你的 .java 源文件时,它会查找所有被 @ 注解标记的代码。
  2. Lombok 介入:编译器发现 Lombok 的注解(如 @Data),就会调用 Lombok 的注解处理器。
  3. AST 操作:Lombok 处理器会访问并修改编译器在内存中生成的 抽象语法树(AST)。AST 是源代码的树状结构化表示。例如,当 Lombok 看到 @Getter,它就会在 AST 中找到对应的字段节点,并为其添加一个新的 getter 方法节点。
  4. 生成字节码:编译器继续工作,但此时它操作的是已经被 Lombok 修改过的、增强后的 AST。最终,它将这个增强后的 AST 转换成标准的 Java 字节码(.class 文件)。

因此,Lombok 并非通过反射等运行时技术实现功能,它更像一个编译器插件,在编译阶段就已经把所有“样板代码”结结实实地写进了最终的 .class 文件中。我们享受了源码的简洁,而 JVM 在运行时看到的则是完整的、包含了所有方法的标准 Java 类。


第四章: Jackson:JSON 处理的事实标准

摘要: 在当今以 API 为核心的后端服务中,JSON (JavaScript Object Notation) 已成为数据交换的通用语言。本章我们将深入学习在 Java 世界中处理 JSON 的“事实标准”——Jackson。我们将掌握其两大核心功能:序列化反序列化,并通过丰富的实战,学习如何使用 ObjectMapper 和各类注解,来灵活、高效地处理 Java 对象与 JSON 之间的转换。

4.1. 核心理念与引入

核心用途

Jackson 的核心用途可以概括为两大方向,它们是所有 Web API 交互的基石:

  1. 序列化 (Serialization):将一个 Java 对象(如 POJO、DTO)转换成一个 JSON 格式的字符串。这个过程通常用于服务器响应 API 请求,将处理结果发送给前端或另一个服务。

    • Java Object -> JSON String
  2. 反序列化 (Deserialization):将一个 JSON 格式的字符串解析成一个 Java 对象。这个过程通常用于服务器接收 API 请求,将请求体中的 JSON 数据转换为程序可以操作的对象。

    • JSON String -> Java Object

它的设计是模块化的,我们日常开发主要与 jackson-databind 模块打交道,它提供了强大的数据绑定功能。

引入方式

  • 在 Spring Boot 环境中:
    我们同样 无需任何操作spring-boot-starter-web 默认集成了 Jackson,并已为我们自动配置好了核心的 ObjectMapper Bean。

  • Maven 依赖:
    在非 Spring Boot 项目中,我们需要手动引入 jackson-databind

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.17.1</version>
    </dependency>
1
2
3
4
5
6
7
8
9
10
11
12
    
---

**处理 Java 8 日期时间的关键依赖**
默认情况下,Jackson 不知道如何处理 Java 8 引入的 `LocalDate`, `LocalDateTime` 等新的日期时间 API。为了能正确地序列化和反序列化这些类型,我们 **必须** 添加以下模块:

```xml
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.17.1</version>
</dependency>

4.2. 核心用法:ObjectMapper 与常用注解

ObjectMapper 是 Jackson 库中最核心的类。我们可以把它想象成一个高度智能的转换引擎,所有 JSON 的读写操作都由它来完成。

ObjectMapper 实战

我们先通过一个简单的例子,看看 ObjectMapper 的基本用法。

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

import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.NoArgsConstructor; // 反序列化需要无参构造函数

@Data
@NoArgsConstructor // Jackson 反序列化时,通常需要一个无参构造函数来创建对象实例
class SimpleUser {
private Long id;
private String name;

public SimpleUser(Long id, String name) {
this.id = id;
this.name = name;
}
}

public class Main {
public static void main(String[] args) throws Exception {
// 1. 准备 ObjectMapper 实例和对象
ObjectMapper mapper = new ObjectMapper();
SimpleUser user = new SimpleUser(1L, "admin");

// 2. 序列化:将 Java 对象转换为 JSON 字符串
String jsonString = mapper.writeValueAsString(user);
System.out.println("序列化结果: " + jsonString);

// 3. 反序列化:将 JSON 字符串转换回 Java 对象
SimpleUser deserializedUser = mapper.readValue(jsonString, SimpleUser.class);
System.out.println("反序列化结果: " + deserializedUser);
}
}
// 输出:
// 序列化结果: {"id": 1, "name": "admin"}
// 反序列化结果: SimpleUser(id = 1, name = admin)

Hutool 也为我们封装好了对应的方法,以下可以参考一下,这也是我平常比较常用的

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;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Map;

@Data
@NoArgsConstructor
@AllArgsConstructor
class User {
private long id;
private String name;
}


public class Main {
public static void main(String[] args) {
User user = new User(1L, "admin");
// 转 JSON 字符串
Map<String, Object> stringObjectMap = BeanUtil.beanToMap(user);
JSONObject jsonObj = JSONUtil.parseObj(stringObjectMap);
System.out.println("序列化结果: " + jsonObj);
// 从 JSON 字符串转回对象
User deserializedUser = JSONUtil.toBean(jsonObj, User.class);
System.out.println("反序列化结果: " + deserializedUser);
}
}

常用注解实战

通过注解,我们可以非常精细地控制对象与 JSON 之间的映射关系,解决各种复杂的场景。

注解功能描述
@JsonProperty(常用) 自定义 Java 属性与 JSON 字段间的映射名。
@JsonIgnore在序列化或反序列化时完全忽略某个属性。
@JsonFormat(常用) 指定日期时间、数字等类型的格式化模式。
@JsonInclude控制属性在何种情况下才被包含在 JSON 中(如 NON_NULL)。
@JsonAlias为属性定义一个或多个“别名”,主要用于反序列化,增强兼容性。

综合场景: 我们来定义一个更复杂的 ApiUser 类,它将综合运用上述注解。

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;


import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@JsonInclude(JsonInclude.Include.NON_NULL) // 全局策略:值为 null 的字段不参与序列化
class ApiUser {
private Long id;

@JsonProperty("user_name") // 序列化和反序列化时,都使用 user_name
private String username;

@JsonIgnore // 此字段将永远不会被序列化或反序列化
private String password;

@JsonAlias({"email_address", "mail"}) // 反序列化时,"email_address" 或 "mail" 都能映射到 email 字段
private String email;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 指定日期时间格式
private LocalDateTime createTime;
}


public class Main {
public static void main(String[] args) throws JsonProcessingException {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule()); // 注册 Java 8 日期模块


// --- 序列化演示 ---
ApiUser user = new ApiUser();
user.setId(101L);
user.setUsername("test-user");
user.setPassword("a_very_secret_password"); // 这个字段将被忽略
user.setCreateTime(LocalDateTime.of(2025, 8, 8, 10, 30, 0));
// user.email 为 null,所以根据@JsonInclude,它不会出现在输出中

// 先通过writerWithDefaultPrettyPrinter()再使用writeValueAsString()能得到一个美观的Json格式
String serializedJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(user);
System.out.println("--- 序列化(对象转JSON)---");
System.out.println(serializedJson);

// --- 反序列化演示 ---
// 注意这里的一个核心:无论使用的是mail还是email_address,反序列化时都会映射到email字段
String incomingJson = "{ \"id\": 102, \"user_name\": \"guest\", \"mail\": \"guest@example.com\" }";
ApiUser deserializedUser = mapper.readValue(incomingJson, ApiUser.class);
System.out.println("\n--- 反序列化(JSON转对象)---");
System.out.println(deserializedUser);


}
}
// 输出:
// --- 序列化(对象转JSON)---
// {
// "id" : 101,
// "user_name" : "test-user",
// "createTime" : "2025-08-08 10:30:00"
// }
//
// --- 反序列化(JSON转对象)---
// ApiUser(id = 102, username = guest, password = null, email = guest@example.com, createTime = null)

4.3. 高级技巧与最佳实践

处理泛型集合

当我们尝试反序列化一个泛型集合(如 List<User>)时,由于 Java 的类型擦除,mapper.readValue(json, List.class)错误 的,这会返回一个 List<LinkedHashMap>

正确做法是使用 TypeReference,它能“捕获”并保留完整的泛型信息。

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

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;


@Data
@NoArgsConstructor
@AllArgsConstructor
class User {
private long id;
private String name;
}


public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String json = "[{\"id\":1,\"name\":\"UserA\"},{\"id\":2,\"name\":\"UserB\"}]";

// 使用 TypeReference 来保留完整的泛型信息
List<User> userList = mapper.readValue(json, new TypeReference<List<User>>() {});

System.out.println("List大小: " + userList.size());
System.out.println("第一个用户的名字: " + userList.get(0).getName());
}
}
// 输出:
// List 大小: 2
// 第一个用户的名字: UserA

动态处理 JSON (JsonNode)

当 JSON 结构不确定,或者我们只关心其中一两个字段时,将整个 JSON 反序列化为 POJO 会很浪费。此时可以使用 JsonNode 树模型,它允许我们像操作 DOM 一样灵活地导航和取值。

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

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public class Main {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String json = "{\"meta\":{\"code\":200},\"data\":{\"user\":{\"name\":\"Admin\",\"roles\":[\"ADMIN\",\"EDITOR\"]}}}";

// 将 JSON 解析为一个树节点
JsonNode rootNode = mapper.readTree(json);

// 使用.path()方法安全地导航,如果节点不存在不会抛异常
String username = rootNode.path("data").path("user").path("name").asText();
String firstRole = rootNode.path("data").path("user").path("roles").get(0).asText();

System.out.println("用户名: " + username);
System.out.println("第一个角色: " + firstRole);
}
}
// 输出:
// 用户名: Admin
// 第一个角色: ADMIN

正常开发中更简便的还是通过Hutool封装好的工具,更加直观,而且学过Python的也能一眼看出来比较Pythonic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;

public class Main {
public static void main(String[] args) {
String json = "{\"meta\":{\"code\":200},\"data\":{\"user\":{\"name\":\"Admin\",\"roles\":[\"ADMIN\",\"EDITOR\"]}}}";

// 使用JSONUtil.parseObj解析并逐层获取
JSONObject jsonObj = JSONUtil.parseObj(json);

// 使用JSONPath更简洁地获取值
String username = JSONUtil.getByPath(jsonObj, "data.user.name").toString();
String firstRole = JSONUtil.getByPath(jsonObj, "data.user.roles[0]").toString();

System.out.println("用户名: " + username); // Admin
System.out.println("第一个角色: " + firstRole); // ADMIN
}
}

第五章: 现代Java单元测试核心

摘要: 单元测试是保证软件质量、提升代码可维护性、以及实现“自信重actoring”的基石。在本章中,我们将摒弃陈旧的测试观念,学习业界主流的现代Java单元测试技术。我们将深入理解测试的“隔离”理念,并分别详细学习“四大组件”——JUnit 5(测试运行与生命周期管理)、AssertJ(流式断言)、Mockito(依赖模拟)以及Spring Boot Test(框架集成测试)的协同工作,最终形成一套完整、专业的测试能力。

5.1. 理念先行:为何与如何测试

在我们一头扎进代码之前,花些时间建立一个清晰、正确的测试观至关重要。这能帮助我们理解每种测试的价值,以及为何我们的学习路径要如此安排。一个成熟的开发流程,绝不仅仅是“写代码-运行-调试”,而是将测试无缝地融入其中。

一个可靠的开发工作流

  1. 编写业务逻辑与单元测试: 当我们在Service层编写核心业务逻辑时,应当同步为其编写单元测试。利用Mockito模拟数据库、第三方服务等外部依赖,确保代码中的每一个if-else、循环、异常处理等逻辑分支都被独立、精确地测试到。
  2. 开发Controller层接口: 编写Controller,通过API的形式将内部服务暴露给外界。
  3. 接口快速调试: 启动应用,使用Postman等工具发送一个“Happy Path”(理想情况)的请求。这一步的目的是快速验证从HTTP请求 -> Controller -> Service -> DB(测试数据库)这条主路能否跑通,JSON序列化是否正常。这是一个快速的“冒烟测试”。
  4. 编写集成测试: 为这个API编写一个自动化的集成测试。它会模拟HTTP请求,并连接到一个真实的测试数据库(如H2),以此来验证从Controller到数据库的完整链路。这一步的价值在于,将第3步的手动调试过程自动化、固化下来,成为永久的质量保障。
  5. 持续集成 (CI): 当代码提交到代码库(如Git)时,CI服务器(如Jenkins, GitHub Actions)会自动拉取最新代码,并执行所有的自动化测试(单元测试+集成测试)。任何一个测试失败都会导致构建失败,从而阻止有问题的代码合入主分支,实现质量的“前置”。

测试金字塔模型

这个业界公认的模型,为我们清晰地指明了不同类型测试在数量和投入上的最佳配比。

1
2
3
4
5
6
7
      / \
/ ▲ \ <-- 端到端测试 (E2E Tests) - 少而精
/-----\
/ ■■ \ <-- 集成测试 (Integration Tests) - 数量适中
/------- \
/ ■■■ \ <-- 单元测试 (Unit Tests) - 量大而快
/----------- \
  • 单元测试 (Unit Tests) - 金字塔的基石

    • 定义:它是针对程序中最小可测试单元(通常是一个方法或一个类)进行的验证。它好比在盖房子前,我们先确保每一块砖头的质量都绝对过关。
    • 特点:它的数量应该是最多的。因为它们运行速度极快(毫秒级)、编写成本低,并且不依赖任何外部环境(如数据库、网络),所以能为我们提供最快速、最精确的反馈。本章的焦点将完全聚焦于此
  • 集成测试 (Integration Tests) - 验证协作

    • 定义:它测试多个“单元”组合在一起时能否正常协作。例如,测试Service层调用DAO层后,数据能否正确地写入数据库。它好比是检验砖头和水泥砌成的墙是否坚固。
    • 特点:数量适中,速度比单元测试慢,因为它可能需要启动部分应用环境或连接测试数据库。
  • 端到端测试 (E2E Tests) - 模拟用户

    • 定义:它从用户的视角,通过操作UI界面来验证整个应用系统的工作流程是否正确。它好比是检验整栋房子是否能正常居住,水电煤是否都通畅。
    • 特点:数量应该最少,因为它运行最慢、最不稳定(容易受网络、UI变动影响)、编写和维护成本最高。

单元测试的核心挑战:隔离

这是单元测试的灵魂所在。我们来思考一个具体问题:我们要测试RegistrationServiceregister()方法。但这个方法内部可能调用了UserRepository去查数据库,还调用了NotificationService去发邮件。

我们如何能确保,当测试失败时,失败的原因仅仅是register()方法自身的业务逻辑错误,而不是因为数据库连不上,或者邮件服务器宕机了呢?

答案就是隔离——将RegistrationService与它所依赖的外部组件(UserRepository, NotificationService)隔离开来。为了实现隔离,我们会使用模拟(Mocking)技术,为这些依赖创建出“冒牌货”或“替身演员”。这些替身完全听从我们的指挥,我们可以命令它们:

  • “当你的existsByUsername方法被调用时,立刻假装用户已存在,返回true。”
  • “当你的sendWelcomeEmail方法被调用时,什么也别做,假装邮件已发送成功。”

5.2. 组件一:JUnit 5 - 运行与生命周期

JUnit 5是Java世界测试框架的基石和标准。如果说我们的业务代码需要一个main方法作为程序入口,那么 JUnit 5 就扮演了我们所有测试代码的main方法

JUnit 5 的角色定位

它的核心角色是“测试运行器”和“生命周期管理器”。它并不关心你的业务逻辑对不对,只负责提供一个平台,并指挥整个测试流程的进行:

  • 发现:在我们的项目中找到所有被@Test等特殊注解标记的方法。
  • 执行:按照一定的顺序和生命周期规则,逐一执行这些测试方法。
  • 报告:收集每个测试的执行结果(成功、失败、跳过),并生成我们可以阅读的报告。

引入与编写第一个测试

在标准的Maven项目中,我们需要在pom.xml<dependencies>中添加JUnit Jupiter的依赖,其<scope>应为test,因为它们只在测试阶段需要。

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>

一个良好结构的测试方法通常遵循Arrange-Act-Assert (3A)模式,这是一种让测试意图清晰化的最佳实践:

  • Arrange (准备):初始化对象,准备测试数据和环境。这是测试的“前情提要”。
  • Act (执行):调用被测试的方法。这是测试的“核心动作”。
  • Assert (断言):验证执行结果是否符合预期。这是测试的“最终裁决”。

代码示例
假设我们有一个简单的Calculator.java

1
2
3
4
5
6
package com.example.utils;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
}

对应的测试类CalculatorTest.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.utils;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

@DisplayName("计算器基础功能测试")
class CalculatorTest {
@Test // 标记这是一个测试方法
@DisplayName("测试两个正数相加")
void test_add_two_positive_numbers() {
// 1. Arrange (准备): 创建计算器实例,定义预期结果
Calculator calculator = new Calculator();
int expectedResult = 15;

// 2. Act (执行): 调用add方法
int actualResult = calculator.add(5, 10);

// 3. Assert (断言): 验证实际结果是否与预期相等
assertEquals(expectedResult, actualResult, "两个正数相加的结果不正确");
}
}

精准控制:测试生命周期

JUnit 5提供了一套强大的生命周期注解,让我们可以精准地控制测试前后的准备(setup)和清理(teardown)工作,这对于管理复杂的测试场景至关重要。

注解功能描述与用途
@BeforeAll在当前测试类中所有测试方法运行执行一次。适合执行昂贵的、全局性的初始化操作,如启动模拟服务器。 方法必须是static
@AfterAll在当前测试类中所有测试方法运行执行一次。适合执行全局性的清理操作。方法必须是static
@BeforeEach每个测试方法运行执行。这是最常用的,用于确保每个测试都在一个“干净”的、互不干扰的环境中开始。
@AfterEach每个测试方法运行执行。用于清理@BeforeEach中准备的资源,确保测试之间不产生副作用。

生命周期代码演示

下面的例子清晰地展示了这些注解的执行顺序。

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
import org.junit.jupiter.api.*;

@DisplayName("JUnit 5 生命周期注解演示")
class LifecycleDemoTest {

// 1. 在所有测试开始前执行一次,必须是静态方法
@BeforeAll
static void beforeAllTests() {
System.out.println("【@BeforeAll】: 所有测试即将开始,执行一次性全局设置...");
}

// 2. 在每个测试方法开始前执行
@BeforeEach
void setUpForEachTest() {
System.out.println("【@BeforeEach】: 测试方法即将开始,准备测试环境...");
}

@Test
@DisplayName("测试方法一")
void testMethodOne() {
System.out.println(">> 执行测试方法 1...");
Assertions.assertTrue(true);
}

@Test
@DisplayName("测试方法二")
void testMethodTwo() {
System.out.println(">> 执行测试方法 2...");
Assertions.assertEquals(4, 2 + 2);
}

// 3. 在每个测试方法结束后执行
@AfterEach
void tearDownForEachTest() {
System.out.println("【@AfterEach】: 测试方法已结束,清理测试环境...");
}

// 4. 在所有测试结束后执行一次,必须是静态方法
@AfterAll
static void afterAllTests() {
System.out.println("【@AfterAll】: 所有测试已完成,执行一次性全局清理...");
}
}

运行上述测试,控制台输出的顺序将是:

1
2
3
4
5
6
7
8
【@BeforeAll】: 所有测试即将开始,执行一次性全局设置...
【@BeforeEach】: 测试方法即将开始,准备测试环境...
>> 执行测试方法 1...
【@AfterEach】: 测试方法已结束,清理测试环境...
【@BeforeEach】: 测试方法即将开始,准备测试环境...
>> 执行测试方法 2...
【@AfterEach】: 测试方法已结束,清理测试环境...
【@AfterAll】: 所有测试已完成,执行一次性全局清理...

组织与管理你的测试

当测试用例增多时,良好的组织和描述就显得尤为重要。JUnit 5提供了一系列注解来帮助我们更好地结构化和管理测试。

注解功能描述与用途
@DisplayName为测试类或方法提供一个更具业务可读性的名称。例如,@DisplayName("当用户余额充足时,支付应成功")远比testPaymentSuccessWhenBalanceIsSufficient()更易于团队成员理解。
@Nested允许在一个测试类内部创建嵌套的内部测试类,形成更有逻辑的结构。这对于测试一个复杂类的不同状态(如“当列表为空时” vs “当列表有元素时”)非常有用。
@Disabled如果某个测试因为Bug或功能未完成而暂时无法运行,可以用此注解跳过它,并可以附带说明原因,避免CI构建失败。
@Tag为测试打上标签(如"fast", "api", "slow")。这在大型项目中非常有用,可以结合构建工具(如Maven, Gradle)选择性地执行特定标签的测试集,例如在CI上只运行快速的单元测试。

组织与管理代码演示

让我们通过一个测试Stack(栈)的例子,来综合运用这些注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import org.junit.jupiter.api.*;
import java.util.Stack;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("一个栈(Stack)的测试")
class StackTest {

Stack<Object> stack;

@Test
@DisplayName("新创建的栈应该是空的")
@Tag("fast")
void is_instantiated_with_new() {
new Stack<>();
}

@Nested
@DisplayName("当栈是新创建时")
class WhenNew {

@BeforeEach
void createNewStack() {
stack = new Stack<>();
}

@Test
@DisplayName("isEmpty() 应该返回 true")
@Tag("fast")
void is_empty() {
assertTrue(stack.isEmpty());
}

@Test
@DisplayName("当 pop 一个空栈时,应抛出 EmptyStackException 异常")
void throws_exception_when_popped() {
assertThrows(java.util.EmptyStackException.class, () -> stack.pop());
}

@Test
@Disabled("这个功能暂时不做,等待需求明确")
@DisplayName("size() 应该返回 0")
void size_must_be_zero() {
assertEquals(0, stack.size());
}
}

@Nested
@DisplayName("当有新元素被 push 进栈后")
class AfterPushing {

String anElement = "an element";

@BeforeEach
void pushAnElement() {
stack = new Stack<>();
stack.push(anElement);
}

@Test
@DisplayName("isEmpty() 应该返回 false")
@Tag("slow") // 假设这个测试场景准备比较耗时
void is_not_empty() {
assertFalse(stack.isEmpty());
}

@Test
@DisplayName("pop() 应该返回 push 进去的那个元素")
@Tag("fast")
void return_element_when_popped() {
assertEquals(anElement, stack.pop());
}

@Test
@DisplayName("pop() 后,栈应该变为空")
@Tag("fast")
void become_empty_after_pop() {
stack.pop();
assertTrue(stack.isEmpty());
}
}
}

这个例子展示了:

  • @DisplayName: 让测试报告的输出像一个行为描述文档。
  • @Nested: 清晰地将“空栈”和“有元素的栈”这两种不同场景的测试逻辑分离开来。
  • @Disabled: 标记了一个暂时不执行的测试,并说明了原因。
  • @Tag: 为测试打了"fast""slow"标签,方便后续按需执行,例如在持续集成服务器上可以配置只运行带"fast"标签的测试,以加快构建速度。

5.3. 组件二:AssertJ - 艺术的流式断言

虽然JUnit 5自带了一套Assertions类,但我们强烈推荐使用一个更专业的第三方断言库——AssertJ。一旦你体验过AssertJ,就很难再回到原生的断言方式。

为什么选择AssertJ?

  • 无与伦比的可读性: AssertJ的核心是“流式API”,它让你的断言代码读起来就像一句通顺的自然语言,从左到右,一气呵成。
  • 强大的链式调用与IDE自动补全: 当你输入assertThat(heroes).后,你的IDE会自动弹出所有专门用于列表的断言方法(如hasSize, contains, startsWith等)。你无需去记忆Assertions类里的静态方法,探索和编写断言变得异常轻松。
  • 丰富的断言类型: AssertJ为各种常见类型(集合、字符串、数字、日期、文件、异常等)提供了海量的、专门定制的断言方法。
  • 清晰的错误报告: 当断言失败时,AssertJ生成的错误信息非常详尽,能帮助你快速定位问题。例如,对比两个列表不一致时,它会清晰地告诉你“哪些元素是预期的但未找到”以及“哪些元素是未预期的但出现了”。

首先,在pom.xml中添加assertj-core依赖。

1
2
3
4
5
6
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>

核心静态导入: 为了使用流式API,我们通常会在测试类中静态导入assertThat方法:

1
import static org.assertj.core.api.Assertions.assertThat;

代码示例

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.assertj;

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class AssertJShowcaseTest {

@Test
void collection_assertions_showcase() {
List<String> fellowship = List.of("Frodo", "Sam", "Pippin");

// 像一句话一样,从左到右阅读即可
assertThat(fellowship).hasSize(3)
.contains("Sam")
.doesNotContain("Sauron");

// 验证包含且仅包含指定元素,且顺序必须完全一致
assertThat(fellowship).containsExactly("Frodo", "Sam", "Pippin");
}

@Test
void string_assertions_showcase() {
String text = "Hello, World!";
assertThat(text).isEqualToIgnoringCase("hello, world!")
.startsWith("Hello")
.endsWith("!")
.contains("World");
}

@Test
void exception_assertions_showcase() {
// 优雅地验证某段代码是否抛出了预期的异常
assertThatThrownBy(() -> {
Integer.parseInt("not a number");
})
.isInstanceOf(NumberFormatException.class) // 验证异常类型
.hasMessageContaining("not a number"); // 验证异常信息
}

@Test
void custom_error_message_showcase() {
String role = "guest";
// 使用.as()方法,可以为断言添加业务描述,一旦失败,错误报告将更加清晰
assertThat(role).as("新注册用户的默认角色应该是'USER'")
.isEqualTo("USER");
// 失败时会输出: [新注册用户的默认角色应该是'USER'] expected: "USER" but was: "guest"
}
}

5.4. 组件三:Mockito - 依赖隔离的魔法

在单元测试中,我们希望像科学家在实验室里一样,只测试一个独立的单元(通常是一个类),而不受其依赖项(如数据库、外部服务)的干扰。Mockito就是我们实现这种“隔离”的魔法工具,它是一个强大的模拟框架(Mocking Framework)

Mockito 的角色定位

它的核心角色,就是为被测对象的依赖项创建出一个 “替身”或“模拟”对象(Mock Object)。这个模拟对象在类型上与真实对象完全一样,但其所有方法的行为都由我们在测试中精确控制。

如果说JUnit是测试的舞台和导演,AssertJ是评判表演的评委,那么Mockito就是为主角(被测对象)提供配戏的、技艺精湛的“特技演员”。它确保主角的戏份能够独立、可预测地进行,无论配角(依赖项)的真实情况多么复杂。

准备工作:集成Mockito与JUnit 5

  1. 添加Maven依赖:在pom.xml中,我们需要mockito-coremockito-junit-jupiter(与JUnit 5集成的粘合剂)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
    </dependency>
    <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.12.0</version>
    <scope>test</scope>
    </dependency>
  2. 启用Mockito扩展:为了让@Mock等注解生效,必须在测试类顶部添加一个JUnit 5的注解。

    1
    2
    3
    4
    5
    6
    7
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.junit.jupiter.MockitoExtension;

    @ExtendWith(MockitoExtension.class) // 这行代码是启用Mockito注解的关键
    class MyServiceTest {
    // ...
    }

使用Mockito的核心在于遵循一个清晰的三步流程:1. 创建模拟 -> 2. 设定行为 -> 3. 验证交互

为了完整地演示这三步,我们假设正在测试一个用户注册服务 RegistrationService。它的逻辑是:

  1. 检查用户名是否已存在(依赖 UserRepository)。
  2. 如果不存在,则保存新用户(依赖 UserRepository)。
  3. 发送欢迎邮件(依赖 NotificationService)。

被测系统 (System Under Test - SUT) 代码:

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
// SUT: RegistrationService.java
public class RegistrationService {
private final UserRepository userRepository;
private final NotificationService notificationService;

public RegistrationService(UserRepository userRepository, NotificationService notificationService) {
this.userRepository = userRepository;
this.notificationService = notificationService;
}

public boolean registerUser(String username, String email) {
if (userRepository.existsByUsername(username)) {
return false; // 用户已存在,注册失败
}
User newUser = new User(username, email);
userRepository.save(newUser);
notificationService.sendWelcomeEmail(email);
return true;
}
}

// 依赖项接口
public interface UserRepository {
boolean existsByUsername(String username);
void save(User user);
}
public interface NotificationService {
void sendWelcomeEmail(String email);
}
public class User { /* ... */ }

5.4.1. 第一步:创建模拟对象与被测实例 (Creating Mocks)

我们使用注解来自动化模拟对象的创建和注入,让代码更简洁。

注解用途
@Mock在一个字段上使用,Mockito会自动为你创建该类型的模拟对象。此对象所有方法默认返回null0false,行为待我们定义。
@InjectMocks被测对象的字段上使用。Mockito会自动创建该类的真实实例,并扫描其内部字段,将所有@Mock标记的模拟对象自动注入进去。

测试类准备代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@ExtendWith(MockitoExtension.class)
class RegistrationServiceTest {

@Mock // 1. 创建一个UserRepository的“模拟”对象
private UserRepository userRepository;

@Mock // 2. 创建一个NotificationService的“模拟”对象
private NotificationService notificationService;

@InjectMocks // 3. 创建一个真实的RegistrationService实例,并将上面的两个mock对象注入进去
private RegistrationService registrationService;

// 接下来我们将在这里编写测试方法...
}

5.4.2. 第二步:设定模拟对象的行为 (Stubbing)

这是Mocking的核心,也叫“打桩”。我们通过打桩来预设模拟对象的行为,告诉它在接收到特定调用时应该做出何种反应。

方法组合含义
when(mock.method()).thenReturn(val)调用mockmethod方法时,就返回val这个值。这是最常用的。
when(mock.method()).thenThrow(ex)调用mockmethod方法时,就抛出ex这个异常。用于测试异常处理逻辑。
doNothing().when(mock).voidMethod()对于返回值为void的方法,如果你想明确表示“什么都不做”(这是默认行为),可以使用此语法。

测试用例:用户注册成功

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
@DisplayName("当用户名不存在时,应注册成功并发送邮件")
void test_register_user_success() {
// === 2. 设定行为 (Stubbing) - 这是 Arrange 阶段的核心 ===
// 规定:当调用userRepository的existsByUsername方法并传入"newUser"时,返回false
when(userRepository.existsByUsername("newUser")).thenReturn(false);

// === Act ===
boolean result = registrationService.registerUser("newUser", "new@example.com");

// === Assert (使用AssertJ) ===
assertThat(result).isTrue();

// 接下来是第三步:验证交互
}

5.4.3. 第三步:验证方法交互 (Verification)

有时,我们不仅关心方法的返回值,更关心被测对象是否与它的依赖进行了正确的交互。例如,注册成功后,save方法是不是真的被调用了?调用了几次?

验证方法含义
verify(mock).method()验证mockmethod方法是否被调用过至少一次
verify(mock, times(n)).method()验证方法被调用了恰好 n 次times(1)是最常见的。
verify(mock, never()).method()验证方法从未被调用过
verify(mock, atLeast(n)).method()验证方法被调用了至少 n 次
verify(mock, atMost(n)).method()验证方法被调用了至多 n 次

接上例,完成验证部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
@DisplayName("当用户名不存在时,应注册成功并发送邮件")
void test_register_user_success() {
// === Arrange & Stubbing ===
when(userRepository.existsByUsername("newUser")).thenReturn(false);

// === Act ===
boolean result = registrationService.registerUser("newUser", "new@example.com");

// === Assert (返回值) ===
assertThat(result).isTrue();

// === 3. 验证交互 (Verification) ===
// 验证:userRepository的save方法被【恰好调用了1次】
verify(userRepository, times(1)).save(any(User.class));
// 验证:notificationService的sendWelcomeEmail方法被【恰好调用了1次】,且参数正确
verify(notificationService).sendWelcomeEmail("new@example.com");
}

高级技巧:参数匹配器与参数捕获

参数匹配器 (Argument Matchers)

当我们不关心传入方法的具体参数值,或者无法预知参数值时使用。

  • 常用匹配器: any(), anyString(), anyInt(), any(User.class)
  • 规则: 如果一个方法的多个参数中,有一个使用了参数匹配器,那么所有参数都必须使用匹配器。对于那些你想使用具体值的参数,可以用eq()包裹。
1
2
3
4
5
// 正确:一个匹配器,一个具体值用eq()包裹
when(service.updateUser(eq(1L), any(UserDTO.class))).thenReturn(true);

// 错误:混合使用匹配器和原始值
// when(service.updateUser(1L, any(UserDTO.class))).thenReturn(true); // 这会抛出异常!

参数捕获 (ArgumentCaptor)

当你需要验证传递给模拟方法的参数的具体内容时,ArgumentCaptor是你的利器。例如,验证传入save方法的User对象的用户名是否正确。

使用三步:1. 创建 -> 2. 捕获 -> 3. 断言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
@DisplayName("注册成功时,保存的User对象用户名应正确")
void test_user_details_on_save() {
// Arrange
when(userRepository.existsByUsername("testUser")).thenReturn(false);

// 1. 创建一个User类型的参数捕获器
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);

// Act
registrationService.registerUser("testUser", "test@example.com");

// 2. 在验证时,使用.capture()来捕获传入的参数
verify(userRepository).save(userCaptor.capture());

// 3. 从捕获器中获取值并进行断言
User savedUser = userCaptor.getValue();
assertThat(savedUser.getUsername()).isEqualTo("testUser");
assertThat(savedUser.getEmail()).isEqualTo("test@example.com");
}