第五章. common-core 工具类(三):StreamUtils 与函数式编程

第五章. common-core 工具类(三):StreamUtils 与函数式编程

摘要:本章我们转向 StreamUtils,这是一个与 Spring 无关的纯 Java 集合处理工具。我们将学习 RVP 是如何封装 Java 8 Stream API 的,并掌握其在集合过滤、转换、分组和合并中的强大用法。我们将通过一个纯 main 方法的测试类来实战。

本章学习路径

中心主题:工具类(三):StreamUtils 与函数式编程


5.1. StreamUtils 的价值:为什么不直接用 Java 8 Stream?

你可能会有疑问:Java 8 早就引入了 stream() API,它已经很强大了,RVP 为什么还要自己封装一个 StreamUtils

答案是:为了“健壮性”和“便利性”。

5.1.1. 痛点:null 集合的 stream() 调用会抛 NullPointerException

Java 8 原生的 stream() API 存在一个“陷阱”:如果你的集合对象本身是 null,调用 stream() 会立刻抛出 NullPointerException(空指针异常)。

1
2
3
4
5
// 痛点代码 (Bad Practice)
List<String> list = null; // 假设这个 list 是从某个方法返回的,可能为 null

// 灾难:这行代码会立即抛出 NullPointerException
list.stream().filter(s -> s.equals("a")).collect(Collectors.toList());

为了安全地使用原生 API,你必须在每次调用前都进行“防御性”的空检查:

1
2
3
4
5
6
7
// 繁琐的防御性编程
List<String> result;
if (list != null) {
result = list.stream().filter(s -> s.equals("a")).collect(Collectors.toList());
} else {
result = new ArrayList<>(); // 必须手动返回一个空集合
}

这非常繁琐,违背了 Stream API 追求“优雅”的初衷。

5.1.2. RVP 封装:StreamUtils 的“空安全”设计

RVP 的 StreamUtils 完美解决了这个痛点。我们打开它的源码(ruoyi-common/ruoyi-common-core/src/main/java/org/dromara/common/core/utils/StreamUtils.java),随便看一个方法,比如 filter

1
2
3
4
5
6
7
8
9
10
11
// 位于 StreamUtils.java
public static <E> List<E> filter(Collection<E> collection, Predicate<E> function) {
// 核心:空安全检查
if (CollUtil.isEmpty(collection)) {
// 如果集合是 null 或空,直接返回一个空 ArrayList,而不是抛异常
return CollUtil.newArrayList();
}
return collection.stream()
.filter(function)
.collect(Collectors.toList());
}

StreamUtils 的所有方法 都在内部帮我们处理了 CollUtil.isEmpty()(Hutool 提供的集合空检查)

这就是它的核心价值

  1. 空安全(Null-Safe):你永远不用担心传入 null 集合会导致 NullPointerException
  2. 便利性:它总是能给你返回一个确定的结果(一个空集合 []),而不是 null,下游代码可以放心地继续处理,无需再做空判断。

5.1.3. 学习准备:在 ruoyi-demo 中创建 StreamUtilsTest 测试类

StreamUtils 是一个纯粹的 Java 工具类,它不依赖 Spring 容器。因此,我们不需要启动整个 Spring Boot 项目来测试它。

我们将使用 Java 最原始的 public static void main(String[] args)(主方法)来对它进行单元测试。


5.2. 测试准备:创建 main 方法与 listTestDemo 数据集

我们约定在 ruoyi-demo 模块中进行所有测试。

5.2.1. 创建 utils.test 包与 StreamUtilsTest.java

首先,我们在 ruoyi-demo 模块的 java 目录下创建一个新的包和测试类:

文件路径ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/utils/test/StreamUtilsTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 在 `org.dromara.demo` 下创建 `utils.test` 包
package org.dromara.demo.utils.test;

import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;

/**
* StreamUtils 工具类实战测试
* (这是一个纯 Java 测试,无需启动 Spring)
*/
public class StreamUtilsTest {

// 静态日志实例,方便在 main 方法使用
private static final Log console = LogFactory.get();

// ... 我们将在这里编写 main 方法和数据 ...

}

5.2.2. 编写 main 方法(psvm

StreamUtilsTest 类中,输入 psvm 并按回车,IDEA 会自动帮我们生成 main 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StreamUtilsTest {

private static final Log console = LogFactory.get();

public static void main(String[] args) {
// 这是我们的测试入口
console.info("StreamUtils 测试开始...");

// ... 我们将在这里调用各个测试方法 ...
}

// ...
}

5.2.3. 核心:编写 listTestDemo() 静态方法(提供 4 条 TestDemo 数据)

为了让所有测试方法都能复用同一批数据,我们创建一个静态方法 listTestDemo(),它返回一个包含 4 个 TestDemo 对象的 List 集合。

特别注意:我们故意让两条数据的 OrderNum(排序号)相同(都为 2),并让它们的值(Value)不同(v2, v3),这是为了后续测试“分组”和“查找”功能。。

StreamUtilsTest 类中添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
public class StreamUtilsTest {
// ... main 方法和 console ...

/**
* 准备一个包含 4 条测试数据的集合
*/
private static List<TestDemo> listTestDemo() {
return List.of(
createDemo(1, "k1", "v1"),
createDemo(2, "k2", "v2"),
createDemo(2, "k3", "v3"),
createDemo(4, "k4", "v4")
);
}

private static TestDemo createDemo(int orderNum, String testKey, String value) {
TestDemo demo = new TestDemo();
demo.setOrderNum(orderNum);
demo.setTestKey(testKey);
demo.setValue(value);
return demo;
}
}

5.3. 核心功能(一):过滤与查找 (filter, findFirst, findAny)

准备工作就绪!我们来编写第一个测试方法 testFilter

5.3.1. 编写 testFilter

我们先在 StreamUtilsTest 类中添加 testFilter 方法:

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
// ...
import java.util.function.Predicate; // 导入 Predicate
// ...
public class StreamUtilsTest {
// ...

/**
* 测试 1:过滤 (filter)
*/
public static void testFilter() {
// 1. 获取我们的数据集
List<TestDemo> list = listTestDemo();
// 2. 调用工具类过滤,目标:找到 orderNum == 1 的数据
List<TestDemo> result = StreamUtils.filter(list, demo -> demo.getOrderNum() == 1);
// 3. 打印结果
console.info("【testFilter 结果】: {}", result);
}

public static void main(String[] args) {
console.info("StreamUtils 测试开始...");
testFilter(); // 在 main 方法中调用它
}

// ... listTestDemo() 方法 ...
}

运行 main 方法 (右键点击 StreamUtilsTest -> Run 'StreamUtilsTest.main()')

控制台输出

1
2
3
... INFO ... StreamUtils 测试开始...
... INFO ... --- 1. 测试 filter ---
... INFO ... 【testFilter 结果】: [TestDemo(id=null, testKey=k1, value=v1, orderNum=1)]

过滤成功!

5.3.2. 编写 testFindFirst (演示 orderNum == 2 有两条数据)

filter 返回所有匹配项,findFirst 只返回第一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...
import java.util.Optional; // 导入 Optional
// ...
public static void testFindFirst() {
console.info("--- 2. 测试 findFirst ---");
List<TestDemo> list = listTestDemo(); // [Demo(1), Demo(2,v2), Demo(2,v3), Demo(4)]

// 目标:找到 orderNum == 2 的第一条数据
// 我们的数据集中有两条 (v2, v3),它应该只返回 v2
Optional<TestDemo> resultOpt = StreamUtils.findFirst(list, t -> t.getOrderNum() == 2);

// 打印结果
console.info("【testFindFirst 结果】: {}", resultOpt.get());
}

public static void main(String[] args) {
console.info("StreamUtils 测试开始...");
// testFilter(); // 注释掉上一个
testFindFirst(); // 调用这一个
}
// ...

Optional<T> 是什么?
findFirst 返回的是 Optional<TestDemo> 而不是 TestDemoOptional 是 Java 8 提供的“容器”类,用于优雅地处理 null

  • 如果找到了,Optional 就“装着” TestDemo 对象。
  • 如果没找到,Optional 就是“空的”。
  • 你可以通过 resultOpt.get() 强制取出里面的对象,但如果 Optional 是空的,get()抛出异常

运行 main 方法,控制台输出

1
2
... INFO ... --- 2. 测试 findFirst ---
... INFO ... 【testFindFirst 结果】: TestDemo(id=null, testKey=k2, value=v2, orderNum=2)

正如预期,它找到了第一条 OrderNum == 2 的数据(v2)。

5.3.4. 编写 testFindAny (演示 Optionalget()orElseGet())

findAnyfindFirst 类似,但在并行流(parallel stream)中,findAny 性能更高(它找到任意一个就返回)。

我们用 findAny 来演示如何“安全地”处理 Optional

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
// ...
public static void testFindAny() {
console.info("--- 3. 测试 findAny ---");
List<TestDemo> list = listTestDemo();

// 场景 1:能找到 (orderNum == 4)
Optional<TestDemo> opt1 = StreamUtils.findAny(list, t -> t.getOrderNum() == 4);
// 安全地获取:检查是否存在,如果存在才 get()
if (opt1.isPresent()) {
console.info("【findAny 场景 1 - 找到】: {}", opt1.get());
}

// 场景 2:找不到 (orderNum == 99)
Optional<TestDemo> opt2 = StreamUtils.findAny(list, t -> t.getOrderNum() == 99);

// 【错误演示】:如果 opt2 是空的,opt2.get() 会抛异常
// console.info("【findAny 场景 2 - 错误】: {}", opt2.get());

// 【正确演示】:使用 orElse() 或 orElseGet() 提供一个“默认值”
TestDemo result2 = opt2.orElse(new TestDemo()); // 如果找不到,返回一个新对象
console.info("【findAny 场景 2 - 默认值】: {}", result2);

// 【正确演示 2】:如果找不到,返回 null
TestDemo result3 = opt2.orElse(null);
console.info("【findAny 场景 2 - orElse(null)】: {}", result3);

// RVP 额外封装了 findFirstValue / findAnyValue,它们内部调用了 orElse(null)
TestDemo result4 = StreamUtils.findAnyValue(list, t -> t.getOrderNum() == 99);
console.info("【findAnyValue 场景 2 - 结果】: {}", result4);
}

public static void main(String[] args) {
console.info("StreamUtils 测试开始...");
// testFilter();
// testFindFirst();
testFindAny();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
... INFO ... --- 3. 测试 findAny ---
... INFO ... 【findAny 场景 1 - 找到】: TestDemo(id=null, testKey=k4, value=v4, orderNum=4)
... INFO ... 【findAny 场景 2 - 默认值】: TestDemo(id=null, testKey=null, value=null, orderNum=null)
... INFO ... 【findAny 场景 2 - orElse(null)】: null
... INFO ... 【findAnyValue 场景 2 - 结果】: null

结论Optional 配合 isPresent()orElse() (或 RVP 的 findAnyValue),可以写出极其健壮、永不空指针的代码。


5.4. 核心功能(二):转换与收集 (join, toList, toSet)

“转换”是指将 List<TestDemo>(对象集合)转换成 List<String>(属性集合),或者一个 Set 集合,甚至是拼接好的一个字符串。

5.4.1. 编写 testJoin (演示默认逗号与自定义 @ 分隔符)

join 方法用于将一个集合“拍扁”,并用指定的分隔符拼接成一个字符串。它需要一个 Function (函数) 接口,你只需要知道:它就是一个“转换器”,你给它一个 TestDemo 对象,它返回一个 String 字符串给你

StreamUtilsTest 类中添加 testJoin 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
import java.util.function.Function; // 导入 Function
// ...
public static void testJoin() {
console.info("--- 4. 测试 join ---");
List<TestDemo> list = listTestDemo();

// 场景 1:默认逗号拼接
// 我们要把 list 里的 "value" 属性 (v1, v2, v3, v4) 拼接起来
// 我们直接使用“方法引用”(Method Reference),这是最简洁的写法
String join1 = StreamUtils.join(list, TestDemo::getValue);
console.info("【join 默认逗号】: {}", join1);

// 场景 2:自定义分隔符 (例如用 '@' 拼接)
String join2 = StreamUtils.join(list, TestDemo::getValue, "@");
console.info("【join 自定义 '@'】: {}", join2);
}

public static void main(String[] args) {
// ...
// testFindAny(); // 注释掉上一个
testJoin(); // 调用这一个
}
// ...

运行 main 方法,控制台输出

1
2
3
... INFO ... --- 4. 测试 join ---
... INFO ... 【join 默认逗号】: v1,v2,v3,v4
... INFO ... 【join 自定义 '@'】: v1@v2@v3@v4

5.4.2. 编写 testToList (从 List<TestDemo> 提取 List<String>)

toListStreamUtils 中最常用的“转换”方法。它和 join 一样接收一个 Function,但它不把结果拼接,而是 收集到一个新的 List 集合 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
public static void testToList() {
console.info("--- 5. 测试 toList (转换) ---");
List<TestDemo> list = listTestDemo(); // [Demo(1), Demo(2,v2), Demo(2,v3), Demo(4)]

// 目标:把 List<TestDemo> 转换为 List<String> (只包含 value)
// 同样使用方法引用 TestDemo::getValue
List<String> valueList = StreamUtils.toList(list, TestDemo::getValue);

console.info("【toList<String> 结果】: {}", valueList);
}

public static void main(String[] args) {
// ...
// testJoin();
testToList();
}
// ...

运行 main 方法,控制台输出

1
2
... INFO ... --- 5. 测试 toList (转换) ---
... INFO ... 【toList<String> 结果】: [v1, v2, v3, v4]

toList数据脱水(例如从 List<User> 提取 List<Long> (用户 ID))的“神器”。

5.4.3. 编写 testToSet (演示 orderNum 的去重)

toSettoList 的用法完全一样,唯一的区别是它会 自动去重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ...
import java.util.Set; // 导入 Set
// ...
public static void testToSet() {
console.info("--- 6. 测试 toSet (转换 + 去重) ---");
List<TestDemo> list = listTestDemo(); // [Demo(1), Demo(2,v2), Demo(2,v3), Demo(4)]

// 目标:提取所有 OrderNum,并去重
// 原始 OrderNum 是:[1, 2, 2, 4]
Set<Integer> orderNumSet = StreamUtils.toSet(list, TestDemo::getOrderNum);

console.info("【toSet<Integer> 结果】: {}", orderNumSet);
}

public static void main(String[] args) {
// ...
// testToList();
testToSet();
}
// ...

运行 main 方法,控制台输出

1
2
... INFO ... --- 6. 测试 toSet (转换 + 去重) ---
... INFO ... 【toSet<Integer> 结果】: [1, 2, 4]

结论toSet 自动将 [1, 2, 2, 4] 转换并去重为 [1, 2, 4]


5.5. 核心功能(三):排序 (sorted)

sorted 方法用于对集合进行排序,它需要我们提供一个 Comparator(比较器)。

Comparator (比较器):它也是一个函数式接口。它负责比较两个对象(o1, o2),并返回一个 int 值:

  • 负数o1 排在 o2 前面(o1 < o2)。
  • o1o2 相等。
  • 正数o1 排在 o2 后面(o1 > o2)。

5.5.1. 编写 testSorted

我们 直接使用 Comparator 提供的辅助方法,这是最简洁、最现代的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
import java.util.Comparator; // 导入 Comparator
// ...
public static void testSort() {
List<TestDemo> list = listTestDemo();
Comparator<TestDemo> comparator = Comparator.comparingInt(TestDemo::getOrderNum);
List<TestDemo> sortedASC = StreamUtils.sorted(list, comparator);
// 为了方便查看排序结果,我们用 toList 提取出 OrderNum
console.info("【sorted 升序 结果】: {}", StreamUtils.toList(sortedASC, TestDemo::getOrderNum));
List<TestDemo> sortedDESC = StreamUtils.sorted(list, comparator.reversed());
console.info("【sorted 降序 结果】: {}", StreamUtils.toList(sortedDESC, TestDemo::getOrderNum));
}

public static void main(String[] args) {
// ...
// testToSet();
testSorted();
}
// ...

运行 main 方法,控制台输出

1
2
3
... INFO ... --- 7. 测试 sorted ---
... INFO ... 【sorted 升序 结果】: [1, 2, 2, 4]
... INFO ... 【sorted 降序 结果】: [4, 2, 2, 1]

5.5.2. 【陷阱】NullPointerException 分析(当 orderNumnull 时)

Comparator.comparingInt() 这种写法(包括 Lambda 的 o1.getOrderNum() - o2.getOrderNum())都隐藏着一个 致命陷阱:如果 TestDemo 对象的 orderNum 字段是 null(而不是 int 的 0),排序会 立即抛出 NullPointerException

如何修复:在真实的企业级开发中,当排序字段 可能为 null 时,你必须使用 Comparator 提供的空安全方法:

1
2
3
4
5
// 修复 1:将 null 排在最前面 (注意 comparingInt 不支持,要用 comparing)
Comparator.nullsFirst(Comparator.comparing(TestDemo::getOrderNum))

// 修复 2:将 null 排在最后面
Comparator.nullsLast(Comparator.comparing(TestDemo::getOrderNum))

5.6. 核心功能(四):转为 Map (toIdentityMap, toMap)

这是 StreamUtils 中功能最强大、也最容易出错的转换:List 转为 Map

5.6.1. 编写 testToIdentityMap (Value 作为 Key,对象作为 Value)

toIdentityMap 的使用场景是:Collection<V> -> Map<K, V>

  • Key:由你提供的方法(Function)生成。
  • Value始终是原始的对象 V 本身
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
import java.util.Map; // 导入 Map
// ...
public static void testToIdentityMap() {
console.info("--- 8. 测试 toIdentityMap (Key -> V) ---");
List<TestDemo> list = listTestDemo(); // [Demo(1,v1), Demo(2,v2), Demo(2,v3), Demo(4,v4)]

// 目标:将 value (v1, v2...) 作为 Key,TestDemo 对象本身作为 Value
// 结果:Map<String, TestDemo>

// 为了测试“Key 冲突”,我们故意让 v3 -> v2
list.get(2).setValue("v2"); // 把第 3 条数据的 value 也改成 "v2"

Map<String, TestDemo> resultMap = StreamUtils.toIdentityMap(list, TestDemo::getValue);

console.info("【toIdentityMap 结果】: {}", resultMap);
}

public static void main(String[] args) {
// ...
// testSorted();
testToIdentityMap();
}
// ...

5.6.2. 【陷阱】toMap 系列的“Key 冲突”策略

testToIdentityMap 中,我们的 list 变成了:[Demo(k2,v2), Demo(k3,v2)]。这意味着 toMap 时,"v2" 这个 Key 会对应两个 TestDemo 对象。程序会崩溃吗?

不会。
RVP 的 StreamUtils.toIdentityMaptoMap 在封装时,已经帮我们处理了 Key 冲突

我们查看 StreamUtils.toIdentityMap 源码:

1
2
// ...
.collect(Collectors.toMap(key, Function.identity(), (l, r) -> l));

(l, r) -> l 这个合并函数是关键!

  • l (left):代表 存入 Map 的 Value。
  • r (right):代表 来试图存入 Map 的 Value。
  • (l, r) -> l:意味着“保留先来的,丢弃后来的”。

运行 main 方法,控制台输出

1
2
... INFO ... --- 8. 测试 toIdentityMap (Key -> V) ---
... INFO ... 【toIdentityMap 结果】: {v1=TestDemo(...v1...), v2=TestDemo(...v2...), v4=TestDemo(...v4...)}

分析

  1. v1 存入 -> {"v1": Demo(v1)}
  2. v2 存入 -> {"v1": Demo(v1), "v2": Demo(v2)} (这条是 k2 的)
  3. 第三个元素的 Key 也是 v2,触发 (l, r) -> ll 是已存在的 Demo(v2)r 是新来的 Demo(k3,v2)。保留 l
  4. v4 存入 -> 最终结果。

注意:RVP 5.x 源码中 toIdentityMaptoMap(l, r) -> l 策略,是区别于原生 Collectors.toMap()(原生 API 遇到冲突会直接抛异常)的重要特性。

5.6.3. 编写 testToMap (OrderNum 作 Key,Value 作 Value)

toMaptoIdentityMap 的“完全体”:Collection<E> -> Map<K, V>。它允许你 同时自定义 Key 和 Value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...
public static void testToMap() {
console.info("--- 9. 测试 toMap (K -> V) ---");
List<TestDemo> list = listTestDemo(); // [1, 2, 2, 4]

// 目标 1:OrderNum 为 Key, Value 字段为 Value
// 结果:Map<Integer, String>
// Key 2 会冲突,根据 (l, r) -> l 策略,v3 会被丢弃
Map<Integer, String> resultMap1 = StreamUtils.toMap(
list,
TestDemo::getOrderNum, // K
TestDemo::getValue // V
);
console.info("【toMap K->V G结果】: {}", resultMap1);
}

public static void main(String[] args) {
// ...
// testToIdentityMap();
testToMap();
}
// ...

运行 main 方法,控制台输出

1
2
... INFO ... --- 9. 测试 toMap (K -> V) ---
... INFO ... 【toMap K->V 结果】: {1=v1, 2=v2, 4=v4}

分析
OrderNum2 的有 v2v3 两条。v2 先存入 Map。当 v3 尝试存入时,触发 (l, r) -> l 策略,v3 被丢弃,v2 被保留。


5.7. 核心功能(五):分组 (groupByKey, groupBy2Key, group2Map)

在上一节(5.4 - 5.6)中,我们掌握了集合的“转换”(toList/toSet)、“排序”(sorted)以及“转为 Map”(toMap),并特别注意了 toMap(l, r) -> lKey 冲突保留策略

现在,我们进入 Stream 中最强大的功能之一:分组

“分组”是 Stream 中最高频的场景之一,它可以将一个 List 快速转换为 Map<K, List<V>>,例如“按部门给员工分组”、“按日期给订单分组”。

5.7.1. 编写 testGroupByKey (按 orderNum 分组)

groupByKey 是最常用的分组,它接收一个 Function 来指定按哪个 Key 分组。

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
// ...
import java.util.Map; // 确保导入
// ...
public static void testGroupByKey() {
console.info("--- 10. 测试 groupByKey (单层分组) ---");
List<TestDemo> list = listTestDemo(); // [1, 2, 2, 4]

// 目标:按 OrderNum 分组
// 结果:Map<Integer, List<TestDemo>>

// 直接使用方法引用指定 Key
Map<Integer, List<TestDemo>> groupMap = StreamUtils.groupByKey(
list,
TestDemo::getOrderNum
);

console.info("【groupByKey 结果】: {}", groupMap);
}

public static void main(String[] args) {
// ...
// testToMap();
testGroupByKey();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
6
... INFO ... --- 10. 测试 groupByKey (单层分组) ---
... INFO ... 【groupByKey 结果】: {
1=[TestDemo(orderNum=1, value=v1)],
2=[TestDemo(orderNum=2, value=v2), TestDemo(orderNum=2, value=v3)],
4=[TestDemo(orderNum=4, value=v4)]
}

分析OrderNum2 的两条数据(v2, v3)被正确地分到了同一个 List 中。

5.7.2. 编写 testGroupBy2Key (按 orderNumvalue 双层分组)

groupBy2Key 允许我们进行“二级分组”,例如“先按省份,再按城市”。它会返回一个 嵌套 Map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ...
public static void testGroupBy2Key() {
console.info("--- 11. 测试 groupBy2Key (双层分组 - List) ---");
List<TestDemo> list = listTestDemo(); // [1, 2, 2, 4]

// 目标:先按 OrderNum 分组,再按 Value 分组
// 结果:Map<Integer, Map<String, List<TestDemo>>>
Map<Integer, Map<String, List<TestDemo>>> groupMap = StreamUtils.groupBy2Key(
list,
TestDemo::getOrderNum, // 第一层 Key
TestDemo::getValue // 第二层 Key
);

console.info("【groupBy2Key 结果】: {}", groupMap);
}

public static void main(String[] args) {
// ...
// testGroupByKey();
testGroupBy2Key();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
... INFO ... --- 11. 测试 groupBy2Key (双层分组 - List) ---
... INFO ... 【groupBy2Key 结果】: {
1={v1=[TestDemo(v1)]},
2={
v2=[TestDemo(v2)],
v3=[TestDemo(v3)]
},
4={v4=[TestDemo(v4)]}
}

分析OrderNum2 的分组下,又按 v2v3 进行了第二层分组。

5.7.3. 编写 testGroup2Map (对比 groupBy2Key,演示 Key 冲突与覆盖)

group2Map 看似与 groupBy2Key 相似,但 区别巨大

  • groupBy2Key:返回 Map<K1, Map<K2, List<E>>>,内层是 List不会丢数据
  • group2Map:返回 Map<K1, Map<K2, E>>,内层是 E(对象本身),它实质上是 Collectors.toMap

这意味着 group2Map 会触发我们在 5.6.2 节学到的 (l, r) -> l 冲突保留策略,从而导致数据丢失!

我们来演示这个“陷阱”:

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
// ...
public static void testGroup2Map() {
console.info("--- 12. 测试 group2Map (双层分组 - 对象) ---");

// 故意制造“双重 Key 冲突”
List<TestDemo> list = listTestDemo();
// 把 v3 改成 v2,现在 list 中有两条 [OrderNum=2, Value=v2]
list.get(2).setValue("v2");
list.get(2).setTestKey("k3-modified"); // 改个 key 区分

// --- 演示 groupBy2Key (安全,不丢数据) ---
// 目标:Map<Integer, Map<String, List<TestDemo>>>
Map<Integer, Map<String, List<TestDemo>>> groupMap1 = StreamUtils.groupBy2Key(
list,
TestDemo::getOrderNum, // K1
TestDemo::getValue // K2
);
console.info("【groupBy2Key 结果 (安全)】: {}", groupMap1);

// --- 演示 group2Map (危险,丢数据) ---
// 目标:Map<Integer, Map<String, TestDemo>>
Map<Integer, Map<String, TestDemo>> groupMap2 = StreamUtils.group2Map(
list,
TestDemo::getOrderNum, // K1
TestDemo::getValue // K2
);
console.info("【group2Map 结果 (丢数据)】: {}", groupMap2);
}

public static void main(String[] args) {
// ...
// testGroupBy2Key();
testGroup2Map();
}
// ...

运行 main 方法,控制台输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
... INFO ... --- 12. 测试 group2Map (双层分组 - 对象) ---
... INFO ... 【groupBy2Key 结果 (安全)】: {
1={v1=[TestDemo(k1,v1)]},
2={
v2=[TestDemo(k2,v2), TestDemo(k3-modified,v2)]
},
4={v4=[TestDemo(k4,v4)]}
}
... INFO ... 【group2Map 结果 (丢数据)】: {
1={v1=TestDemo(k1,v1)},
2={
v2=TestDemo(k2,v2)
},
4={v4=TestDemo(k4,v4)}
}

结论

  • groupBy2Key 安全地保留了 OrderNum=2, Value=v2两条 数据。
  • group2MapOrderNum=2, Value=v2 这个 Key 上发生了冲突,执行 (l, r) -> l 策略,保留了先来的 TestDemo(k2,v2),丢弃了后来的 TestDemo(k3-modified,v2)
  • 使用 group2Map 前必须确保你的二级 Key 是唯一的,否则请使用 groupBy2Key

5.8. 核心功能(六):合并 (merge)

mergeStreamUtils 中最复杂的一个方法,用于合并两个 Map,即使它们的 Value 类型不同,只要 Key 类型相同即可。

5.8.1. 编写 testMerge (创建 map1map2)

BiFunction<X, Y, V> 接口
merge 方法需要一个 BiFunction (双参数函数) 接口。你只需要知道:它接收两个参数(map1 的 Value 和 map2 的 Value),由你决定如何将它们“合并”成一个新 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ...
import cn.hutool.core.map.MapUtil; // 导入 MapUtil
import cn.hutool.core.util.ObjectUtil; // 导入 ObjectUtil
import org.dromara.demo.domain.vo.TestDemoVo; // 假设我们有一个 VO
import java.util.function.BiFunction;
// ...
public static void testMerge() {
console.info("--- 13. 测试 merge (合并 Map) ---");

// 1. 准备 map1 (Key="1", Value=TestDemo)
Map<String, TestDemo> map1 = MapUtil.newHashMap();
TestDemo demo1 = new TestDemo();
demo1.setTestKey("k1");
map1.put("1", demo1);
map1.put("only_in_map1", new TestDemo()); // map1 独有的 Key

// 2. 准备 map2 (Key="1", Value=Integer)
Map<String, Integer> map2 = MapUtil.newHashMap();
map2.put("1", 100); // Key 冲突
map2.put("only_in_map2", 200); // map2 独有的 Key

// 目标:将 map1 和 map2 合并为 Map<String, TestDemoVo>
// 合并逻辑:将 map2 的 Integer 设置到 map1 的 TestDemo 中,再转为 Vo

// 3. 定义合并函数 (BiFunction)
BiFunction<TestDemo, Integer, TestDemoVo> mergeLogic = (demo, num) -> {

// 【核心陷阱处理】
// 当 Key 只在 map2 中存在时,demo 会是 null
// 当 Key 只在 map1 中存在时,num 会是 null

// 优雅的写法:
// TestDemo safeDemo = ObjectUtil.defaultIfNull(demo, new TestDemo());
// safeDemo.setOrderNum(ObjectUtil.defaultIfNull(num, 0));
// return BeanUtil.toBean(safeDemo, TestDemoVo.class);

// 为了演示 NullPointerException,我们先写错误的代码:
demo.setOrderNum(num); // 如果 demo 或 num 为 null,这里会崩溃

TestDemoVo vo = new TestDemoVo();
vo.setTestKey(demo.getTestKey());
vo.setOrderNum(demo.getOrderNum());
return vo;
};

// 4. 执行合并
try {
Map<String, TestDemoVo> mergedMap = StreamUtils.merge(map1, map2, mergeLogic);
console.info("【merge 结果】: {}", mergedMap);
} catch (Exception e) {
console.error("【merge 失败】: 发生 NullPointerException!", e);
}
}

public static void main(String[] args) {
// ...
// testGroup2Map();
testMerge();
}
// ...

5.8.2. 【陷阱】NullPointerException 分析与修复

运行 main 方法,控制台输出

1
2
3
... INFO ... --- 13. 测试 merge (合并 Map) ---
... ERROR ... 【merge 失败】: 发生 NullPointerException!
java.lang.NullPointerException: Cannot invoke "org.dromara.demo.domain.TestDemo.setOrderNum(java.lang.Integer)" because "demo" is null

分析
StreamUtils.merge 内部会遍历两个 map 的 所有 Key 的并集"1", "only_in_map1", "only_in_map2")。

  1. 当 Key = "1" 时:demo(有值), num(有值, 100)。合并成功。
  2. 当 Key = "only_in_map1" 时:demo(有值), numnull)。执行 demo.setOrderNum(null),抛 NullPointerException。(如果 OrderNumint 而非 Integer,拆箱时会崩溃)。
  3. 当 Key = "only_in_map2" 时:demonull), num(有值, 200)。执行 null.setOrderNum(200),抛 NullPointerException

【修复 testMerge 方法】
我们必须在 BiFunction 中做空判断。

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
// ...
public static void testMerge() {
console.info("--- 13. 测试 merge (合并 Map) ---");

// 1. 准备 map1 (Key="1", Value=TestDemo)
Map<String, TestDemo> map1 = MapUtil.newHashMap();
TestDemo demo1 = new TestDemo();
demo1.setTestKey("k1");
map1.put("1", demo1);
map1.put("only_in_map1", new TestDemo());

// 2. 准备 map2 (Key="1", Value=Integer)
Map<String, Integer> map2 = MapUtil.newHashMap();
map2.put("1", 100);
map2.put("only_in_map2", 200);

// 3. 定义【健壮的】合并函数 (BiFunction)
BiFunction<TestDemo, Integer, TestDemoVo> mergeLogic = (demo, num) -> {

// 【修复】: 如果 demo 是 null,我们就 new 一个
TestDemo safeDemo = ObjectUtil.defaultIfNull(demo, new TestDemo());

// 【修复】: 如果 num 是 null,我们就用 0
safeDemo.setOrderNum(ObjectUtil.defaultIfNull(num, 0));

// 假设我们有一个 TestDemoVo
TestDemoVo vo = new TestDemoVo();
vo.setTestKey(safeDemo.getTestKey());
vo.setOrderNum(safeDemo.getOrderNum());
return vo;
};

// 4. 执行合并
Map<String, TestDemoVo> mergedMap = StreamUtils.merge(map1, map2, mergeLogic);
console.info("【merge 结果 (已修复)】: {}", mergedMap);
}

public static void main(String[] args) {
// ...
// testGroup2Map();
testMerge();
}
// ...

再次运行 main 方法,控制台输出

1
2
3
4
5
6
... INFO ... --- 13. 测试 merge (合并 Map) ---
... INFO ... 【merge 结果 (已修复)】: {
1=TestDemoVo(testKey=k1, orderNum=100),
only_in_map1=TestDemoVo(testKey=null, orderNum=0),
only_in_map2=TestDemoVo(testKey=null, orderNum=200)
}

结论merge 非常强大,但也非常危险,必须在合并逻辑中处理 null


5.9. 本章总结

在本章中,我们聚焦于 StreamUtils 这一纯 Java 集合处理工具类。我们摒弃了繁琐的“演进”过程,直接使用了最高效的 Lambda 表达式和方法引用来实战。

我们掌握了:

  1. StreamUtils 的核心价值:它提供了**空安全(Null-Safe)**的封装,避免了原生 list.stream()listnull 时抛出的 NullPointerException
  2. 过滤与查找filter (返回列表), findFirst/findAny (返回 Optional)。
  3. 转换与收集toList (转为新 List), toSet (自动去重), join (拼接字符串)。
  4. 排序sorted 配合 Comparator.comparingInt()reversed() 实现升降序。
  5. 转 Map(重点)toIdentityMaptoMap,并理解了它们 (l, r) -> l 的 Key 冲突保留策略。
  6. 分组(重点)groupByKey (一级分组), groupBy2Key (二级分组, 安全), group2Map (二级分组, 会丢数据)。
  7. 合并(难点)merge 用于合并两个不同 Value 类型的 Map,但必须在合并函数中 手动处理 null

StreamUtils 体现了 RVP 框架对 Java 8 Stream API 的“健壮性”增强,使业务代码在处理集合时更加简洁和安全。