在前面几篇文章中,已经讲解了单例模式、工厂方法模式、抽象工厂模式,创建型还剩下一个比较重要的模式-建造者模式。在理解该模式之前,我还是希望重申设计模式的初衷,即为解决一些问题而提供的优良方案。学习设计模式遗忘其初衷,注定无法理解其真正的深刻内涵。从创建型模式的名称上来看,这些都是为了解决创建对象相关的问题。单例模式解决了如何创建唯一对象的问题,工厂方法模式解决了对象创建过程的封装问题,抽象工厂模式解决了创建多个相关联对象的问题,那么不知道你之前是否有思考过,建造者模式是要解决什么问题吗?我相信很多人可能没有思考而直接用老一套去学习该模式,最终就是不理解、记不住、用不会!
一、建造者模式概念理解
建造者模式,又称生成器模式,在大部分参考资料中都公认的定义为:
将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。
我不知道你们能不能理解这个很抽象的定义,起初我是不太能理解。我似乎从"分离"上能看出是要解耦,至于"构建"、"表示"这两个词本身就有模糊的感觉。实际上,这里的"构建"可以理解为"里子","表示"可以理解为"面子","同样的构建过程可以创建不同的表示"就是里子和多个面子需要解耦,如同一个人可以拥有很多面具以示人。意即,你还是你,但是外人看到的可以有多个。
"构建"指的就是里子。每一个可由外界创建对象的类都会提供一个或多个构造函数,或为有参构造,亦或为无参构造。对象的正规创建最常用的方式通过new关键字及构造方法Constructor。然而实际上,对象的构建过程并不止步于此。对象的本质是类信息(包括属性、方法)+数据,前者是编译后就固定不变的,后者数据是运行时改变的,因此对象的构建过程应从分配内存开始直到对象数据初始化结束。对象的初始化除了调用Constructor方法之外,还有Setter方法也会经常用于初始化对象数据(注,这里Setter方法不仅仅指的是get\set方法)。因此,对象的构建过程本质上是类构造函数-Constructor +Setter方法 。我们通常会使用这二者协同初始化对象数据并获得对象,但是这里面会不会存在问题呢?
可实例化类会提供多个构造函数提供给外界用于创建对象。构造函数可能的复杂性包括两个部分,一个是入参,一个是具体逻辑。后者可以通过工厂方法模式进行封装,那前者怎么办?即,若对象的创建需要很多外界输入参数,其中包括必要参数或非必要参数,这种情况下我们在一些源码或业务代码中会看到类中会提供很多个构造方法,不同构造方法通过重载的方式处理参数的差异性,使用方根据需要选择不同的构造方法。那,如果参数再多一些呢?你无法判断使用方想传入哪些参数或者不传入哪些参数来构建对象。还有一种情况即使类的构建过程需要通过对应的内部配置类进行构建。你只提供内部相关复杂对象作为入参的有参构造,我根本不了解这个入参如何办?举例,你提供了Configure内部类作为创建会话对象的有参构造,保证了迪米特原则,但是我这边数据可能是XML类型配置、YAML类型配置文件等,是否我需要解决这些文件解析为Configure内部类对象才能使用你的构造方法呢?这些都是问题,问题的根因就是你的类创建对象过程里子和面子耦合太严重(一个构造对象提供一个面子)。
对于构造方法参数数量问题,有的同学会说那我就把必选参数留在构造方法,可选参数使用Setter方法。似乎是能够解决一部分问题,但是Setter方法可能会使得对象的构建逻辑分散在各处,增加了使用不完整对象的风险。【因此,建造者模式是不建议调用端直接使用对象的Setter方法,必须封装起来】
如何解耦呢?能否将构建对象所需数据(面子)和构造函数中的逻辑(里子)拆分。我们讲过,解决耦合问题的究极好办法就是加一层,即Builder层。让Builder来充当这个面子,Builder负责提供给外界并预处理外界数据,转换为内部Constructor所需要的数据,然后由Builder来调用Constructor来返回对象 。这样的解耦使得Constructor仅专注于内部构建逻辑,而外界所需要的面子均由Builder来负责,如此设计既满足业务需要,也满足单一职责原则。
概念总结:
- 建造者模式主要解决创建对象时对象的创建过程与创建的所需数据表示的严重耦合性问题
- Constructor构造方法在非必要参数多时无法满足调用方的需求,且在复杂构造参数(内部配置类)时增加调用方创建难度。
- Setter方法也是对象构建过程的一部分,但是可能会误使用不成熟对象
- 通过职责拆分的方法解耦对象构建过程及其表示。Builder负责承接调用方可能赋值的数据,并转换为类对象创建的内部参数需要。目标对象的类仅关注自身功能的实现。【即Builder出去跑业务,伺候甲方的。Construstor做好自己本职工作】
二、应用实践
上一章节限于个人水平有限,理解角度与主流理解不完全契合,也会存在让大家误解的地方。因此,这个章节就通过具体的示例来说说我的想法,比较不同方案的优劣达到理解建造者模式的目的。在其他参考资料中,给出的案例和建造者解决方案我认为虽容易看懂,但不易理解且难以投入实际使用,可能还存在一些问题。下面我会通过一个简单的案例,一步步分析为什么常规的建造者模式大家几乎都不会使用到。
2.1 基本案例
案例的背景就以大家熟悉的"电脑"对象创建为例,想象以下,创建一个电脑对象应该具备哪些东西(数据)呢?大概会有CPU(中央处理器)、内存、硬盘、显卡、显示器、键盘、鼠标、声卡、网卡、光驱等。所以,创建一个电脑对象可能会需要很多种数据,但是根据用户的需求不同,需要创建的电脑对象可能也存在差异性。如:
① 我仅需要电脑用于跑程序,那我只需要【CPU、内存、硬盘】去创建电脑对象
② 我要跑深度学习,那我除了以上组件(数据)之外,还需要好的【显卡】
③ 我要打游戏,那我除了以上组件(数据)之外,还需要好的【显示器、键盘、鼠标、声卡、网卡】等
④ 我要看DVD,那我就得需要【光驱】了。
...
你看看多头疼,根据调用方使用场景不同,电脑对象的所需组件也有不同。难道你要遍历所有场景给出不同的构造方法,很明显这种方案不太现实。但如果你仔细发现就可以看到,对象的创建是可以区分必要组件和非必要组件的。在这个例子中,不论用户的需求是啥,都需要有【CPU、内存、硬盘】才能创建电脑。那是否电脑类仅提供必要组件的构造方法,非必要组件由Setter方法来负责呢?这已经是个很好的方案,大部分的业务开发中可能都会使用这个方案 。但设计模式不会止步于此,存在两个疑问:(1)Setter方案存在什么问题?(2)有没有更好的方案解决?
Setter方案存在两个问题,第一个问题前面也提到了,对象数据初始化逻辑分散在各处,增加了使用不完整对象的风险。第二个问题是使用方不清楚Setter方法具体含义,是仅用于对象初始化(类似于Construstor)还是用于对象数据运行时修改(类似于其余普通函数),即责任不明。
为解决这个问题,我们前面提出中间添加builder层,由Builder来负责封装对象构建的多种表示的差异性。示例代码如类图如下:
① "电脑"对象类
java
public class Computer {
private Object cpu;
private Object memory;
private Object hardDisk;
private Object graphicsCard;
private Object monitor;
private Object keyboard;
private Object mouse;
private Object soundCard;
private Object networkCard;
private Object opticalDrive;
public Computer(Object cpu, Object memory, Object hardDisk) {
this.cpu = cpu;
this.memory = memory;
this.hardDisk = hardDisk;
}
public void setGraphicsCard(Object graphicsCard) {
this.graphicsCard = graphicsCard;
}
public void setMemory(Object memory) {
this.memory = memory;
}
// 其他可选属性set方法省略...
public void doSomething() {
// ...
}
}
② Builder接口
java
public interface ComputerBuilder {
void setGraphicsCard(Object graphicsCard);
void setMonitor(Object monitor);
void setKeyboard(Object keyboard);
void setMouse(Object mouse);
void setSoundCard(Object soundCard);
void setNetworkCard(Object networkCard);
void setOpticalDrive(Object opticalDrive);
Computer buildComputer(Object cpu, Object memory, Object hardDisk);
Computer buildDLComputer(Object cpu, Object memory, Object hardDisk, Object graphicsCard);
}
③ Builder具体实现类
java
public class ConcreteComputerBuilder implements ComputerBuilder{
private Object graphicsCard;
private Object monitor;
private Object keyboard;
private Object mouse;
private Object soundCard;
private Object networkCard;
private Object opticalDrive;
@Override
public void setGraphicsCard(Object graphicsCard) {
this.graphicsCard = graphicsCard;
}
@Override
public void setMonitor(Object monitor) {
this.monitor = monitor;
}
@Override
public void setKeyboard(Object keyboard) {
this.keyboard = keyboard;
}
@Override
public void setMouse(Object mouse) {
this.mouse = mouse;
}
@Override
public void setSoundCard(Object soundCard) {
this.soundCard = soundCard;
}
@Override
public void setNetworkCard(Object networkCard) {
this.networkCard = networkCard;
}
@Override
public void setOpticalDrive(Object opticalDrive) {
this.opticalDrive = opticalDrive;
}
@Override
public Computer buildComputer(Object cpu, Object memory, Object hardDisk) {
// ...这里省略必要参数的校验逻辑
Computer ins = new Computer(cpu, memory, hardDisk);
if(this.graphicsCard != null) {
ins.setGraphicsCard(this.graphicsCard);
}
// ... 省略其余可选参数的处理逻辑
return ins;
}
@Override
public Computer buildDLComputer(Object cpu, Object memory, Object hardDisk, Object graphicsCard) {
// ...这里省略必要参数的校验逻辑
Computer ins = new Computer(cpu, memory, hardDisk);
if(graphicsCard == null) {
throw new RuntimeException("缺少显卡组件,无法创建深度学习机器");
}
ins.setGraphicsCard(this.graphicsCard);
// ... 省略其余可选参数的处理逻辑
return ins;
}
}
完整的代码如上,类图中使用SetXXX省略了很多Set方法。这种就属于建造者模式,调用方可通过ComputerBuilder来实现获取Computer对象,而在ComputerBuilder中对于可选参数的处理通过封装在了buildXXX方法中,并且提供了多种类型的builder方法供调用方使用。因此这样就实现了对象的构建与它的表示(代码中给出了2种表示,还可以更多)解耦,表示虽不同但是实际上都是通过同一个Constructor来创建对象的。
在一些参考资料中,还给出了Director类,Director翻译为导演类,意即就是将多种表示(如普通电脑、深度学习电脑、打游戏电脑等)预先封装到Director中供调用方使用,调用方不再感知setXXX设置组件数据的过程了。
我认为这种封装会造成类的急速膨胀,而且效果不好。你根本无法预知调用方会有什么样的场景,Director类也无法彻底解决问题。如上例,在Builder通过不同的方法返回不同对象也有同样效果且没有问题。
目前Setter方法问题通过这种方法已经很好解决了,很多相关参考资料也到此结束了,但我认为建造者模式的理解尚不能止步,因为还有遗漏的地方。之前的思考思路是,对象初始化Constructor构造函数可能会存在很多可选参数 ,不可能全部提供对应的构造方法。然后,我们分析了使用Setter方法解决可选参数的问题,但是Setter方法的使用增加了使用不成熟对象的风险。之后,我们增加了builder层封装处理了Setter方法,然后创建对象。
可以看出,我们之前只是从构造函数数量问题上思考,但是这很难让我们理解并使用建造者模式。为什么这么说呢?因为我们在平时开发中几乎不会碰到这种情况。在大部分的开发场景下,当函数形参数量大于7时,我们就会单独封装为一个类,因此我们很少会碰见要处理参数数量上的问题,大部分情况下我们是要处理类型问题。
2.2 理解"表示"
不同的表示 就是指用于初始化对象的数据不确定(由调用方指定),包括数量不确定 以及类型不确定 两个方面。数量不确定可通过上一小节的方案解决,但需要注意的是当函数形参数量大于7时,我们就会单独封装为一个类,这也会转换为类型不确定。类型不确定是啥意思呢?在第一章节内我们提到大部分的复杂对象由于其所需数据多,一般创建对象都是通过其内部配置类创建的,你不能要求所有的调用方都必须了解这个内部配置类才能创建对象。
说起来有些许抽象,就以前一小节案例来说。创建一个深度学习机器肯定是需要显卡组件(参数),细想这里会存在一个我们经常忽略的问题,就是显卡对于"创建电脑"来说会很复杂。创建电脑的过程你必须考虑显卡的接口、频率、功率等信息,这就意味着显卡的类型不仅仅是个简单Object类,而是一个相对复杂的参数类(定义为类GraphicsCard)。那问题来了,调用端为了意图创建一个电脑还需要先创建一个GraphicsCard对象吗?那其他组件呢?太麻烦啦,调用方能否只传输一个String表示显卡的型号呢。继续分析,电脑对象会提供String类型显卡参数的构造方法吗?明显不会,否则电脑对象内部就得处理String到GraphicsCard对象的创建过程了,电脑类的职责不再单一,这就是构建过程和表示耦合导致的结果。因此将String到GraphicsCard对象的过程就可以由Builder承担了,这样既满足了调用方对于不同表示的需求,也保证了目标对象构建过程的职责清晰。
根据此,响应代码类图如下:(代码简单不再提供具体代码)
从类图中看出,Builder提供了接受String类型数据来负责处理显卡参数类型问题,Computer还是仅负责自己内部的逻辑即可,外面的一切由Builder这个跑业务的给摆平。这么一看,建造者模式理解起来就豁然开朗,调用方要创建对象但是无法提供创建对象的条件,那就上Builder来处理这其中的GAP即可。这就达到了外界可以使用多种方式(表示)通过Builder创建目标对象,
而实际Computer创建对象的动作可能还是同一套逻辑(Constructor)。
数量不确定 远没有 类型不确定 带来的问题严重。如果仅仅是数量问题,使用Setter方法方案即可,使用Builder封装Setter稍微有点大材小用了。但是类型问题,就必须得使用Builder来解决了。
大部分的 数量问题 也都可能转换为 类型问题 。当参数数量很多时,都会考虑将这些参数封装起来,比如Configure类。目标对象的创建仅通过Confugure对象数据来创建,而Builder就负责将外部数据转换为Configure对象。这是普遍的做法,说到这你是否觉得上文的两个类图有哪里让人不适的地方?对,属性太多啦,目标对象属性一套数据,Builder也跟着一套数据,代码重复且可读性会差。解决办法就是封装,要么使用Configure类,要么使用枚举或其他都行。
大部分情况下我们是要处理类型问题 ,下面我们简单看下Mybatis中用于创建SqlSessionFactory对象的建造者模式是怎么应用的,下面给出SqlSessionFactoryBuilder的代码截图:
SqlSessionFactory对象的创建需要内部配置类Configuration,而调用方可能提供多种不同的源数据及配置。这其中的转换、验证逻辑均有Builder类来负责处理。
建造者模式优点:
- 将对象创建过程与表示解耦,满足单一职责原则
- 由于相互独立,对象的创建方式十分容易扩展
建造者模式缺点:
- 建造者模式几乎没有缺点。最好用于创建复杂对象,简单对象使用该模式会增加代码复杂度。