
本文详细介绍了如何利用java stream api,特别是collectors.groupingby与collectors.reducing组合,高效地对数据进行多条件聚合。通过构建自定义度量类,我们能够同时实现按月份对特定数值进行求和,并统计对应事件的数量。教程涵盖了数据模型定义、聚合逻辑实现以及最终结果的映射转换,并强调了在计数场景中事件计数与去重实体计数的区别。
在现代Java应用中,对集合数据进行高效的统计、分组和聚合是常见的需求。Java Stream API提供了强大而灵活的工具来处理这类任务。本教程将深入探讨如何使用Collectors.groupingBy结合Collectors.reducing来实现复杂的数据聚合,例如按月份分组,并同时计算某个值的总和以及组内元素的数量。
1. 场景与需求分析
假设我们有一个Person对象的列表,每个Person对象包含ID、事件类型、事件日期和关联值。我们的目标是:
- 按事件发生的月份进行分组。
- 统计每个月所有Person对象的value总和。
- 统计每个月发生的Person事件总数。
原始数据示例如下:
ID,Info,Date,Value per1, STATUS1, 10-01-2022, 1 per2, STATUS2, 10-01-2022, 2 per3, STATUS3, 10-01-2022, 3 per1, STATUS4, 10-01-2022, 1 per1, STATUS1, 10-02-2022, 1 per2, STATUS1, 10-03-2022, 1 per3, STATUS2, 10-03-2022, 2
我们期望的输出结果格式为:
立即学习“Java免费学习笔记(深入)”;
Month | Total Sum | Person Count (事件数) 1 7 4 2 1 1 3 3 2
请注意,这里Person Count指的是每个月发生的Person事件记录数,而不是去重后的Person ID数量。
2. 数据模型定义
首先,我们需要定义Person类和相关的枚举类型。为了简化示例,我们将Person的value字段定义为int类型,并使用Java 14引入的record特性来创建简洁的数据类。
import java.time.LocalDate;
// 事件类型枚举
enum Statement {
STATUS1, STATUS2, STATUS3, STATUS4 // 扩展以包含示例数据中的所有状态
}
// Person记录类,包含ID、事件类型、事件日期和值
record Person(String id,
Statement event,
LocalDate eventDate,
int value) {}
// 最终结果的DTO
record DTO(int month, int totalSum, int totalPersons) {} // totalPersons在此表示事件计数3. 构建自定义聚合度量类 PersonGroupMetric
为了在一次Stream操作中同时计算总和和计数,我们创建一个PersonGroupMetric记录类来封装这些聚合结果。这个类将作为Collectors.reducing操作的中间累加器。
record PersonGroupMetric(int count, int sum) {
// 定义一个空的度量实例,作为reducing操作的初始值
public static final PersonGroupMetric EMPTY = new PersonGroupMetric(0, 0);
// 构造函数:将单个Person对象映射为初始的PersonGroupMetric
// 每个Person对象对应一个事件,所以count为1,sum为该Person的值
public PersonGroupMetric(Person p) {
this(1, p.value());
}
// 合并方法:将两个PersonGroupMetric实例合并
// 累加它们的count和sum
public PersonGroupMetric add(PersonGroupMetric other) {
return new PersonGroupMetric(
this.count + other.count,
this.sum + other.sum
);
}
}PersonGroupMetric的设计是关键:
- EMPTY常量提供了reducing操作的起始点。
- 构造函数PersonGroupMetric(Person p)将每个Person对象转换为一个包含其自身计数(1)和值(p.value())的度量实例。
- add方法定义了如何将两个PersonGroupMetric实例合并,即简单地将它们的count和sum相加。
4. 使用 Stream API 进行聚合
有了数据模型和度量类,我们现在可以使用Collectors.groupingBy和Collectors.reducing来执行聚合操作。
首先,准备示例数据:
import java.util.List;
import java.util.Map;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.reducing;
import static java.util.stream.Collectors.toList;
public class StreamAggregationTutorial {
public static void main(String[] args) {
var src = List.of(
new Person("per1", Statement.STATUS1, LocalDate.of(2022, 01, 10), 1),
new Person("per2", Statement.STATUS2, LocalDate.of(2022, 01, 10), 2),
new Person("per3", Statement.STATUS3, LocalDate.of(2022, 01, 10), 3),
new Person("per1", Statement.STATUS4, LocalDate.of(2022, 01, 10), 1), // 月份1的第二个per1事件
new Person("per1", Statement.STATUS1, LocalDate.of(2022, 02, 10), 1),
new Person("per2", Statement.STATUS1, LocalDate.of(2022, 03, 10), 1),
new Person("per3", Statement.STATUS2, LocalDate.of(2022, 03, 10), 2)
);
// 核心聚合逻辑
Map res = src.stream()
.collect(groupingBy(
p -> p.eventDate().getMonthValue(), // 按月份分组
reducing(
PersonGroupMetric.EMPTY, // 初始值
PersonGroupMetric::new, // 映射器:将Person转换为PersonGroupMetric
PersonGroupMetric::add // 合并器:合并两个PersonGroupMetric
)
));
// 将聚合结果映射到最终的DTO
var fin = res.entrySet().stream()
.map(entry -> new DTO(
entry.getKey(), // 月份
entry.getValue().sum(), // 总和
entry.getValue().count() // 事件总数
))
.sorted((d1, d2) -> Integer.compare(d1.month(), d2.month())) // 按月份排序
.collect(toList());
// 打印结果
fin.forEach(System.out::println);
}
} 代码解析:
src.stream(): 创建Person对象的Stream。
-
collect(groupingBy(...)): 这是主要的收集器,用于将Stream中的元素分组。
- p -> p.eventDate().getMonthValue(): 这是分组键的函数,它提取Person对象的月份作为分组依据。
- reducing(...): 这是groupingBy的下游收集器,负责在每个组内执行聚合。
- PersonGroupMetric.EMPTY: reducing操作的初始累加器值。
- PersonGroupMetric::new: 映射函数,将Stream中的每个Person对象转换(映射)为一个PersonGroupMetric实例。
- PersonGroupMetric::add: 合并函数,用于将两个PersonGroupMetric实例合并成一个。这正是count和sum进行累加的地方。
res.entrySet().stream().map(...): 聚合完成后,res是一个Map
。我们遍历其entrySet,将每个Map.Entry转换为最终的DTO对象。 sorted(...): 对结果按月份进行排序。
collect(toList()): 将最终的DTO对象收集到一个列表中。
运行上述代码,将得到如下输出:
DTO[month=1, totalSum=7, totalPersons=4] DTO[month=2, totalSum=1, totalPersons=1] DTO[month=3, totalSum=3, totalPersons=2]
这与我们期望的Total Sum和Person Count(事件数)完全一致。
5. 注意事项与总结
-
事件计数 vs. 去重实体计数: 本教程中的PersonGroupMetric的count字段统计的是每个月发生的Person事件(即Person对象在列表中出现的次数)。如果您的需求是统计每个月去重后的Person ID数量(例如,per1在1月份出现了两次,但只算作1个不同的Person),则需要采用不同的聚合策略。例如,可以先按月份分组,然后对每个组内的Person ID进行Collectors.mapping和Collectors.toSet()操作,最后获取Set的大小。
// 示例:如果需要统计去重后的Person ID数量 Map
distinctPersonCounts = src.stream() .collect(groupingBy( p -> p.eventDate().getMonthValue(), mapping(Person::id, Collectors.collectingAndThen(Collectors.toSet(), Set::size).longValue()) // 错误的写法,需要Collectors.mapping )); // 正确的去重Person ID计数 Map distinctPersonCountsCorrect = src.stream() .collect(groupingBy( p -> p.eventDate().getMonthValue(), Collectors.mapping(Person::id, Collectors.collectingAndThen(Collectors.toSet(), Set::size).longValue()) // 错误的写法,需要Collectors.mapping )); // 正确的去重Person ID计数 Map distinctPersonCountsFinal = src.stream() .collect(groupingBy( Person::getMonthValue, // 假设Person有一个getMonthValue方法 Collectors.mapping(Person::id, Collectors.collectingAndThen(Collectors.toSet(), Set::size)) )); // 实际实现 Map > distinctIdsByMonth = src.stream() .collect(groupingBy( p -> p.eventDate().getMonthValue(), Collectors.mapping(Person::id, Collectors.toSet()) )); // 然后可以遍历 distinctIdsByMonth 来获取每个月的 distinct ID 数量 distinctIdsByMonth.forEach((month, ids) -> System.out.println("Month " + month + ": Distinct Persons = " + ids.size()) ); 对于同时进行去重计数和求和,则需要一个更复杂的自定义Collector或多次Stream操作。
Collectors.reducing的强大之处:reducing收集器是处理复杂聚合任务的强大工具,它允许您定义一个初始值、一个将元素映射到累加器的方法,以及一个将两个累加器合并的方法。这使得它非常适合于需要同时计算多个指标的场景。
代码可读性与维护性: 通过将聚合逻辑封装在PersonGroupMetric这样的自定义度量类中,我们提高了代码的可读性和可维护性。聚合的细节被抽象出来,使得Stream管道本身更加清晰,专注于“如何分组”和“如何应用聚合”。
本教程展示了如何利用Java Stream API的groupingBy和reducing组合,优雅地解决多指标聚合问题。理解并熟练运用这些高级收集器,将极大地提升您处理数据集合的效率和代码质量。










