当我们从列表中对元素进行分组时,我们可以随后聚合分组元素的字段以执行有意义的操作,以帮助我们分析数据。一些示例包括加法、平均值或最大值/最小值。使用 Java Streams 和 Collectors 可以很容易地完成单个字段的这些聚合。该文档提供了如何执行这些类型的计算的简单示例。
但是,还有更复杂的聚合,例如加权平均值、几何平均值。此外,可能需要同时对多个字段进行聚合。在本文中,我们将展示使用 Java Streams 解决此类问题的直接途径。使用此框架使我们能够快速有效地处理大量数据。
我们假设读者对 Java Streams 和实用程序 Collectors 类有基本的了解。
问题布局
让我们考虑一个简单的例子来展示我们想要解决的问题类型。我们将它变得非常通用,以便我们可以很容易地概括它。让我们考虑由以下代码定义的实体列表:TaxEntry
public class TaxEntry {
private String state;
private String city;
private int numEntries;
private double price;
//Constructors, getters, hashCode, equals etc
}
计算给定城市的条目总数非常简单:
Map<String, Integer> totalNumEntriesByCity =
taxes.stream().collect(Collectors.groupingBy(TaxEntry::getCity,
Collectors.summingInt(TaxEntry::getNumEntries)));
Collectors.groupingBy采用两个参数:一个用于分组的分类器函数,以及一个用于对属于给定组的所有元素进行下游聚合的 Collector。我们用作分类器函数。对于下游,我们使用 which 返回 a 来求和我们为每个分组元素获得的税收条目数。TaxEntry::getCityCollectors::summingIntCollector
如果我们试图找到复合分组,事情会稍微复杂一些。例如,在前面的示例中,给定州和城市的条目总数。有几种方法可以做到这一点,但首先要定义一个非常简单的方法:
record StateCityGroup(String state, String city) {}
请注意,我们使用的是 Java ,这是定义不可变类的简洁方法。此外,Java 编译器为我们生成字段访问器方法、等于和实现。有了这个,现在的解决方案很简单:record hashCode toString
Map<StateCityGroup, Integer> totalNumEntriesForStateCity =
taxes.stream().collect(groupingBy(p -> new StateCityGroup(p.getState(), p.getCity()),
Collectors.summingInt(TaxEntrySimple::getNumEntries))
);
例如,我们使用 lambda 表达式设置分类器函数,该表达式创建一个封装每个州-城市的新记录。下游收集器与之前相同。Collectors::groupingBy StateCityGroup
注意:为了简洁起见,在代码示例中,我们将假设 Collectors 类的所有方法都是静态导入,因此我们不必显示它们的类限定。
事情开始变得更加复杂的地方是,如果我们想同时做几个聚合。例如,查找给定州和城市的条目数和平均价格之和。该库没有提供此问题的简单解决方案。
为了开始解开这个问题,我们从前面的聚合中获取提示,并定义一个封装所有需要聚合的字段的记录:
record TaxEntryAggregation (int totalNumEntries, double averagePrice ) {}
现在,我们如何同时对这两个字段进行聚合?始终有可能执行两次流收集以单独查找每个聚合,如以下代码中所示:
Map<StateCityGroup, TaxEntryAggregation> aggregationByStateCity = taxes.stream().collect(
groupingBy(p -> new StateCityGroup(p.getState(), p.getCity()),
collectingAndThen(Collectors.toList(),
list -> {int entries = list.stream().collect(
summingInt(TaxEntrySimple::getNumEntries));
double priceAverage = list.stream().collect(
averagingDouble(TaxEntrySimple::getPrice));
return new TaxEntryAggregation(entries, priceAverage);})));
分组和以前一样完成,但对于下游,我们使用(第 3 行)进行聚合。此函数采用两个参数:Collectors::collectingAndThen
我们转换为列表的初始分组的下载流(在第 3 行中使用)Collectors::toList()
Finisher 函数(第 4-9 行),我们使用 lambda 表达式从上一个列表中创建两个不同的流来执行聚合并将它们组合在新记录中返回TaxEntryAggregation
想象一下,我们想同时做更多的字段聚合。我们需要相应地增加下游列表中的流数量。代码变得效率低下、重复性强且不尽如人意。我们应该寻找更好的选择。
此外,问题并没有就此结束,一般来说,我们受到 Collectors 帮助程序类可以执行的聚合类型的限制。他们的方法(suming*、averaging* 和 summarizing*)仅支持整数、长型和双精度本机类型。如果我们有更复杂的类型,如 或 ,我们该怎么办?BigIntegerBigDecimal
雪上加霜的是,汇总*方法仅提供以下方面的汇总统计数据:最小值、最大值、计数、总和和平均值。如果我们想执行更复杂的计算,例如加权平均值或几何平均值,该怎么办?
有些人会争辩说,我们总是可以编写自定义收集器,但这需要了解收集器接口并很好地理解流收集器流。使用 Collectors 类中的实用工具方法提供的内置收集器更直接。在下一节中,我们将展示一些关于如何实现此目的的策略。
复杂多重聚合:解析路径
让我们考虑一个简单的例子,它将突出我们在上一节中提到的挑战。假设我们有以下实体:
public class TaxEntry {
private String state;
private String city;
private BigDecimal rate;
private BigDecimal price;
record StateCityGroup(String state, String city) {
}
//Constructors, getters, hashCode/equals etc
}
我们首先询问对于每个不同的州-城市对,我们如何找到条目的总数和 和 (∑(rate * price)) 的乘积总和。请注意,我们正在使用 .rate price BigDecimal
正如我们在上一节中所做的那样,我们定义了一个封装聚合的类:
record RatePriceAggregation(int count, BigDecimal ratePrice) {}
乍一看似乎令人惊讶,但对于后跟简单聚合的分组,一个直接的解决方案是使用 .让我们看看我们该怎么做:Collectors::toMap
Map<StateCityGroup, RatePriceAggregation> mapAggregation = taxes.stream().collect(
toMap(p -> new StateCityGroup(p.getState(), p.getCity()),
p -> new RatePriceAggregation(1, p.getRate().multiply(p.getPrice())),
(u1,u2) -> new RatePriceAggregation( u1.count() + u2.count(), u1.ratePrice().add(u2.ratePrice()))
));
(第 2 行)接受三个参数,我们进行以下实现:Collectors::toMap
第一个参数是用于生成映射键的 lambda 表达式。此函数创建为地图的键。这将按州和城市对元素进行分组(第 2 行)。StateCityGroup
第二个参数生成映射的值。在我们的例子中,我们创建一个计数为 1 的初始化,以及 rate 和 price 的乘积(第 3 行)。RatePriceAggregation
最后,最后一个参数是合并多个元素映射到同一个州-城市键的情况。我们将计数和价格相加以进行聚合(第 4 行)。BinaryOperator
让我们演示一下这将如何设置一些示例数据:
List<TaxEntry> taxes = Arrays.asList(
new TaxEntry("New York", "NYC", BigDecimal.valueOf(0.2), BigDecimal.valueOf(20.0)),
new TaxEntry("New York", "NYC", BigDecimal.valueOf(0.4), BigDecimal.valueOf(10.0)),
new TaxEntry("New York", "NYC", BigDecimal.valueOf(0.6), BigDecimal.valueOf(10.0)),
new TaxEntry("Florida", "Orlando", BigDecimal.valueOf(0.3), BigDecimal.valueOf(13.0)));
若要从前面的代码示例中获取 New York 的结果,这一点很简单:
System.out.println("New York: " + mapAggregation.get(new StateCityGroup("New York", "NYC")));
这打印:
New York: RatePriceAggregation[count=3, ratePrice=14.00]
这是一个简单的实现,它确定了多个字段和非原始数据类型的分组和聚合(在我们的例子中)。但是,它的缺点是它没有任何允许您执行额外操作的终结器。例如,您不能进行任何类型的平均值。BigDecimal
为了说明这个问题,让我们考虑一个更复杂的问题。假设我们想要找到费率-价格的加权平均值,以及每个州和城市对的所有价格的总和。特别是,要找到加权平均值,我们需要计算属于每个州-城市对的所有条目的费率和价格的乘积之和,然后除以每个案例的条目总数 n:1/n ∑(rate * price)。
为了解决这个问题,我们开始定义一个包含聚合的记录:
record TaxEntryAggregation(int count, BigDecimal weightedAveragePrice, BigDecimal totalPrice) {}
有了这个,我们可以做以下实现:
Map<StateCityGroup, TaxEntryAggregation> groupByAggregation = taxes.stream().collect(
groupingBy(p -> new StateCityGroup(p.getState(), p.getCity()),
mapping(p -> new TaxEntryAggregation(1, p.getRate().multiply(p.getPrice()), p.getPrice()),
collectingAndThen(reducing(new TaxEntryAggregation(0, BigDecimal.ZERO, BigDecimal.ZERO),
(u1,u2) -> new TaxEntryAggregation(u1.count() + u2.count(),
u1.weightedAveragePrice().add(u2.weightedAveragePrice()),
u1.totalPrice().add(u2.totalPrice()))
),
u -> new TaxEntryAggregation(u.count(),
u.weightedAveragePrice().divide(BigDecimal.valueOf(u.count()),
2, RoundingMode.HALF_DOWN),
u.totalPrice())
)
)
));
我们可以看到代码稍微复杂一些,但允许我们获得我们正在寻找的解决方案。我们将更详细地关注它:
Collectors::groupingBy(第 2 行):
对于分类函数,我们创建一条记录StateCityGroup
对于下游,我们调用(第 3 行):Collectors::mapping
对于第一个参数,我们应用于输入元素的映射器将分组的州-城市税收记录转换为新条目,这些条目将初始计数分配给 1,将费率乘以价格,然后设置价格(第 3 行)。TaxEntryAggregation
对于下游,我们调用(第 4 行),正如我们将看到的,这将允许我们将精加工转换应用于下游收集器。Collectors::collectingAndThen
调用(第 4 行)Collectors::reducing
创建一个默认值以涵盖没有下游元素的情况(第 4 行)。TaxEntryAggregation
Lambda 表达式进行约简并返回具有字段聚合的新表达式(第 5、6、7 行)TaxEntryAggregation
执行精加工转换,使用在上一次缩减中计算的计数计算平均值,并返回最终值(第 9、10、11 行)。TaxEntryAggregation
我们看到,这种实现不仅允许我们同时进行多个字段聚合,而且还可以在多个阶段执行复杂的计算。
这可以很容易地推广以解决更复杂的问题。路径很简单:定义一条记录,封装所有需要聚合的字段,用于初始化记录,然后应用以执行缩减和最终聚合。Collectors::mappingCollectors::collectingAndThen
和以前一样,我们可以得到纽约的聚合:
System.out.println("Finished aggregation: " + groupByAggregation.get(new StateCityGroup("New York", "NYC")));
我们得到的结果:
Finished aggregation: TaxEntryAggregation[count=3, weightedAveragePrice=4.67, totalPrice=40.0]
还值得指出的是,因为 Java 是不可变的,因此可以使用流收集器库提供的支持并行化计算。TaxEntryAggregation record
结论
我们已经展示了几种策略来执行复杂的多字段分组,其中包含包含具有多字段和跨字段计算的非原始数据类型的聚合。这是针对使用 Java 流和收集器 API 的记录列表,因此它为我们提供了快速高效地处理大量数据的能力。