《你不知道的 JAVA》💘 喝 Java 咖啡配元组蛋糕,饿!

642 阅读7分钟

学会这款 全新技术的 Java 脚手架 ,从此面试不再怕!

Trump-mjga-logo_cn.png

slogan_long_2.png

前言万语

俗话说二楼必须修在一楼上面,喝 Java 咖啡必须要配元组蛋糕。今天我们就从数据库查询开始讲起。

image.png

List<Map> 与类型安全 🤔

userDao.queryByLikeWith() 是一个数据库查询接口,我们通过这个接口能获得一个 List<Map> 类型的数据。

大家可以想想,有哪些框架的默认行为,会在 dao 查询中返回一个 List<Map> | <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");

这样的查询方式有下面几个问题:

  1. userListMap 的长度未知。
  2. userMap 的 key 未知。
  3. 强制类型转换的类型安全隐患。

要解决上面的问题,创建一个 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 分钟。除了老板大家都笑了。

image.png

说了这么多,能不能不创建 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: [numberboolean, string];
ourTuple = [5false'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

image.png 下面的代码中,getUserTuple 方法返回了 Record2 这个对象……等等,为什么是 Record2?这样的命名方式很少见!这里的 2 表示的是元组中有两个元素——回顾上面的内容,明确表示集合中元素的数量,是元组的职责范围之一。

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();
}

那既然这样是不是还有 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 对象的大体设计思路了吧?为什么 JOOQ 不直接返回一个 Map 来表示查询结果而是专门设计了 Record..N这样的类型?

因为 JOOQ 希望利用提前设计的 Record 类型,尽可能在框架层面确保开发者在 CRUD 的过程中随时都获得类型安全的保护;尽量减少开发者为了编译时安全这个重要特性去做的手工操作(比如创建一个 DTO);也一定程度上避免了 DTO 爆炸所带来的隐患。

还有更多吗❓

到这里你可能还有一些疑问,比如:

  1. 有没有可运行的代码示例?
  2. 元组看起来不错,但相比 Map 看起来元组失去了通过 key 来访问 value 的特性。
  3. 还有没有更多的场景能够利用元组来进一步降低 DTO 爆炸的风险?
  4. JOOQ 的 Record 还有没有更多的杀手锏功能?

关于代码示例,我做了一个开箱即用的仓库供大家取用 github.com/ccmjga/mjga… 如果你不要忘记给它一个 Star,便可得好运相随。剩下的问题,如果大家的 Star 给力,我就在后面的章节中一一为大家解答。

写在最后

  • 我是 Chuck1sn,一个长期致力于现代 Jvm 生态推广的开发者。
  • 您的回帖、点赞、收藏、就是我持续更新的动力。
  • 关注我的账号,第一时间收到文章推送。