在上一讲中,我们讲解了单例模式以及它的应用场景,并且还实现了一个基于 ThreadLocal 线程级别的全局上下文的完整单例的例子。今天,我们继续往下学习另外一个高频使用的创建型设计模式——Builder 模式,中文一般叫建造者模式或生成器模式。
事实上,建造者模式的代码实现非常简单,原理掌握起来也不难,而难点就在于什么时候采用它。比如,经常会遇到的以下两个问题:
为什么直接使用构造函数或者使用 set 方法来创建对象不方便?
为什么一定需要建造者模式来创建?
在 GoF 的书中,建造者模式的定义是这样的:
将复杂对象的构造与其表示分离,以便同一构造过程可以创建不同的表示。
接下来我们再来看建造者模式的通用 UML 图:
从图中我们可以看到,建造者模式主要包含四个角色。
Product:代表最终构建的对象,比如,汽车类。
Builder:代表建造者的抽象基类(可以使用接口来代替)。它定义了构建 Product 的步骤,它的子类(或接口实现类)需要实现这些步骤。同时,它还需要包含一个用来返回最终对象的方法 getProduct()。
ConcreteBuilder:代表 Builder 类的具体实现类。
Director:代表需要建造最终对象的某种算法。这里通过使用构造函数 Construct(Builder builder) 来调用 Builder 的创建方法创建对象,等创建完成后,再通过 getProduct() 方法来获取最终的完整对象。
为了帮助你更好地理解建造者模式,我画了一张创建一个 Product 所需要的时序图,如下图所示:
可以看到,这个创建原理还是很简单的。总结来说,就是先创建一个建造者,然后给建造者指定一个构建算法,建造者按照算法中的步骤分步完成对象的构建,最后获取最终对象。
下面我们再来看看 UML 图对应的实现代码:
public class Product {
private int partA;
private String partB;
private int partC;
public Product(int partA, String partB, int partC) {
this.partA = partA;
this.partB = partB;
this.partC = partC;
}
@Override
public String toString() {
return "Product{" +
"partA=" + partA +
", partB='" + partB + '\'' +
", partC=" + partC +
'}';
}
}
public interface Builder {
void buildPartA(int partA);
void buildPartB(String partB);
void buildPartC(int partC);
Product getResult();
}
public class ConcreteBuilder implements Builder {
private int partA;
private String partB;
private int partC;
@Override
public void buildPartA(int partA) {
this.partA = partA;
}
@Override
public void buildPartB(String partB) {
this.partB = partB;
}
@Override
public void buildPartC(int partC) {
this.partC = partC;
}
public Product getResult(){
return new Product(partA,partB,partC);
}
}
public class Director {
public void construct(Builder builder) {
builder.buildPartA(1);
builder.buildPartB("test-test");
builder.buildPartC(2);
}
}
运行一下单元测试:
public static void main(String[] args) {
Director director = new Director();
Builder builder = new ConcreteBuilder();
director.construct(builder);
System.out.println(builder.getResult());
}
输出结果如下:
Product{partA=1, partB='test-test', partC=2}
从代码实现中,我们可以分析出建造者模式封装(信息隐藏)了如下变化:
每个具体建造器的构建步骤;
当前正在使用哪一个建造器;
现有建造器的数量;
一个建造器里可以创建多个属性的特性。
你会发现,使用建造者模式后对象的职责是保证按照正确的步骤进行自由的组合。
建造者模式有哪些比较常用的场景呢?这里我简单总结了下,在以下四种情况下可以使用建造者模式。
需要生成的对象包含多个成员属性。
需要生成的对象的属性相互依赖,需要指定其生成顺序。
对象的创建过程独立于创建该对象的类。
需要隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。
接下来我们依然通过“没有使用建造者模式”和“使用了建造者模式”这两个对比的例子来帮助你理解建造者模式的常用场景,如下代码所示,我们创建一个打工人的类,每个打工人都需要包含姓名、年龄、电话和性别。
public class MigrantWorkerOld {
private String name; //姓名
private int age; //年龄
private String phone; //电话
private String gender; //性别
public MigrantWorkerOld(String name, int age, String phone, String gender) {
this.name = name;
this.age = age;
this.phone = phone;
this.gender = gender;
}
public MigrantWorkerOld(String name, int age, String phone) {
this.name = name;
this.age = age;
this.phone = phone;
}
public MigrantWorkerOld(String name, int age) {
this.name = name;
this.age = age;
}
}
上面这段代码没有使用建造者模式,所以我们需要使用传统的 getter、setter 方法,并指定不同的入参来构造对象。
而使用建造者模式后的类,功能却发生了完全不一样的变化,如下所示:
public class MigrantWorker {
//所有属性
private String name;
private int age;
private String phone;
private String gender;
public MigrantWorker() {
}
public static MigrantWorker builder() {
return new MigrantWorker();
}
//将属性作为步骤
public MigrantWorker name(String name) {
this.name = name;
return this;
}
//将属性作为步骤
public MigrantWorker age(int age) {
this.age = age;
return this;
}
//将属性作为步骤
public MigrantWorker phone(String phone) {
this.phone = phone;
return this;
}
//将属性作为步骤
public MigrantWorker gender(String gender) {
this.gender = gender;
return this;
}
//执行创建操作
public MigrantWorker build() {
validateObject(this);
return this;
}
private void validateObject(MigrantWorker migrantWorker) {
//可以做基础预校验,或自定义校验
}
@Override
public String toString() {
return "MigrantWorker{" +
"name='" + name + '\'' +
", age=" + age +
", phone='" + phone + '\'' +
", gender='" + gender + '\'' +
'}';
}
}
这里我们再写一个简单的单元测试:
public static void main(String[] args) {
MigrantWorker migrantWorker1 = MigrantWorker.builder()
.name("Spike")
.age(27)
.phone("1810000111")
.gender("男")
.build();
System.out.println(migrantWorker1);
MigrantWorker migrantWorker2 = MigrantWorker.builder()
.name("Max")
.age(7)
.phone("1810000222")
//没有性别
.build();
System.out.println(migrantWorker2);
MigrantWorker migrantWorker3 = MigrantWorker.builder()
.name("Mia")
.age(17)
//没有 电话
.gender("女")
.build();
System.out.println(migrantWorker3);
MigrantWorker migrantWorker4 = MigrantWorker.builder()
.name("Mick")
//没有 年龄
//没有 电话
//没有 性别
.build();
System.out.println(migrantWorker4);
}
得到的输出结果如下:
MigrantWorker{name='Spike', age=27, phone='1810000111', gender='男'}
MigrantWorker{name='Max', age=7, phone='1810000222', gender='null'}
MigrantWorker{name='Mia', age=17, phone='null', gender='女'}
MigrantWorker{name='Mick', age=0, phone='null', gender='null'}
从输出结果中,你会发现,虽然在创建 MigrantWorker 对象实例时只是指定了不同的属性构建步骤,但却构建出了完全不同的对象实例,而使用传统的 getter、setter 方法,则需要写很多不同的构造函数来应对变化。
所以说,使用建造者模式能更方便地帮助我们按需进行对象的实例化,避免写很多不同参数的构造函数,同时还能解决同一类型参数只能写一个构造函数的弊端。
虽然上面案例的实现比较简单,但是也充分演示了如何使用建造者模式。在实际的使用中,通常可以直接使用 lombok 的 @Builder 注解实现类自身的建造者模式,或者使用案例中的将自身类作为建造者的方法来实现。
实际上,所有 JDK 类库中的 Appendable 接口都是实现了建造者模式的优秀范例,你若感兴趣的话可自行去研究。
JDK 包中 Appendable 接口的实现类
那么问题来了,为什么要使用建造者模式来创建类?在我看来,有以下两点原因。
第一,分阶段、分步骤的方法更适合多次运算结果类创建场景。在面向对象软件开发中,很多时候创建类所需要的参数并不是一次都能准备好的,比如,计算订单优惠价格、查询库存状态等,有的参数可能需要通过调用多个服务运算后才能得出,这时我们可以根据已知参数预先对类进行创建,等有合适的参数时再设置类的属性,而不是等到所有结果都齐备后才去创建类。
第二,不需要关心特定类型的建造者的具体算法实现。 比如,我们在使用 StringBuilder 时,并不太关心它的具体代码实现,而是关心它提供给我们的使用功能。这在某些需要快速复用的场景中,能起到提升编码效率的作用。而换个角度来看,当你需要给别人提供一个建造者来创建类时,你就需要严格地设计你的建造者,并保证你的建造者类能够创建符合预期的类。
那使用建造者模式我们能收获什么呢?也就是建造者模式的优点有哪些呢?我总结出以下三点。
分离创建与使用。 在建造者模式中, 使用方不必知道你的内部实现算法(步骤)的细节,通过统一方法接口的调用,可以自由组合出不同的对象实例。
满足开闭原则。 每一个建造者都相对独立,因此能方便地进行替换或新增,这就大大提升了代码的可扩展性。
自由地组合对象的创建过程。由于建造者模式将复杂的创建步骤拆分为单个独立的创建步骤,这不仅使得代码的可读性更高,也使得在创建过程中,使用者可以根据自己的需要灵活创建。
当然,除了以上优点外,建造者模式也有一些缺点。
使用范围有限。 建造者模式所创建的对象一般都需要有很多的共同点,如果对象实例之间的差异性很大,则不适合使用建造者模式。
容易引起超大的类。我们都知道一辆汽车内部构造其实很复杂,作为开发者的你其实更关心的是像发动机、轮胎这样具备重用性的组件。一旦过度定制化对象创建的过程步骤,那么随着创建对象新需求的出现或变化,新的创建步骤就会被加进来,这会造成代码量的急剧膨胀,最终形成一个庞大的超大类。
增加代码行数。 虽然建造者模式能够提高代码的可阅读性,但也会以增加代码行数来作为代价。
你会发现,在现实中,我们经常会遇见很多使用建造者模式的软件,比如,Maven、Ant 之类的自动化构建系统,再比如,Jenkins 等持续集成系统,它们实际上都是使用了建造者模式的具体例子。
建造者模式的主要优点在于客户端不必知道对象内部组成的细节,将创建与使用进行了很好的解耦,使得我们可以使用相同的创建过程创建不同的对象,因此符合“开闭原则”,能够极大地提升代码的复用性。同时,因为每一个对象属性的创建步骤都被独立出来,所以还可以更加精细地控制对象的创建过程。
不过,缺点也同样突出。当我们使用建造者模式创建对象时,需要对象具备更多的共同点才能抽象出更适合的步骤,因此使用范围会受到很大的限制,一旦产品内部开始变得复杂,可能就会导致需要定义很多定制化的建造者类来适应这种变化,从而导致代码变得非常庞大。
应用建造者模式的关键就在于抓住拆分步骤,这是与工厂模式最大的区别所在。如果把建造者模式比作汽车整体组装工厂,那么工厂模式就是汽车配件组装工厂,前者侧重于把对象按照特定步骤组装完整,后者侧重于把组成对象的每一个属性或方法做得更通用后再组装。一定不要以为只要叫“工厂”就是指我们通常认为的统一组装模式。
如果使用建造者模式时,发现某一个步骤缺失,这时有什么办法可以不改动代码就能完成对象的构建呢?