作者 | 李增光
杏仁后端工程师。只有变秃,才能变强!
记得之前,我在《effective java》上看过 Builder 构建器相关的内容,但实际开发中不经常用。后来,在项目中使用了 lombok ,发现它有一个注解 @Builder
,就是为 java bean 生成一个构建器。于是,回头重新复习了下相关知识,整理如下。
0.何为 Builder 模式 ?
Builder 模式 又被称作 建造者模式 或者 生成器模式.是一种设计模式。
维基百科上的定义为:
将复杂对象的建造过程抽象出来(抽象类别),使这个抽象过程的不同实现方法可以构造出不同表现(属性)的对象。
一个典型的 Builder 模式的 UML 类图如下:
角色介绍:
- Product 产品类 : 产品的抽象类。
- Builder : 抽象类, 规范产品的组建,一般是由子类实现具体的组件过程。
- ConcreteBuilder : 具体的构建器。
- Director : 统一组装过程(可省略)。
注意与抽象工厂模式的区别: 抽象工厂模式与生成器相似,因为它也可以创建复杂对象.主要的区别是生成器模式着重于一步步构造一个复杂对象.而抽象工厂模式着重于多个系列的产品对象(简单的或是复杂的).生成器在最后的一步返回产品,而对于抽象工厂来说,产品是立即返回的。
1. 为什么使用构建器模式 ?
若一个类具有大量的成员变量,我们就需要提供一个全参的构造器或大量的 set 方法.这让实例的创建和赋值,变得很麻烦,且不直观.我们通过构建器,可以让变量的赋值变成链式调用,而且调用的方法名对应着成员变量的名称.让对象的创建和赋值都变得很简洁、直观。
Builder 模式的使用场景:
- 相同的方法,不同的执行顺序,产生不同的事件结果时。
- 多个部件或零件,都可以装配到一个对象中,但是产生的运行结果又不相同时。
- 产品类非常复杂,或者产品类中的调用顺序不同产生了不同的效能,这个时候使用建造者模式非常合适。
- 当初始化一个对象特别复杂,如参数多,且很多参数都具有默认值时。
2. lombok Builder使用样例
借助于 Lombok 我们可以快速创建 Builder 模式。首先,创建一个名为 User 的 Java Bean,非常简单,只有两个属性,sex,和 name。其中 @Builder
可以自动为 User 对象生成一个构建器,@ToString
可以自动为 User 对象生成 toStrng()
方法。
@Builder
@ToString
public class User {
private Integer sex;
private String name;
}
现来测试一下 Lombok 为我们自动提供的构建器功能:
@Test
public void builderTest() {
User user = User.builder()
.name("杏仁")
.sex(1)
.build();
System.out.println(user.toString());
}
会看到控制台打印:
User(sex=1,name=杏仁)
可以看到,打印结果就是我们想要的样子,但是 Lombok 为我们做了什么事情呢?
使用 Lombok 需要注意理解 Lombok 的注解做了什么,否则很容易出错。
3. 反编译 Lombok 生成的 User.class
*注意:下面请区分两组名词:"builder方法"和“build方法”,“构造器”和“构建器”。
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.xingren.lomboklearn.pojo;
public class User {
private Integer sex;
private String name;
User(final Integer sex,final String name) {
this.sex = sex;
this.name = name;
}
public static User.UserBuilder builder() {
return new User.UserBuilder();
}
public String toString() {
return "User(sex=" + this.sex + ",name=" + this.name + ")";
}
public static class UserBuilder {
private Integer sex;
private String name;
UserBuilder() {
}
public User.UserBuilder sex(final Integer sex) {
this.sex = sex;
return this;
}
public User.UserBuilder name(final String name) {
this.name = name;
return this;
}
public User build() {
return new User(this.sex,this.name);
}
public String toString() {
return "User.UserBuilder(sex=" + this.sex + ",name=" + this.name + ")";
}
}
}
我们通过反编译 User.class,获得上方的源码(最好用 idea 自带的反编译器,jd-gui 反编译的源码不全)。
我们发现源码中有一个 UserBuilder
的静态内部类,我们在调用 builder 方法时,实际返回了这个静态内部类的实例。
这个UserBuilder
类,具有和User相同的成员变量,且拥有名为sex
,name
的方法.这些以 User 的成员变量命名的方法,都是给UserBuilder
的成员变量赋值,并返回 this 。
这些方法返回 this,其实就是返回调用这些方法的UserBuilder
对象,也可称为“返回对象本身”。通过返回对象本身,形成了方法的链式调用。
再看 build 方法,它是UserBuilder
类的方法.它创建了一个新的 User 对象,并将自身的成员变量值,传给了User 的成员变量。
所以为什么这种模式叫''构建器'',因为要创建 User 类的实例,首先要创建内部类UserBuilder
。而这个UserBuilder 也就是构建器,是创建 User 对象的一个过渡者.所以利用这种模式,会有中间实例的创建,会加大虚拟机内存的消耗。
4.链式方法赋值,一定要用构建器模式吗?
不一定要用到构建器模式,通过上面分析 Lombok 的构建器原理可知,Lombok 的@Builder
更适合 final 修饰的成员变量。
因为 final 修饰的成员变量,需要在实例创建时就把值确定下来。(常见的如: web 开发中的 Query/Form 等查询入参,变量通常会设置为 final 的) 在类具有大量成员变量的时候,我们是不希望用户直接调用全参构造器的。
那如果有大量属性,但不需要它是成员变量不可变的对象,我们还需要构建器模式吗?个人认为,不需要,我们可以参考构建器,把代码赋值改成链式的即可:
public class User {
private Integer sex;
private String name;
public static User build() {
return new User();
}
private User() {
}
public User sex(Integer sex) {
this.sex = sex;
return this;
}
public User name(String name){
this.name = name;
return this;
}
}
我们要做的很简单: 私有化构造函数,在 build()
方法中实例化 User 对象。
5. 使用 @Builder 注解需要注意的 属性默认值问题
我们先来改造一下 User 类:
@Builder
@ToString
public class User {
@NonNull
private Integer sex = 1;
private String name;
}
添加一个 @NoNull
注解,此注解会要求sex属性不能为空。而我们又为 sex 属性设置了默认值 1 ,看起来应该没什么问题,我们再来测试一下:
@Test
public void builderTest() {
User user = User.builder()
.name("杏仁")
.build();
System.out.println(user.toString());
}
我们去掉了sex属性的设置,因为它已经设置了默认值,但是运行就会发现报如下错误:
java.lang.NullPointerException: sex is marked @NonNull but is null
这就很奇怪了,我们明明给 sex 属性设置了默认值啊,怎么还会是null呢?
为了一探究竟,我们还是反编译User.class文件看下Lombok 到底做了什么?以下为简化代码:
public class User {
@NonNull
private Integer sex = 1;
private String name;
// ...(代码略)
public static class UserBuilder {
private Integer sex;
private String name;
// ...(代码略)
public User build() {
return new User(this.sex,this.name);
}
// ...(代码略)
}
}
通过反编译后的代码可以看到,User 的 sex 属性已经有了默认值为 1。 但是 内部类 UserBuilder 却没有默认值,我们再调用 User.builder() 方法是,实例化的是 UserBuilder 内部类。
最后调用 UserBuilder.build() 方法时,是把 UserBuilder 类的属性传给了 User 类,导致 User 类的默认值被 UserBuilder 类覆盖。
而在User的全参构造方法中,则会判断 sex 是否 null ,是的话就会抛出NullPointerException("sex is marked @NonNull but is null")
异常。
如何解决呢?
解决起来也很简单,不止一种方法,其中一种是 我们可以在需要默认值的属性上使用@Builder.Default
注解
@NonNull
@Builder.Default
private Integer sex = 1;
再次测试就会发现,输出结果如我们期望那样。
那这个 @Builder.Default
注解到底做了什么事情呢? 老规矩,还是反编译下 User.class,查看下简化源代码:
public class User {
@NonNull
private Integer sex;
private String name;
private static Integer $default$sex() {
return 1;
}
// ...
public static User.UserBuilder builder() {
return new User.UserBuilder();
}
public static class UserBuilder {
private boolean sex$set;
private Integer sex;
private String name;
// ...
public User build() {
Integer sex = this.sex;
if (!this.sex$set) {
sex = User.$default$sex();
}
return new User(sex,this.name);
}
// ...
}
}
首先我们发现,User 类多了一个静态方法:$default$sex()
,此方法返回了 sex 的默认值。
接下来看内部类 UserBuilder 多了一个 boolean 类型的变量 sex$set
。
结合 sex() 方法来推断: 可知,在调用构建器设置 sex 属性时,会先判断是否为空(因为有@NoNull
注解),并且如果不为空,会设置内部类 UserBuilder 的 sex 属性值,并且把 sex$set
置为 true.由此我们可知,sex$set
变量就是来表示 sex是否是默认值的。
最后,在 UserBuilder.build() 方法里面不再是单纯的调用 User 的全参构造器实例化 User 对象,而是先判断sex是否有无默认值。
这就是 Lombok 为我们所做的事情。
6. 总结
所以,我觉得使用 Lombok 的 @Builder
注解的时候,还是要思考一下。当你不需要成员变量不可变的时候,你完全没必要使用构建器模式,因为这会消耗 Java 虚拟机的内存。还有使用 Lombok 的 @Builder
注解时,属性默认值失效问题。
全文完
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。
欢迎搜索关注微信公众号:杏仁技术站(微信号 xingren-tech)。