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

前言万语 :fire:

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

List<Map> 与类型安全 :thinking:

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");

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

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

这样问题就解决了。

时光飞逝 :innocent:

时光飞逝,项目新增功能越来越多,你也创建了更多返回特定字段的 DTO 对象。你可能觉得这很正常——项目越大类文件不就越多吗?

很不幸,项目越大类文件越多的定义不包括 DTO 类型的文件。我们有一种专业术语来形容项目中存在过量的 DTO 文件,叫做 DTO 爆炸

class UserDto1 {
}
class UserDto2 {
}
class UserDto3 {
}
class UserDto4 {
}
class UserDto5 {
}

说到这里你可能会问:使用一个 DTO 对象来全量返回所有字段可行吗?这显然不可行,因为以下几个原因:

  • 敏感字段不可以让别的模块或用户获取。
  • 不同权限的调用返回不同的字段。
  • 大型系统冗余返回造成的性能开销问题(尤其针对某些 blob 或者 text 字段)

DTO 爆炸 :collision:

我们上面说过,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 字段报错了。因为从数据库中查询出的字段类型可能有多种,但泛型表示的类型是固定的。那有没有一种数据类型,可以表示多种不同的类型呢?有的,这就是元组。

元组 :bullseye:

元组是一种数据结构,他看起来就像是数组,但他的长度和元素的类型是固定的。

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 中的元组支持 :dizzy:

由于 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 爆炸所带来的隐患。

还有更多吗:red_question_mark:

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

  1. 有没有可运行的代码示例?
  2. 元组看起来不错,但相比 Map 看起来元组失去了通过 key 来访问 value 的特性。
  3. 还有没有更多的场景能够利用元组来进一步降低 DTO 爆炸的风险?
  4. 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 生态推广的开发者。
  • 您的回帖、点赞、收藏、就是我持续更新的动力。
  • 关注我的账号,第一时间收到文章推送。
37 Likes

在使用Record的时候必须要知道value1234…字段的具体含义,感觉可维护性会降低

可以不用 1234 后缀啊,也有 Record 这个类型。1234 是一种可选项,给对类型安全要求很高的开发人员使用的。

貌似我在上上家还是上家公司用了tuple来着

1 Like

又来学习新知识了,先占坑

1 Like

大佬好强! :tieba_087:

1 Like

你们也用的是 JOOQ 吗?
Java 没有原生的 Tuple 类型,一般都用的第三方库。

不是,当时应该是手写了个类。不是通用的,借了个形式

1 Like

如果返回字段过多,是不是导致这个元组泛型定义过长。而且如果多个字段是同一个类型的,比如都是String,这样返回的元组后续使用的时候,调用者要明确字段元组中的顺序和查询语句中的顺序,这样是不是变麻烦了?

1 Like

OOP 就老老实实写对象吧, 别弄这些奇巧淫技了

Tuple 是常见的数据类型,很多库像 appache common 之类的都会去实现。在项目中很常用。

而 DTO 爆炸更是软件工程上的经典问题,大多数框架都会给出自己的解决方案,怎么能算是奇巧淫技了呢?只是这些东西在国内的大环境下不为人知而已。

如果只要老老实实写对象,就能解决一切问题那 java8 还引入函数式编程干啥。

主贴中补充了一下这里的内容哈佬友。

1 Like

原来如此,哈哈。这个元祖类型确实好,但是 apache common 这些库实现的很早了,大部分项目又都要用这个库,估计今后 java 自身不会做这个的实现了。

这东西,比较偏僻。
大多数项目基本都到不了DTO爆炸的程度

1 Like

对,大部分项目不需要担心 DTO 爆炸。因为 90% 都是小中型项目。

不过如果能够熟练使用这个功能,自己写代码会很方便。少建一个类其实少挺多工作量。而且建类这个动作和手写代码挺割裂的。

大帅哥晚上好

谢谢佬友资瓷。

欢迎大家收看刚出炉的《你不知道的 JAVA》系列奥。

佬友,主帖中补充了不使用 1234的写法,可以参考哈。

1 Like

在字节码中,java如何保证自身的安全性?