作为一名Java开发,工作中经常会跟集合打交道。此前,碰到这类需求时的第一反应就是for-each遍历走起,循环体内算法框架先写出来,再编写若干被调用的private方法进行具体细节的处理。正好最近迷上了Java 8的函数式编程风格以及无敌的Stream API,所以碰到这类问题时首先想到的总是各种流水线操作。
本篇记录今天遇到的一个复杂的集合处理需求,本来用命令式风格也可以做,并且感觉更直观易懂,但最后还是难以抵御流的诱惑,毕竟流水线是真的香。
问题描述
首先,通过数据库查询操作以及对查询结果的一些后续处理,得到List<Map<String, Object>>类型的集合;其次,基于列表中元素的某几个键的组合对其进行分组或归类;然后,对各分组中的元素基于某个键进行有符号的求和操作;最后,输出处理后的List<Map<String, Object>>类型的集合。此外,由于处理前数据可能按照某些键进行了排序,处理后仍要保持原先的排序规则
文字描述起来比较抽象,我们结合具体数据(这里的数据非工作中遇到的实际数据,纯属虚构,如有雷同纯属巧合)将需求捋一遍。处理前的集合数据如下代码所示:
List<Map<String, Object>> generateData() {
List<Map<String, Object>> data = new ArrayList<>();
Map<String, Object> rec1 = new HashMap() {{
put("Product", "A");
put("Lot", 1);
put("Sign", "Positive");
put("Units", 100);
}};
Map<String, Object> rec2 = new HashMap() {{
put("Product", "A");
put("Lot", 1);
put("Sign", "Positive");
put("Units", 200);
}};
Map<String, Object> rec3 = new HashMap() {{
put("Product", "A");
put("Lot", 1);
put("Sign", "Negative");
put("Units", 100);
}};
Map<String, Object> rec4 = new HashMap() {{
put("Product", "A");
put("Lot", 2);
put("Sign", "Negative");
put("Units", 300);
}};
Map<String, Object> rec5 = new HashMap() {{
put("Product", "A");
put("Lot", 2);
put("Sign", "Negative");
put("Units", 400);
}};
Map<String, Object> rec6 = new HashMap() {{
put("Product", "B");
put("Lot", 1);
put("Sign", "Positive");
put("Units", 400);
}};
Map<String, Object> rec7 = new HashMap() {{
put("Product", "B");
put("Lot", 2);
put("Sign", "Negative");
put("Units", 150);
}};
data.add(rec1);
data.add(rec2);
data.add(rec3);
data.add(rec4);
data.add(rec5);
data.add(rec6);
data.add(rec7);
return data;
}
列表中的元素类型为
Map<String, Object>,各元素包含Product、Lot、Sign和Units四个键。现将Product/Lot视为复合ID,Sign和Units组合起来表示产品的数量(可正可负)。要求是:输出仍为Map<String, Object>类型元素列表,其中包含Product、Lot、Units三个键,并且Product/Lot(视为复合ID)不重复,Units为该复合ID对应的多条记录的带符号总和。例如,本例输出的集合中应包含4个元素,分别为A1(表示Product A + Lot 1)、A2、B1以及B2,对应的Units则分别为200、-700、400以及-150。
此外,原列表是按Product/Lot升序排列的,要求输出列表也按此规则进行排序
命令式风格解决方案
现在,需求应该比较明朗了。按照传统的命令式风格写成代码如下(这里先不考虑排序这一需求):
import org.apache.commons.lang3.StringUtils;
import java.util.*;
public void traditionalSolution() {
List<Map<String, Object>> data = generateData();
Map<String, List<Map<String, Object>>> groupedData = grouping(data);
List<Map<String, Object>> result = aggregating(groupedData);
System.out.println(result);
}
private Map<String, List<Map<String, Object>>> grouping(List<Map<String, Object>> data) {
Map<String, List<Map<String, Object>>> result = new HashMap<>();
for (Map<String, Object> record : data) {
String compositeKey = getCompositeKey(record);
if (!result.containsKey(compositeKey)) {
List<Map<String, Object>> products = new ArrayList<>();
result.put(compositeKey, products);
}
result.get(compositeKey).add(record);
}
return result;
}
private List<Map<String, Object>> aggregating(Map<String, List<Map<String, Object>>> productsByKey) {
List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, List<Map<String, Object>>> entry : productsByKey.entrySet()) {
List<Map<String, Object>> products = entry.getValue();
Map<String, Object> merged = merging(products);
result.add(merged);
}
return result;
}
private Map<String, Object> merging(List<Map<String, Object>> records) {
Integer sum = 0;
String product = "";
Integer lot = 0;
for (Map<String, Object> record : records) {
product = (String) record.get("Product");
lot = (Integer) record.get("Lot");
Integer factor = ("Positive".equals(record.get("Sign")) ? 1 : -1);
Integer units = (Integer)record.getOrDefault("Units", 0) * factor;
sum += units;
}
Map<String, Object> result = new HashMap<>();
result.put("Product", product);
result.put("Lot", lot);
result.put("Units", sum);
return result;
}
private String getCompositeKey(Map<String, Object> rec) {
return StringUtils.join(rec.get("Product"), rec.get("Lot"));
}
输出结果为:
[{Lot=1, Product=A, Units=200}, {Lot=2, Product=B, Units=-150}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
可见,结果跟我们的预期是一致的(排序确实乱了,但这里先暂不考虑这一需求)。这里用60行代码解决了这一问题,代码行数其实也不算太多,但想要一眼看出代码逻辑并不是那么容易。而且,像分组这样的需求,如果采用Stream API中的Collectors.groupingBy来做,可以很轻松地进行处理,并且逻辑足够清晰。
接下来,将部分代码基于Stream API重写,以求更简洁
引入部分流操作
首先,修改grouping方法的实现,其余方法不变(循序渐进)
import static java.util.stream.Collectors.*;
private Map<String, List<Map<String, Object>>> grouping(List<Map<String, Object>> data) {
return data.stream().collect(groupingBy(this::getCompositeKey));
}
哇!我们看到,这回仅仅1行代码便解决了原先11行才解决的问题,而且方法的目的一目了然。此时的输出结果如下:
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
列表中的数据仍与之前保持一致,不过元素的顺序有所变化,这是因为流水线内部采取了不同的实现方式所致。但这里无关紧要,如果有需求的话,我们完全可以进一步对得到的结果进行排序处理,以保证输出完全符合期望。
能否更进一步?
我们当然希望能对aggregating和merging两个方法也作类似重构,达到一行胜千言的效果。但是,从命令式风格的代码中,我们也可以看到,这不是那么容易。aggregating和merging方法各自都包含了循环结构,并且merging方法的调用是在aggregating方法的循环体中,整个逻辑还是有些复杂的。
进一步分析发现,merging方法接受List<Map<String, Object>>类型的参数,输出为Map<String, Object>类型。其行为抽象出来类似Collectors.reducing归约操作,即对列表中的各个元素依次应用某个归约函数,最后折叠为一个元素。
另一方面,整个traditionalSolution方法的操作思路是分组 -> 归约 -> 收集。分组操作前一步已经完成,归约操作由merging方法完成。分组操作后返回的集合类型是Map,而我们的最终目标类型是List,因此需要通过aggregating把收集器返回的结果转换为另一种类型。通常用Collectors.collectingAndThen工厂方法返回的收集器来做到这一点(参见《Java实战》第二版P132)。
进一步整合
进一步整合后的代码如下所示:
import org.apache.commons.lang3.StringUtils;
import java.util.*;
import static java.util.stream.Collectors.*;
public void functionalSolution() {
List<Map<String, Object>> data = generateData();
List<Map<String, Object>> processedData =
data.stream()
.map(this::transform)
.collect(collectingAndThen(
groupingBy(this::getCompositeKey, reducing(this::accumulating)), // 要转换的Collector
map -> map.values().stream().filter(Optional::isPresent).map(Optional::get).collect(toList()) // 转换函数
));
System.out.println(processedData);
}
private Map<String, Object> transform(Map<String, Object> originalData) {
Map<String, Object> newData = new HashMap<>(originalData);
Integer units = (Integer)originalData.getOrDefault("Units", 0);
// get and remove "Sign"
Integer factor = ("Negative".equals(newData.remove("Sign")) ? -1 : 1);
newData.put("Units", factor * units);
return newData;
}
private Map<String, Object> accumulating(Map<String, Object> m1, Map<String, Object> m2) {
Map<String, Object> accumulated = new HashMap<>(m1);
Integer units1 = (Integer)m1.getOrDefault("Units", 0);
Integer units2 = (Integer)m2.getOrDefault("Units", 0);
accumulated.put("Units", units1 + units2);
return accumulated;
}
private String getCompositeKey(Map<String, Object> rec) {
return StringUtils.join(rec.get("Product"), rec.get("Lot"));
}
简要分析如下:整个数据处理过程由groupingBy分组开始,然后通过reducing对每个分组内的数据进行归约,最后通过collectingAndThen对前两步操作复合而成的收集器进行转换。使得输出由Map<String, Optional<Map<String, Object>>>类型变为List<Map<String, Object>>。
值得一提的是,为了便于处理,在进行上述操作之前先进行了map操作,将每个元素对应的"Units"值都转换为有符号整数,并顺便去掉了不需要的字段"Sign"。我做过一个试验,不进行这一步转换操作,后续的reducing操作可能会有符号不正确的问题,原因是Collectors.reducing单参数版本的默认初始值就是第一个元素,倘若第一个元素的"Sign"值是“Negative”并且某个桶中仅有这一个元素,则传给reducing的归约函数将不起作用,从而无法达到对“Units”取反的效果;如果调用reducing的三参数版本函数,则初始值会比较难以给出。由此,便陷入了两难境地。所以,我觉得通过先行map处理绕过了这个问题还是挺好的一步棋。另外,通过filter(Optional::isPresent).map(Optional::get)流水线去掉Optional这个麻烦家伙之后,再进行Collectos.toList()收集。
这样,40行代码(实际上仅有30余行,原因是流水线操作出于代码清晰考虑,一行分作多行书写了)就解决了同样的问题。此时的运行结果如下,显然结果符合预期。
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
解决排序需求
排序需求跟其余需求其实相关性不大,因此本文在实现其余需求后进行独立讨论。排序这一需求可以分解为两个要点:
- 多字段组合排序
- 顺序逆序
需求1很简单,主要利用了Java 8中Comparator新增的comparing和thenComparing方法,以及List新增的sort方法,代码如下:
private Comparator<Map<String, Object>> getComparator() {
return Comparator.comparing((Map<String, Object> map) -> (String)map.get("Product"))
.thenComparing(map -> (Integer)map.get("Lot"));
}
@Test
public void functionalSolution() {
// 省略已有代码
System.out.println(processedData);
processedData.sort(getComparator());
System.out.println(processedData);
}
输出如下,显然输出符合期望
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
[{Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}, {Lot=2, Product=B, Units=-150}]
现在增加需求2,即要求对List按“Product”以及“Lot”值逆序排列。引入Comparator的reversed方法,将代码修改如下:
private Comparator<Map<String, Object>> getComparator() {
return Comparator.comparing((Map<String, Object> map) -> (String)map.get("Product")).reversed()
.thenComparing(map -> (Integer)map.get("Lot")).reversed();
}
此时,输出结果如下
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
[{Lot=2, Product=A, Units=-700}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=B, Units=-150}, {Lot=1, Product=B, Units=400}]
哎呀,观察发现输出的顺序并不符合我们的预期。在函数式风格下,我们的声明式代码意图一目了然:我们的比较器的意图确实是对两个字段都按逆序排列啊,为什么会这样呢?
经过一番摸索,我发现comparing和thenComparing方法还有接受两个参数的重载版本。对代码作如下修改
private Comparator<Map<String, Object>> getComparator() {
return Comparator.comparing((Map<String, Object> map) -> (String)map.get("Product"), Comparator.reverseOrder())
.thenComparing(map -> (Integer)map.get("Lot"), Comparator.reverseOrder());
}
这次的输出顺序符合预期
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=A, Units=200}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=B, Units=400}]
[{Lot=2, Product=B, Units=-150}, {Lot=1, Product=B, Units=400}, {Lot=2, Product=A, Units=-700}, {Lot=1, Product=A, Units=200}]
但我对于前一种方案为何失效仍是百思不得其解。这篇文章提到:
以下两种排序是完全不一样的,一定要区分开来。
Comparator.comparing(ClassX::extractY).reversed();Comparator.comparing(ClassX::extractY, Comparator.reverseOrder());1是得到排序结果后再排序,2是直接进行排序,很多人会混淆导致理解出错;2更好理解,建议使用2
另外,还有这篇文章的例子可以一并参考。
如此,刚开始的逆序排列方案输出不符合预期就可以解释了:第一个reversed对按“Product”顺序排列后的结果进行逆序(从而“Product B”记录都排到了前面),然后第二个reversed又对再按“Lot”顺序排列后的结果进行逆序(于是,“Product B”的记录又被挪到了后面)。
以这种思路来思考第一种方案,将代码作如下变更,结果完全符合预期了。
private Comparator<Map<String, Object>> getComparator() {
return Comparator.comparing((Map<String, Object> map) -> (String)map.get("Product"))
.thenComparing(map -> (Integer)map.get("Lot")).reversed();
}
但这种方案还是太不直观了,脑子里绕半天不说,一旦加入排序的字段增多,整个顺序、逆序关系就更乱了,所以还是第二种方案更简单、直观。
另外,还有一个坑是null,也就是说如果某条记录中某个参与排序的字段值为null,那么会抛出NullPointerException。解决方案是nullsFirst或nullsLast,doc对于nullsFirst的说明如下:
Returns a null-friendly comparator that considers {@code null} to be less than non-null. When both are {@code null}, they are considered equal. If both are non-null, the specified {@code Comparator} is used to determine the order. If the specified comparator is {@code null}, then the returned comparator considers all non-null values to be equal.
例如,在知道“Lot”值可能缺失的情况下,修改比较器代码如下,可以使得排序代码再次成功运行:
private Comparator<Map<String, Object>> getComparator() {
return Comparator.comparing((Map<String, Object> map) -> (String)map.get("Product"), Comparator.reverseOrder())
.thenComparing(map -> (Integer)map.get("Lot"), Comparator.nullsFirst(Comparator.reverseOrder()));
}