使用 Java 8 的分组

使用 Java 8 的分组


2019-12-19

Java 8 发布后一直没有好好看过新加的特性,偶尔会用用 Optional 类,用 lambda 替换一下匿名类,今年才系统的看了下,确实给这个老语言注入了些新活力。(感谢 Scala 给 Java 了一堆改进目标……)

有这样一个需求:

需要根据品类 category 提供10个品类的数据。从库中查出的数据有三列:id, category, score. 要求提供每个品类下边2条数据,同时新的这几组数据不能与上次提供的数据重复,在最终输出时需要根据每组数据的 score 进行排序。最终输出结果只需要一个有序的 id 列表即可(上次保存的结果也是如此)。

思考一下发现,解决这个问题需要三个步骤:分类、排序和去重复。去重复是一个“修剪”的过程,分类和排序则是一个“重新摆放”过程。从效率上来看,较少的元素对我们分类和排序的效率是有帮助的,从需求上来看排序是在分类之后(按照 score 排除),所以我们的顺序应该是:去重复 -> 分类 -> 排序。

为了盛装我们查处的多条数据需要先创建一个 DTO (Data Transfer Object)类:

1
2
3
4
5
6
public class DemoDto {
private String id;
private String category;
private int rank;
// getter setter non-constructor constructor equals hashCode
}

注意也重写了equalhashCode。重写的原因会在下面看到。

数据放在一个 List<DemoDto> list 变量中,上次查询的结果放在 List<String> oldList 中。首先做“去重复”操作。如果将两个列表中的元素比较并且删除,最多会有 m*n (假定 list 长度为 m, oldList 长度为 n)次比较。所以我先将 list 按照 id 分组,装进一个HashMap ,根据 key 值(也就是 id )批量删除重复的元素:

1
2
Map<String, List<DemoDto>> map = list.stream().collect(groupingBy(DemoDto::getId));
oldList.forEach(s -> map.remove(s));

通过 Java 8 的新特性,直接通过收集器进行分组。分组之后,按照 key (也就是 id) 进行删除,这样只要 n 次遍历就可以删掉所有重复的数据。同时在查找 key 的时候,由于 HashMap 是底层是红黑树,查询时效率很高。在去除重复之后,我们需要将剩下的数据重新分组,这次是根据品类进行分组。

1
2
3
4
5
6
7
8
9
10
11
12
Set<LikeBookDto> sets = new HashSet<>();
for (List<LikeBookDto> listBook: map.values()) {
sets.addAll(listBook);
}

sets.stream().collect(groupingBy(LikeBookDto::getCategoryId)).forEach((k, v) -> {
v.sort(Comparator.comparing(LikeBookDto::getRank, Comparator.reverseOrder()));
if (v.size() > 0)
list.add(v.get(0).getBookId());
if (v.size() > 1)
list.add(v.get(1).getBookId());
});

不仅要删除与之前旧数据重复的数据,还要删除数据中本身重复的数据,我选择使用 HashSet 来保存剩下的所有DTO 对象同时删除重复的DTO(这里回答了上面的问题:为什么要重写 equalshashCode?比较和计算hash 时并没有带上 categoryrank ,因为要保证最终的结果中不会出现重复的id)。

然后将set转换为stream,根据category重新分组。分组之后利用comparing根据rank降序排列。之后,如果里面的数据有两位就取前两位。

到这里就完了吗?没有。我们需要根据最终结果排序,将最终结果按照 rank 排序(而不是按照每组category内部的rank),这样就需要修改上面的代码,最后一步输出的时候带上 DemoDTO 的信息,根据里面的 rank 进行排序。整个过程比较简单,我就不给出代码了。

Java 8 的新特性将整个过程变得非常简单,无论是分组还是排序都省去了大量冗余的实现代码。需要注意的是我们并没有一开始就把数据集转换为流做完所有的操作再返回回来,而是中间多了两次 forEach ,因为我们需要对数据集进行一个“修剪”操作,对于 lambda 来说,更擅长处理不可变对象而不是去改变对象。

思考一下,整个过程好像不够那么的 lambda ,现在我们使用 filter 等函数结合谓词来做一个纯粹一点的流处理过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> res = list.stream()
.distinct()
.filter(o -> !oldList.contains(o.getId()))
.collect(Collectors.groupingBy(DTO::getCategory))
.values()
.stream()
.flatMap(x ->
x.stream()
.sorted(Comparator.comparing(DTO::getRank, Comparator.reverseOrder()))
.limit(2)
).sorted(Comparator.comparing(DTO::getRank, Comparator.reverseOrder()))
.map(DTO::getId)
.collect(Collectors.toList());

具体过程是这样的:

  1. 去重复。

  2. 过滤上次已经存在的数据。

  3. 根据品类分组。

  4. 对每组数据进行排序。

  5. 取出排序后的前两位。

  6. 将最终结果排序。

  7. 取出最终结果的 id

  8. 返回一个List ,完成!

整个过程一气呵成,无论是可读性还是处理流程都不拖泥带水,这在引入 Stream 之前是不能想象的。

值得一提的是,Java 8还提供了新的时间处理方法。

在这个工作任务中还有一个小的需求,有一个资源列表从其他服务获取,每次获取之后就放在了缓存当中,在每周六晚上八点之前这些资源都可以重复使用,但是过了周六晚上八点就要拿最新的资源,直到下周六的八点再更新。思考一下,这个问题可以化简为找到当前这个任务的周六八点,可以分为两个部分:

  1. 本周六八点之前的时间,过期时间就是本周六八点。
  2. 本周六八点之后的时间,过期时间就是下周六八点。

Java 8 不仅加入了 LocalDateTime 还加入了 TemporalAdjusterDurationInstant 之类的辅助类,来感受一下:

1
2
3
4
5
6
7
8
LocalDateTime localDateTime = LocalDateTime.now().with(temporal -> {
DayOfWeek dow = DayOfWeek.of(temporal.get(ChronoField.DAY_OF_WEEK));
LocalTime clock20 = LocalTime.of(20,0);
if (dow == DayOfWeek.SUNDAY || (dow == DayOfWeek.SATURDAY
&& Duration.between(clock20, LocalTime.from(temporal)).getSeconds() > 0))
return temporal.plus(dow == DayOfWeek.SUNDAY ? 6 : 7, ChronoUnit.DAYS).with(clock20);
return temporal.with(DayOfWeek.SATURDAY).with(clock20);
});

综上,问题总是可以解决,不过在引入新特性之后写起来更加简单易于处理了。