前言万语 
俗话说二楼必须修在一楼上面,喝 Java 咖啡必须要配元组蛋糕。今天我们就从数据库查询开始讲起。
List<Map>
与类型安全 
userDao.queryByLikeWith()
是一个数据库查询接口,我们通过这个接口能获得一个 List<Map>
类型的数据。
List<Map> userListMap = userDao.queryByLikeWith("someKey");
Long idUser1 = (Long) userListMap.get(0).get("id");
String usernameUser1 = (String) userListMap.get(0).get("username");
Integer ageUser2 = (Integer) userListMap.get(1).get("age");
这样的查询方式有下面几个问题:
- userListMap 的长度未知。
- userMap 的 key 未知。
- 强制类型转换的类型安全隐患。
要解决上面的问题,创建一个 User Class
就可以了。
class User {
private Long id;
private String username;
private Integer age;
}
List<User> userList = userDao.queryUserByLikeWith("someKey");
Integer userAge = userList.get(0).getAge();
Long userId = userList.get(0).getId();
String username = userList.get(1).getUsername();
虽然 user 的数量还是未知的,但至少 key 变成已知了,我们可以放心大胆的访问想要的属性。像这样的方式我们称为为查询结果封装一个 DTO。
DTO 是一种数据封装对象,他没有业务逻辑,只承担数据封装的功能。现在我有一个问题,你认为 DTO 可变不可变?可在评论区讨论。
有一天新的需求来了。新功能依然需要查询 User 领域相关内容,但需要追加返回 address phone 字段并不再允许返回 id 字段(这是敏感字段)。
现在的我们不能再去修改 User 类,因为这会影响既存接口破坏系统稳定性。怎么办呢?好办,再创建一个 User Dto
不就行了。
class UserDetail {
private String username;
private Integer age;
private String address;
private String phone;
}
List<User> userList = userDao.queryUserDetailByLikeWith("someKey");
Integer userAge = userList.get(0).getAge();
String username = userList.get(1).getUsername();
String address = userList.get(0).getAddress();
这样问题就解决了。
时光飞逝 
时光飞逝,项目新增功能越来越多,你也创建了更多返回特定字段的 DTO 对象。你可能觉得这很正常——项目越大类文件不就越多吗?
很不幸,项目越大类文件越多的定义不包括 DTO 类型的文件。我们有一种专业术语来形容项目中存在过量的 DTO 文件,叫做 DTO 爆炸。
class UserDto1 {
}
class UserDto2 {
}
class UserDto3 {
}
class UserDto4 {
}
class UserDto5 {
}
说到这里你可能会问:使用一个 DTO 对象来全量返回所有字段可行吗?这显然不可行,因为以下几个原因:
- 敏感字段不可以让别的模块或用户获取。
- 不同权限的调用返回不同的字段。
- 大型系统冗余返回造成的性能开销问题(尤其针对某些 blob 或者 text 字段)
DTO 爆炸 
我们上面说过,DTO 是一种数据传输对象,它不包含业务功能,只用来承载数据。言下之意,这是一种「低价值对象」,但又偏偏要用一个文件来创建。
如果你的项目中充数着大量的低价值对象,你就会遇到下面这两个经典问题:
- 这个接口应该用哪个 DTO 对象来返回数据?
- 这个接口的 DTO 对象应该如何命名?
编程 10 分钟,命名就花了 8 分钟。除了老板大家都笑了。
说了这么多,能不能不创建 DTO 呢?之前提到的 List<Map>
似乎能承担这个功能,但又有致命缺点:它是类型不安全的。那给 Map 加上泛型行不行呢?
// 我们期待 queryByLikeWith 返回两个字段:username 和 age 的值。
List<Map<String,String>> userListMap = userDao.queryByLikeWith("someKey");
// 通过
String usernameUser1 = userListMap.get(0).get("username");
// 报错
Integer ageUser2 = userListMap.get(1).get("age");
ageUser2
字段报错了。因为从数据库中查询出的字段类型可能有多种,但泛型表示的类型是固定的。那有没有一种数据类型,可以表示多种不同的类型呢?有的,这就是元组。
元组 
元组是一种数据结构,他看起来就像是数组,但他的长度和元素的类型是固定的。
let ourTuple: [number, boolean, string];
ourTuple = [5, false, 'Coding God was here'];
上面的 ourTuple 就是一个元组。他包含且三个元素且元素类型和顺序必须是 number boolean string
。尝试给它赋值其他类型的数据会报错:
// initialized incorrectly which throws an error
ourTuple = [false, 'Coding God was mistaken', 5];
获取这个元组中的元素的方式和数组一样,都是通过索引来获取:
let ourTuple: [number, boolean, string];
ourTuple = [5, false, 'Coding God was here'];
let firstElement: number = ourTuple[0];
let secondElement: boolean = ourTuple[1];
let thirdElement: string = ourTuple[2];
试想,如果把数据库中查询出的字段封装到一个元组上面,是不是就可以节省了创建 DTO 的开销了呢?说不如做,让我们试试看。
JOOQ 中的元组支持 
由于 Java 没有原生的元组类型,所以我们使用 JOOQ 这个库为我们封装的元组对象。这个对象曾经在我们的这篇文章中提到过,它叫做 Record
void createOrderAndNotifyAdmin() {
Record2<String, Long> testUserA = getUserTuple();
String username = testUserA.value1();
Long userId = testUserA.value2();
orderService.createOrderBy(userId);
notifyService.notifyAdminBy(username);
}
private Record2<String, Long> getUserTuple() {
return dsl.select(USER.USERNAME, USER.ID).from(USER).where(USER.USERNAME.eq("uniqueUserName")).fetchOne();
}
上面的代码中,getUserTuple
方法返回了 Record2
这个对象……等等,为什么是 Record2
?这样的命名方式很少见!这里的 2 表示的是元组中有两个元素——回顾上面的内容,明确表示集合中元素的数量,是元组的职责范围之一。
那既然这样是不是还有 Record3
呢?当然有,Record1..22
JOOQ 提供了这样长度的预定义元组对象供你使用,你想用哪个就用哪个。
private Record3<String, Long, String> getUserByUserNameEq(String username) {
return dsl.select(USER.USERNAME, USER.ID, USER.PASSWORD).from(USER).where(USER.USERNAME.eq(username)).fetchOne();
}
由于这次你查询了两个字段,所以使用 Record2
来表示这次的查询结果。再通过 testUserA.value1() testUserA.value2()
就能通过类型安全的方式访问到对应的值,然后传递给需要的业务方法。
附上 Record 类型的结构设计
public interface Record extends Fields, Attachable, Comparable<Record>, Formattable {
@NotNull
Row valuesRow();
}
public interface Record2<T1, T2> extends Record {
@NotNull
Field<T1> field1();
@NotNull
Field<T2> field2();
}
final class RecordImpl2<T1, T2> extends AbstractRecord implements InternalRecord, Record2<T1, T2> {
RecordImpl2(AbstractRow<?> row) {
super(row);
}
@Override
public RowImpl2<T1, T2> fieldsRow() {
return new RowImpl2<T1, T2>(field1(), field2());
}
另外一方面,JOOQ 当然会提供一个 Record
对象来满足你不使用元组的情况。这种情况太寻常了,任何正常的库都会提供这种支持我也就没有介绍。但是为了避免误会,还是写一下代码:
public Result<Record> fetchUniqueUserWithRolePermissionBy(Long userId) {
return ctx()
.select()
.from(USER)
.leftJoin(USER_ROLE_MAP)
.on(USER.ID.eq(USER_ROLE_MAP.USER_ID))
.leftJoin(ROLE)
.on(USER_ROLE_MAP.ROLE_ID.eq(ROLE.ID))
.leftJoin(ROLE_PERMISSION_MAP)
.on(ROLE.ID.eq(ROLE_PERMISSION_MAP.ROLE_ID))
.leftJoin(PERMISSION)
.on(ROLE_PERMISSION_MAP.PERMISSION_ID.eq(PERMISSION.ID))
.where(USER.ID.eq(userId))
.fetch();
}
Result<Record> records = userRepository.fetchUniqueUserWithRolePermissionBy(1L);
assertThat(records.size()).isEqualTo(2);
assertThat(records.get(0).get(USER.USERNAME)).isEqualTo("testUserA");assertThat(records.get(1).get(USER.USERNAME)).isEqualTo("testUserA");
assertThat(records.get(0).get(ROLE.NAME)).isEqualTo("testRoleA");
assertThat(records.get(1).get(ROLE.NAME)).isEqualTo("testRoleA");
讲到这里你应该能明白 JOOQ 中 Record
对象的大体设计思路了吧?为什么 JOOQ 不直接返回一个 Map
来表示查询结果而是专门设计了 Record..N
这样的类型?
大家可以想想,有哪些框架的默认行为会在 dao 查询中返回一个
List<Map> | <Map>
结构呢?
因为 JOOQ 希望利用提前设计的 Record
类型,尽可能在框架层面确保开发者在 CRUD 的过程中随时都获得类型安全的保护;尽量减少开发者为了编译时安全这个重要特性去做的手工操作(比如创建一个 DTO);也一定程度上避免了 DTO 爆炸所带来的隐患。
还有更多吗
到这里你可能还有一些疑问,比如:
- 有没有可运行的代码示例?
- 元组看起来不错,但相比
Map
看起来元组失去了通过 key 来访问 value 的特性。 - 还有没有更多的场景能够利用元组来进一步降低 DTO 爆炸的风险?
- JOOQ 的 Record 还有没有更多的杀手锏功能?
关于代码示例,我做了一个开箱即用的仓库供大家取用 GitHub - ccmjga/mjga-scaffold: 🔥 一个与 IntelliJ IDEA 配套的 Java 脚手架,你的专属生产力工具。 如果你不要忘记给它一个 Star,便可得好运相随。剩下的问题,如果大家的 Star 给力,我就在后面的章节中一一为大家解答。
Java 关于 Tuple 的实现。
Apache Common Lang 这个库对常用的元组类型进行了实现,分别是 Pairs 和 Triples。他们的用法看名字就可以看出。
private static MutablePair<String, String> mutablePair;
@BeforeClass
public static void setUpMutablePairInstance() {
mutablePair = new MutablePair<>("leftElement", "rightElement");
}
@Test
public void whenCalledgetLeft_thenCorrect() {
assertThat(mutablePair.getLeft()).isEqualTo("leftElement");
}
@Test
public void whenCalledgetRight_thenCorrect() {
assertThat(mutablePair.getRight()).isEqualTo("rightElement");
}
@Test
public void whenCalledsetLeft_thenCorrect() {
mutablePair.setLeft("newLeftElement");
assertThat(mutablePair.getLeft()).isEqualTo("newLeftElement");
}
写在最后
- 我是 Chuck1sn,一个长期致力于现代 Jvm 生态推广的开发者。
- 您的回帖、点赞、收藏、就是我持续更新的动力。
- 关注我的账号,第一时间收到文章推送。