这是我参与新手入门的第3篇文章。
在使用数据库的时候,我们经常对数据分组求和等操作。在 Java 中有 list 这样的数据结构,非常类似数据表记录中的一行。因此,也有很多场景会对 list 分组聚合。
准备基础数据
首先定义一个 Book
对象,这里有几个简单的属性。为了简化代码,用到了 lombok 注解 @Data
,他会生成 setters/getters 等方法,还有一个全参数构造器 @AllArgsConstructor
。
package com.mapull.demo.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class Book {
/**
* 序号
*/
private Integer id;
/**
* 作者
*/
private String author;
/**
* 书名
*/
private String name;
/**
* 价格
*/
private double price;
}
复制代码
这里主要是为了将 list 转换为 map,因此先初始化一个 list 。
static List<Book> list = Arrays.asList(
new Book(427, "十年踪迹", "写给普通人看的网页开发课", 9.9),
new Book(110, "阿面", "Uniapp 从入门到进阶", 29.9),
new Book(188, "十年踪迹", "前端工程师进阶 10 日谈", 29.9),
new Book(99, "程序员十三", "Vue 商城项目开发实战", 49)
);
复制代码
原生 Java 转换代码
使用通俗的方式将 list 转换为 map,并将结果使用 fastjson 打印到控制台。
Map<Integer, Book> map = new HashMap<>(3);
for(Book book: list){
map.put(book.getId(), book);
}
System.out.println(JSON.toJSONString(map));
复制代码
打印的结果:
{
427: {
"author": "十年踪迹",
"id": 427,
"name": "写给普通人看的网页开发课",
"price": 9.9
},
99: {
"author": "程序员十三",
"id": 99,
"name": "Vue 商城项目开发实战",
"price": 49.0
},
188: {
"author": "十年踪迹",
"id": 188,
"name": "前端工程师进阶 10 日谈",
"price": 29.9
},
110: {
"author": "阿面",
"id": 110,
"name": "Uniapp 从入门到进阶",
"price": 29.9
}
}
复制代码
Java 8 链式编程
在 Java 8 的 Collectors 中有 toMap 方法,方法签名:
public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,Function<? super T, ? extends U> valueMapper) {
return toMap(keyMapper, valueMapper, throwingMerger(), HashMap::new);
}
复制代码
看起来非常复杂,入参是两个 Function,分别代表 map 的 key 和 value 的生成策略。
Java 8 的 stream 流改写上面的代码
Map<Integer, Book> collect = list.stream().collect(Collectors.toMap(Book::getId, book -> book));
System.out.println(JSON.toJSONString(collect));
复制代码
改写后,有效代码只有一行。
实际上,由于上面的场景太常见,大多数场景都是取对象中的某一个值作为 key ,整个对象作为 value 。java 8 还提供了一个函数指代自身。
于是再次改写上面的代码:
Map<Integer, Book> collect = list.stream().collect(Collectors.toMap(Book::getId, Function.identity()));
System.out.println(JSON.toJSONString(collect));
复制代码
这里的 Function.identity()
和 book -> book
含义相同。
控制台打印的结果,也是和上面相同的。
{
427: {
"author": "十年踪迹",
"id": 427,
"name": "写给普通人看的网页开发课",
"price": 9.9
},
99: {
"author": "程序员十三",
"id": 99,
"name": "Vue 商城项目开发实战",
"price": 49.0
},
188: {
"author": "十年踪迹",
"id": 188,
"name": "前端工程师进阶 10 日谈",
"price": 29.9
},
110: {
"author": "阿面",
"id": 110,
"name": "Uniapp 从入门到进阶",
"price": 29.9
}
}
复制代码
Duplicate key xx 异常
上面是通过 id 来对 book 分组,在控制台能看到正确的结果。
下面我们通过作者 author 字段进行分组,可以很容易地写出如下代码:
Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity()));
System.out.println(JSON.toJSONString(collect));
复制代码
仅仅将 getId 换成了 getAuthor,运行后发现并没有如期打印正确结果,而是报错了:
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Book(id=427, author=十年踪迹, name=写给普通人看的网页开发课, price=9.9)
at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)
复制代码
通过查阅java 源代码发现:
toMap 的第三个参数调用了throwingMerger()
方法,这个方法在干什么呢?
通过方法的注释,可以看出,这个方法的作用是在遇到 map 的 key 冲突时,如何解决冲突。
该方法的默认实现如下:
private static <T> BinaryOperator<T> throwingMerger() {
return (u,v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u)); };
复制代码
默认情况下,java 不知道该如何处理这种数据,于是直接抛出异常 “Duplicate key “。
java 提供了下面的方法,便于我们在出现 key 冲突时,自定义处理逻辑。
public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,Function<? super T, ? extends U> valueMapper,BinaryOperator<U> mergeFunction) {
return toMap(keyMapper, valueMapper, mergeFunction, HashMap::new);
}
复制代码
如果在遇到 key 冲突时,将旧值丢弃,存入新值,就可以这样写:
Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), (oldValue, newValue) -> newValue));
System.out.println(JSON.toJSONString(collect));
复制代码
(oldValue, newValue) -> newValue
相当于重写了 mergeFunction
。
控制台打印的结果:
{
"阿面": {
"author": "阿面",
"id": 110,
"name": "Uniapp 从入门到进阶",
"price": 29.9
},
"程序员十三": {
"author": "程序员十三",
"id": 99,
"name": "Vue 商城项目开发实战",
"price": 49
},
"十年踪迹": {
"author": "十年踪迹",
"id": 188,
"name": "前端工程师进阶 10 日谈",
"price": 29.9
}
}
复制代码
效果能达到,但是经过测试发现,当 list 中数据顺便变化时,得到的结果不一致。
原因是,在我们重写 mergeFunction
时,新值和旧值不确定,第一次取到的值是旧值,之后冲突时处理的值是新值。
那有啥办法可以控制每次返回的结果都可控呢,我们可以定一个规则:id 大的值留下,id 小的值舍弃。于是,可以
Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), (oldValue, newValue) -> newValue.getId() > oldValue.getId() ? newValue : oldValue));
System.out.println(JSON.toJSONString(collect));
复制代码
试验发现,无论怎么改动 list 中数据顺序,输出的结果都是相同的。
当然效果是达到了,但是代码着实有点丑,长长的三元表达式 (oldValue, newValue) -> newValue.getId() > oldValue.getId() ? newValue : oldValue
很不利于程序维护。
为此,可以将代码做如下改动:
Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), BinaryOperator.maxBy(Comparator.comparingInt(Book::getId))));
System.out.println(JSON.toJSONString(collect));
复制代码
这里用到了BinaryOperator.maxBy
和Comparator.comparingInt
使得程序可读性高了很多。
在项目实际使用时,非常建议重写这个 merge 方法,因为很难从数据角度控制 key 不重复,已定义 merge 方法可以增加程序健壮性,避免非必要的程序异常。
数据分组
有时候,我们希望对 list 中数据简单分组,用 toMap 可以方便地实现。
每位作者的书名
Map<String, String> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getName, (oldValue, newValue) -> oldValue + ";" + newValue));
System.out.println(JSON.toJSONString(collect));
复制代码
得到的结果:
{
"阿面": "Uniapp 从入门到进阶",
"程序员十三": "Vue 商城项目开发实战",
"十年踪迹": "写给普通人看的网页开发课;前端工程师进阶 10 日谈"
}
复制代码
因为有的作者有多本书,书名中间用 分号
隔开。
每本书的价格
Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getName, Book::getPrice));
System.out.println(JSON.toJSONString(collect));
复制代码
得到的结果:
{
"前端工程师进阶 10 日谈": 29.9,
"Vue 商城项目开发实战": 49,
"写给普通人看的网页开发课": 9.9,
"Uniapp 从入门到进阶": 29.9
}
复制代码
上面已经可以清晰地显示每本书名以及对应的价格。
每位作者著作的价格
Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, (oldValue, newValue) -> oldValue + newValue));
System.out.println(JSON.toJSONString(collect));
复制代码
得到的结果:
{
"阿面": 29.9,
"程序员十三": 49,
"十年踪迹": 39.8
}
复制代码
上面的代码,我们使用了加法运算符对两个数求和。很多时候,这样的代码是表达不清晰的。因为两个字符串也可以用 +
来拼接。
我们可以用下面的代码来让代码含义更清晰:
Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, Double::sum));
System.out.println(JSON.toJSONString(collect));
复制代码
用 Double::sum
更便于理解这块代码逻辑。
每位作者著作中定价最高的那本书
获取每组数据中最大的一条,相当于分组,排序,取最大值。在 MySQL 中,写出这个 sql 都得好大一会儿工夫。
Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, (oldValue, newValue) -> newValue > oldValue ? newValue : oldValue));
System.out.println(JSON.toJSONString(collect));
复制代码
得到的结果:
{
"阿面": 29.9,
"程序员十三": 49,
"十年踪迹": 29.9
}
复制代码
在 java 中,只用了一行代码,每位作者的书籍最高定价一目了然。
当然,还是那个问题,三元表达式,写的时候一时爽,之后维护排错的时候就难了,于是可以如下改造:
Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, BinaryOperator.maxBy(Comparator.comparingDouble(p -> (p)))));
System.out.println(JSON.toJSONString(collect));
复制代码
上面的例子仅仅展示了使用 toMap 如何实现业务需求,实际上, Java 8 提供了很多 API 来简化代码,提高开发效率。
扩展
日常使用中,对 list 分组,我们常常也会用到 groupingBy
来做。
例如,通过作者名对数据分组:
Map<String, List<Book>> collect = list.stream().collect(Collectors.groupingBy(Book::getAuthor));
System.out.println(JSON.toJSONString(collect));
复制代码
得到的数据:
{
"阿面": [
{
"author": "阿面",
"id": 110,
"name": "Uniapp 从入门到进阶",
"price": 29.9
}
],
"程序员十三": [
{
"author": "程序员十三",
"id": 99,
"name": "Vue 商城项目开发实战",
"price": 49
}
],
"十年踪迹": [
{
"author": "十年踪迹",
"id": 427,
"name": "写给普通人看的网页开发课",
"price": 9.9
},
{
"author": "十年踪迹",
"id": 188,
"name": "前端工程师进阶 10 日谈",
"price": 29.9
}
]
}
复制代码
分组返回的一个 key 对应一组数据。
总结
回顾一下,完整的 toMap 参数含义:
- keyMapper:Key 的映射函数,用于获取 map
- valueMapper:Value 的映射函数
- mergeFunction:当 Key 冲突时,调用的合并方法
- mapSupplier:Map 构造器,在需要返回特定的 Map 时使用
有些需求,如果用 SQL 写,我们可以很快给出答案。但是如果原始数据 list 已经提供,用程序来控制有时候也很容易。如果能多从不同的数据结构如 list 与 map 转换来看待问题,说不定只需要一行代码就可以解决棘手的问题。