第五章. common-core 工具类(三):StreamUtils 与函数式编程
第五章. common-core 工具类(三):StreamUtils 与函数式编程
Prorise第五章. common-core 工具类(三):StreamUtils 与函数式编程
摘要:本章我们转向 StreamUtils,这是一个与 Spring 无关的纯 Java 集合处理工具。我们将学习 RVP 是如何封装 Java 8 Stream API 的,并掌握其在集合过滤、转换、分组和合并中的强大用法。我们将通过一个纯 main 方法的测试类来实战。
本章学习路径

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 | // 痛点代码 (Bad Practice) |
为了安全地使用原生 API,你必须在每次调用前都进行“防御性”的空检查:
1 | // 繁琐的防御性编程 |
这非常繁琐,违背了 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 | // 位于 StreamUtils.java |
StreamUtils 的所有方法 都在内部帮我们处理了 CollUtil.isEmpty()(Hutool 提供的集合空检查)。
这就是它的核心价值:
- 空安全(Null-Safe):你永远不用担心传入
null集合会导致NullPointerException。 - 便利性:它总是能给你返回一个确定的结果(一个空集合
[]),而不是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 | // 1. 在 `org.dromara.demo` 下创建 `utils.test` 包 |
5.2.2. 编写 main 方法(psvm)
在 StreamUtilsTest 类中,输入 psvm 并按回车,IDEA 会自动帮我们生成 main 方法:
1 | public class StreamUtilsTest { |
5.2.3. 核心:编写 listTestDemo() 静态方法(提供 4 条 TestDemo 数据)
为了让所有测试方法都能复用同一批数据,我们创建一个静态方法 listTestDemo(),它返回一个包含 4 个 TestDemo 对象的 List 集合。
特别注意:我们故意让两条数据的 OrderNum(排序号)相同(都为 2),并让它们的值(Value)不同(v2, v3),这是为了后续测试“分组”和“查找”功能。。
在 StreamUtilsTest 类中添加以下方法:
1 | // ... |
5.3. 核心功能(一):过滤与查找 (filter, findFirst, findAny)
准备工作就绪!我们来编写第一个测试方法 testFilter。
5.3.1. 编写 testFilter
我们先在 StreamUtilsTest 类中添加 testFilter 方法:
1 | // ... |
运行 main 方法 (右键点击 StreamUtilsTest -> Run 'StreamUtilsTest.main()')
控制台输出:
1 | ... INFO ... StreamUtils 测试开始... |
过滤成功!
5.3.2. 编写 testFindFirst (演示 orderNum == 2 有两条数据)
filter 返回所有匹配项,findFirst 只返回第一个。
1 | // ... |
Optional<T> 是什么?findFirst 返回的是 Optional<TestDemo> 而不是 TestDemo。Optional 是 Java 8 提供的“容器”类,用于优雅地处理 null。
- 如果找到了,
Optional就“装着”TestDemo对象。 - 如果没找到,
Optional就是“空的”。 - 你可以通过
resultOpt.get()强制取出里面的对象,但如果Optional是空的,get()会 抛出异常。
运行 main 方法,控制台输出:
1 | ... INFO ... --- 2. 测试 findFirst --- |
正如预期,它找到了第一条 OrderNum == 2 的数据(v2)。
5.3.4. 编写 testFindAny (演示 Optional 的 get() 与 orElseGet())
findAny 和 findFirst 类似,但在并行流(parallel stream)中,findAny 性能更高(它找到任意一个就返回)。
我们用 findAny 来演示如何“安全地”处理 Optional。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 3. 测试 findAny --- |
结论: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 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 4. 测试 join --- |
5.4.2. 编写 testToList (从 List<TestDemo> 提取 List<String>)
toList 是 StreamUtils 中最常用的“转换”方法。它和 join 一样接收一个 Function,但它不把结果拼接,而是 收集到一个新的 List 集合 中。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 5. 测试 toList (转换) --- |
toList 是 数据脱水(例如从 List<User> 提取 List<Long> (用户 ID))的“神器”。
5.4.3. 编写 testToSet (演示 orderNum 的去重)
toSet 和 toList 的用法完全一样,唯一的区别是它会 自动去重。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 6. 测试 toSet (转换 + 去重) --- |
结论:toSet 自动将 [1, 2, 2, 4] 转换并去重为 [1, 2, 4]。
5.5. 核心功能(三):排序 (sorted)
sorted 方法用于对集合进行排序,它需要我们提供一个 Comparator(比较器)。
Comparator (比较器):它也是一个函数式接口。它负责比较两个对象(o1, o2),并返回一个 int 值:
- 负数:
o1排在o2前面(o1 < o2)。 - 零:
o1和o2相等。 - 正数:
o1排在o2后面(o1 > o2)。
5.5.1. 编写 testSorted
我们 直接使用 Comparator 提供的辅助方法,这是最简洁、最现代的写法。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 7. 测试 sorted --- |
5.5.2. 【陷阱】NullPointerException 分析(当 orderNum 为 null 时)
Comparator.comparingInt() 这种写法(包括 Lambda 的 o1.getOrderNum() - o2.getOrderNum())都隐藏着一个 致命陷阱:如果 TestDemo 对象的 orderNum 字段是 null(而不是 int 的 0),排序会 立即抛出 NullPointerException!
如何修复:在真实的企业级开发中,当排序字段 可能为 null 时,你必须使用 Comparator 提供的空安全方法:
1 | // 修复 1:将 null 排在最前面 (注意 comparingInt 不支持,要用 comparing) |
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 | // ... |
5.6.2. 【陷阱】toMap 系列的“Key 冲突”策略
在 testToIdentityMap 中,我们的 list 变成了:[Demo(k2,v2), Demo(k3,v2)]。这意味着 toMap 时,"v2" 这个 Key 会对应两个 TestDemo 对象。程序会崩溃吗?
不会。
RVP 的 StreamUtils.toIdentityMap 和 toMap 在封装时,已经帮我们处理了 Key 冲突。
我们查看 StreamUtils.toIdentityMap 源码:
1 | // ... |
(l, r) -> l 这个合并函数是关键!
l(left):代表 先 存入 Map 的 Value。r(right):代表 后 来试图存入 Map 的 Value。(l, r) -> l:意味着“保留先来的,丢弃后来的”。
运行 main 方法,控制台输出:
1 | ... INFO ... --- 8. 测试 toIdentityMap (Key -> V) --- |
分析:
v1存入 ->{"v1": Demo(v1)}v2存入 ->{"v1": Demo(v1), "v2": Demo(v2)}(这条是 k2 的)- 第三个元素的 Key 也是
v2,触发(l, r) -> l。l是已存在的Demo(v2),r是新来的Demo(k3,v2)。保留l。 v4存入 -> 最终结果。
注意:RVP 5.x 源码中 toIdentityMap 和 toMap 的 (l, r) -> l 策略,是区别于原生 Collectors.toMap()(原生 API 遇到冲突会直接抛异常)的重要特性。
5.6.3. 编写 testToMap (OrderNum 作 Key,Value 作 Value)
toMap 是 toIdentityMap 的“完全体”:Collection<E> -> Map<K, V>。它允许你 同时自定义 Key 和 Value。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 9. 测试 toMap (K -> V) --- |
分析:OrderNum 为 2 的有 v2 和 v3 两条。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) -> l 的 Key 冲突保留策略。
现在,我们进入 Stream 中最强大的功能之一:分组。
“分组”是 Stream 中最高频的场景之一,它可以将一个 List 快速转换为 Map<K, List<V>>,例如“按部门给员工分组”、“按日期给订单分组”。
5.7.1. 编写 testGroupByKey (按 orderNum 分组)
groupByKey 是最常用的分组,它接收一个 Function 来指定按哪个 Key 分组。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 10. 测试 groupByKey (单层分组) --- |
分析:OrderNum 为 2 的两条数据(v2, v3)被正确地分到了同一个 List 中。
5.7.2. 编写 testGroupBy2Key (按 orderNum 和 value 双层分组)
groupBy2Key 允许我们进行“二级分组”,例如“先按省份,再按城市”。它会返回一个 嵌套 Map。
1 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 11. 测试 groupBy2Key (双层分组 - List) --- |
分析:OrderNum 为 2 的分组下,又按 v2 和 v3 进行了第二层分组。
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 | // ... |
运行 main 方法,控制台输出:
1 | ... INFO ... --- 12. 测试 group2Map (双层分组 - 对象) --- |
结论:
groupBy2Key安全地保留了OrderNum=2, Value=v2的 两条 数据。group2Map在OrderNum=2, Value=v2这个 Key 上发生了冲突,执行(l, r) -> l策略,保留了先来的TestDemo(k2,v2),丢弃了后来的TestDemo(k3-modified,v2)。- 使用
group2Map前必须确保你的二级 Key 是唯一的,否则请使用groupBy2Key。
5.8. 核心功能(六):合并 (merge)
merge 是 StreamUtils 中最复杂的一个方法,用于合并两个 Map,即使它们的 Value 类型不同,只要 Key 类型相同即可。
5.8.1. 编写 testMerge (创建 map1 和 map2)
BiFunction<X, Y, V> 接口:merge 方法需要一个 BiFunction (双参数函数) 接口。你只需要知道:它接收两个参数(map1 的 Value 和 map2 的 Value),由你决定如何将它们“合并”成一个新 Value。
1 | // ... |
5.8.2. 【陷阱】NullPointerException 分析与修复
运行 main 方法,控制台输出:
1 | ... INFO ... --- 13. 测试 merge (合并 Map) --- |
分析:StreamUtils.merge 内部会遍历两个 map 的 所有 Key 的并集("1", "only_in_map1", "only_in_map2")。
- 当 Key =
"1"时:demo(有值),num(有值, 100)。合并成功。 - 当 Key =
"only_in_map1"时:demo(有值),num(null)。执行demo.setOrderNum(null),抛NullPointerException。(如果OrderNum是int而非Integer,拆箱时会崩溃)。 - 当 Key =
"only_in_map2"时:demo(null),num(有值, 200)。执行null.setOrderNum(200),抛NullPointerException。
【修复 testMerge 方法】
我们必须在 BiFunction 中做空判断。
1 | // ... |
再次运行 main 方法,控制台输出:
1 | ... INFO ... --- 13. 测试 merge (合并 Map) --- |
结论:merge 非常强大,但也非常危险,必须在合并逻辑中处理 null 值。
5.9. 本章总结
在本章中,我们聚焦于 StreamUtils 这一纯 Java 集合处理工具类。我们摒弃了繁琐的“演进”过程,直接使用了最高效的 Lambda 表达式和方法引用来实战。
我们掌握了:
StreamUtils的核心价值:它提供了**空安全(Null-Safe)**的封装,避免了原生list.stream()在list为null时抛出的NullPointerException。- 过滤与查找:
filter(返回列表),findFirst/findAny(返回Optional)。 - 转换与收集:
toList(转为新 List),toSet(自动去重),join(拼接字符串)。 - 排序:
sorted配合Comparator.comparingInt()和reversed()实现升降序。 - 转 Map(重点):
toIdentityMap和toMap,并理解了它们(l, r) -> l的 Key 冲突保留策略。 - 分组(重点):
groupByKey(一级分组),groupBy2Key(二级分组, 安全),group2Map(二级分组, 会丢数据)。 - 合并(难点):
merge用于合并两个不同 Value 类型的 Map,但必须在合并函数中 手动处理null。
StreamUtils 体现了 RVP 框架对 Java 8 Stream API 的“健壮性”增强,使业务代码在处理集合时更加简洁和安全。









