diff --git a/README.md b/README.md index f90d501..8329e86 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,17 @@ AQS剩余部分,以及阻塞队列源码暂时先搁置一下。 -# 设计模式【更新中】 +# 设计模式【更新中-6.27更新】 [1.设计模式-设计思想](docs/design_patterns/design_ideas/设计模式-01.设计思想.md) [2.设计模式-经典设计原则-第一节](docs/design_patterns/design_principles/设计模式-02.经典设计原则-第一节[必读].md) -[3.设计模式-经典设计原则-第二节](docs/design_patterns/design_principles/设计模式-02.经典设计原则-第二节[必读].md) +[2.设计模式-经典设计原则-第二节](docs/design_patterns/design_principles/设计模式-02.经典设计原则-第二节[必读].md) + +[3.设计模式-创建型-单例](docs/design_patterns/creational/设计模式-03.01-创建型-单例.md) + +[3.设计模式-创建型-工厂&建造者&原型](docs/design_patterns/creational/设计模式-03.02-创建型-工厂&建造者&原型.md) diff --git a/docs/design_patterns/creational/设计模式-03.01-创建型-单例.md b/docs/design_patterns/creational/设计模式-03.01-创建型-单例.md new file mode 100644 index 0000000..e9fb0c4 --- /dev/null +++ b/docs/design_patterns/creational/设计模式-03.01-创建型-单例.md @@ -0,0 +1,877 @@ +--- +title: 设计模式-03.01-创建型-单例 +tags: + - 设计模式 + - 单例 +categories: + - 设计模式 + - 03.创建型 +keywords: 设计模式,单例 +description: 详解了单例设计模式。 +cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@master/design_patterns/logo.jpg' +abbrlink: b5a1ed4a +date: 2021-06-26 21:51:58 +--- + + + + + +# 前言 + +23 种经典的设计模式。它们又可以分为三大类:创建型、结构型、行为型。对于这 23 种设计模式的学习,我们要有侧重点,因为有些模式是比较常用的,有些模式是很少被用到的。对于常用的设计模式,我们要花多点时间理解掌握。对于不常用的设计模式,我们只需要稍微了解即可。按照类型和是否常用,对这些设计模式,进行了简单的分类,具体如下所示。 + +### 创建型 + +常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。 + +不常用的有:原型模式。 + + + +### 结构型 + +常用的有:代理模式、桥接模式、装饰者模式、适配器模式。 + +不常用的有:门面模式、组合模式、享元模式。 + + + +### 行为型 + +常用的有:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。 + +不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。 + + + + + +# 单例模式【常用】 + +网上有很多讲解单例模式的文章,但大部分都侧重讲解,如何来实现一个线程安全的单例。今天也会讲到各种单例的实现方法,但是,重点还是希望带你搞清楚下面这样几个问题。 + +- 为什么要使用单例? +- 单例存在哪些问题? +- 单例与静态类的区别? +- 有何替代的解决方案? + + + +## 为什么要使用单例? + +单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。 + +对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。 + + + +### 实战案例一:处理资源访问冲突 + +我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的 Logger 类。具体的代码实现如下所示: + +```java +public class Logger { + private FileWriter writer; + + public Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + + public void log(String message) { + writer.write(message); + } +} + +// Logger类的应用示例: +public class UserController { + private Logger logger = new Logger(); + + public void login(String username, String password) { + // ...省略业务逻辑代码... + logger.log(username + " logined!"); + } +} + +public class OrderController { + private Logger logger = new Logger(); + + public void create(OrderVo order) { + // ...省略业务逻辑代码... + logger.log("Created an order: " + order.toString()); + } +} +``` + +在上面的代码中,我们注意到,所有的日志都写入到同一个文件 /Users/wangzheng/log.txt 中。在 UserController 和 OrderController 中,我们分别创建两个 Logger 对象。在 Web 容器的 Servlet 多线程环境下,如果两个 Servlet 线程同时分别执行 login() 和 create() 两个函数,并且同时写日志到 log.txt 文件中,那就有可能存在日志信息互相覆盖的情况。【这属于并发的知识点,看不懂的可以看笔者的并发系列】 + + + +那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给 log() 函数加互斥锁,同一时刻只允许一个线程调用执行 log() 函数。 + +```java +public class Logger { + private FileWriter writer; + + public Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + + public void log(String message) { + synchronized(this) { + writer.write(mesasge); + } + } +} +``` + +`synchronized(this)`这种对象级别的锁,锁不住,因为不同的对象之间并不共享同一把锁。所以我们换成类级别的锁。 + +```java +public class Logger { + private FileWriter writer; + + public Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + + public void log(String message) { + synchronized(Logger.class) { // 类级别的锁 + writer.write(mesasge); + } + } +} +``` + +除了使用类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列(比如 Java 中的 BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。 + + + +相对于这两种解决方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)。 + +我们将 Logger 设计成一个单例类,程序中只允许创建一个 Logger 对象,所有的线程共享使用的这一个 Logger 对象,共享一个 FileWriter 对象,而 FileWriter 本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。按照这个设计思路,我们实现了 Logger 单例类。具体代码如下所示: + +```java +public class Logger { + private FileWriter writer; + private static final Logger instance = new Logger(); + + private Logger() { + File file = new File("/Users/wangzheng/log.txt"); + writer = new FileWriter(file, true); //true表示追加写入 + } + + public static Logger getInstance() { + return instance; + } + + public void log(String message) { + writer.write(mesasge); + } +} + +// Logger类的应用示例: +public class UserController { + public void login(String username, String password) { + // ...省略业务逻辑代码... + Logger.getInstance().log(username + " logined!"); + } +} + +public class OrderController { + public void create(OrderVo order) { + // ...省略业务逻辑代码... + Logger.getInstance().log("Created a order: " + order.toString()); + } +} +``` + + + +### 实战案例二:表示全局唯一类 + +从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。 + +再比如,唯一递增 ID 号码生成器。如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。 + +```java +public class IdGenerator { + // AtomicLong是一个Java并发库中提供的一个原子变量类型, + // 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作, + // 比如下面会用到的incrementAndGet(). + private AtomicLong id = new AtomicLong(0); + private static final IdGenerator instance = new IdGenerator(); + private IdGenerator() {} + public static IdGenerator getInstance() { + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} + +// IdGenerator使用举例 +long id = IdGenerator.getInstance().getId(); +``` + + + +## 如何实现一个单例? + +尽管介绍如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,我这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,我们需要关注的点无外乎下面几个: + +- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例; + +- 考虑对象创建时的线程安全问题; + +- 考虑是否支持延迟加载; + +- 考虑 getInstance() 性能是否高(是否加锁)。 + + + +### 饿汉式(静态变量) + +饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示: + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static final IdGenerator instance = new IdGenerator(); + private IdGenerator() {} + public static IdGenerator getInstance() { + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` + + + +1. 有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点 +2. 如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。 +3. 如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。(如果初始化消耗资源过多,反而推荐懒汉式,早日发现问题) + + + +### 饿汉式(静态代码块) + +```java + +public class SingletonTest02 { + + public static void main(String[] args) { + // 测试 + Singleton instance = Singleton.getInstance(); + Singleton instance2 = Singleton.getInstance(); + System.out.println(instance == instance2); // true + System.out.println("instance.hashCode=" + instance.hashCode()); + System.out.println("instance2.hashCode=" + instance2.hashCode()); + } +} + +// 饿汉式(静态变量) + +class Singleton { + + // 1. 构造器私有化 + private Singleton() {} + + // 2.本类内部创建对象实例 + private static Singleton instance; + + static { // 在静态代码块中,创建单例对象 + instance = new Singleton(); + } + + // 3. 提供一个公有的静态方法,返回实例对象 + public static Singleton getInstance() { + return instance; + } +} + +``` + +没什么好说的,就是静态变量换成了静态代码块。 + + + + + +### 懒汉式(线程安全,同步方法) + +懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示: + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static IdGenerator instance; + private IdGenerator() {} + public static synchronized IdGenerator getInstance() { + if (instance == null) { + instance = new IdGenerator(); + } + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` + +不过懒汉式的缺点也很明显,我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。 + + + +### 懒汉式(线程安全,同步代码块) + +```java +// 懒汉式(线程安全,同步代码块) +class Singleton { + private static Singleton instance; + + private Singleton() {} + + public static Singleton getInstance() { + if (instance == null) { + synchronized (Singleton.class) { + instance = new Singleton(); + } + } + return instance; + } +} +``` + + + +### 懒汉式(线程不安全) + + + +```java +class Singleton { + private static Singleton instance; + + private Singleton() {} + + // 提供一个静态的公有方法,当使用到该方法时,才去创建 instance + // 即懒汉式 + public static Singleton getInstance() { + if (instance == null) { + instance = new Singleton(); + } + return instance; + } +``` + +只是为了完整性,写了出来,实际开发不推荐用线程不安全的 + +### 双重检测 + +饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。在这种实现方式中,只要 instance 被创建之后,即便再调用 getInstance() 函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示: + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static IdGenerator instance; + private IdGenerator() {} + public static IdGenerator getInstance() { + if (instance == null) { + synchronized(IdGenerator.class) { // 此处为类级别的锁 + if (instance == null) { + instance = new IdGenerator(); + } + } + } + return instance; + } + public long getId() { + return id.incrementAndGet(); + } +} +``` + + + +这种实现方式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。 + +据说,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。 + + + +### 静态内部类 + +我们再来看一种比双重检测更加简单的实现方法,那就是利用 Java 的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现 + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private IdGenerator() {} + + private static class SingletonHolder{ + private static final IdGenerator instance = new IdGenerator(); + } + + public static IdGenerator getInstance() { + return SingletonHolder.instance; + } + + public long getId() { + return id.incrementAndGet(); + } +} +``` + +SingletonHolder 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。 + + + +### 枚举 + +最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示: + +```java +public enum IdGenerator { + INSTANCE; + private AtomicLong id = new AtomicLong(0); + + public long getId() { + return id.incrementAndGet(); + } +} +``` + + + +## 单例存在哪些问题? + +尽管单例是一个很常用的设计模式,在实际的开发中,我们也确实经常用到它,但是,有些人认为单例是一种反模式(anti-pattern),并不推荐使用。大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题。接下来,我们就具体看看到底有哪些问题。 + + + +### 单例对 OOP 特性的支持不友好 + +我们知道,OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们还是通过 IdGenerator 这个例子来讲解。 + +```java +public class Order { + public void create(...) { + //... + long id = IdGenerator.getInstance().getId(); + //... + } +} + +public class User { + public void create(...) { + // ... + long id = IdGenerator.getInstance().getId(); + //... + } +} +``` + +IdGenerator 的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的 OOP 的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的 ID 生成算法。比如,订单 ID 和用户 ID 采用不同的 ID 生成器来生成。为了应对这个需求变化,我们需要修改所有用到 IdGenerator 类的地方,这样代码的改动就会比较大。 + +```java +public class Order { + public void create(...) { + //... + long id = IdGenerator.getInstance().getId(); + // 需要将上面一行代码,替换为下面一行代码 + long id = OrderIdGenerator.getIntance().getId(); + //... + } +} + +public class User { + public void create(...) { + // ... + long id = IdGenerator.getInstance().getId(); + // 需要将上面一行代码,替换为下面一行代码 + long id = UserIdGenerator.getIntance().getId(); + } +} +``` + +除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性 + + + +### 单例会隐藏类之间的依赖关系 + +我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。 + + + + + +### 单例对代码的扩展性不友好 + +1. 我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢? +2. 实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。 +3. 在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,我们希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行。 +4. 如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。 + + + +### 单例对代码的可测试性不友好 + +1. 单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,我们在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换。 + +2. 除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。 + + + + + +### 单例不支持有参数的构造函数 + +单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。 + + + +**第一种解决思路是**:创建完实例之后,再调用 init() 函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用 init() 方法,然后才能调用 getInstance() 方法,否则代码会抛出异常。具体的代码实现如下所示: + +```java +public class Singleton { + private static Singleton instance = null; + private final int paramA; + private final int paramB; + + private Singleton(int paramA, int paramB) { + this.paramA = paramA; + this.paramB = paramB; + } + + public static Singleton getInstance() { + if (instance == null) { + throw new RuntimeException("Run init() first."); + } + return instance; + } + + public synchronized static Singleton init(int paramA, int paramB) { + if (instance != null){ + throw new RuntimeException("Singleton has been created!"); + } + instance = new Singleton(paramA, paramB); + return instance; + } +} + +Singleton.init(10, 50); // 先init,再使用 +Singleton singleton = Singleton.getInstance(); +``` + + + +**第二种解决思路是**:将参数放到 getIntance() 方法中。具体的代码实现如下所示: + +```java +public class Singleton { + private static Singleton instance = null; + private final int paramA; + private final int paramB; + + private Singleton(int paramA, int paramB) { + this.paramA = paramA; + this.paramB = paramB; + } + + public synchronized static Singleton getInstance(int paramA, int paramB) { + if (instance == null) { + instance = new Singleton(paramA, paramB); + } + return instance; + } +} + +Singleton singleton = Singleton.getInstance(10, 50); +``` + + + +不知道你有没有发现,上面的代码实现稍微有点问题。如果我们如下两次执行 getInstance() 方法,那获取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。 + +```java +Singleton singleton1 = Singleton.getInstance(10, 50); +Singleton singleton2 = Singleton.getInstance(20, 30); +``` + + + + + +**第三种解决思路是**:将参数放到另外一个全局变量中。具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。**实际上,这种方式是最值得推荐的。** + +```java +public class Config { + public static final int PARAM_A = 123; + public static final int PARAM_B = 245; +} + +public class Singleton { + private static Singleton instance = null; + private final int paramA; + private final int paramB; + + private Singleton() { + this.paramA = Config.PARAM_A; + this.paramB = Config.PARAM_B; + } + + public synchronized static Singleton getInstance() { + if (instance == null) { + instance = new Singleton(); + } + return instance; + } +} +``` + + + +## 有何替代解决方案? + +刚刚我们提到了单例的很多问题,你可能会说,即便单例有这么多问题,但我不用不行啊。我业务上有表示全局唯一类的需求,如果不用单例,我怎么才能保证这个类的对象全局唯一呢?为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如, ID 唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子: + +```java +// 静态方法实现方式 +public class IdGenerator { + private static AtomicLong id = new AtomicLong(0); + + public static long getId() { + return id.incrementAndGet(); + } +} +// 使用举例 +long id = IdGenerator.getId(); +``` + +不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。具体的代码如下所示: + +```java +// 1. 老的使用方式 +public demofunction() { + //... + long id = IdGenerator.getInstance().getId(); + //... +} + +// 2. 新的使用方式:依赖注入 +public demofunction(IdGenerator idGenerator) { + long id = idGenerator.getId(); +} +// 外部调用demofunction()的时候,传入idGenerator +IdGenerator idGenerator = IdGenerator.getInsance(); +demofunction(idGenerator); +``` + + + +1. 基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对 OOP 特性、扩展性、可测性不友好等问题,还是无法解决。 +2. 所以,如果要完全解决这些问题,我们可能要从根上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC 容器(比如 Spring IOC 容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似 Java 中内存对象的释放由 JVM 来负责,而 C++ 中由程序员自己负责,道理是一样的。 +3. 对于替代方案工厂模式、IOC 容器的详细讲解,我们放到后面讲解。 + + + +## 如何理解单例模式中的唯一性? + +1. 首先,我们重新看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。 +2. ”定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。这里有点不好理解,我来详细地解释一下。 +3. 我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时所说的“可执行文件”(比如 Windows 下的 exe 文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,你完全可以简单地理解为就是代码本身。 +4. 当我们使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象。进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个 fork() 语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如 user 临时变量、User 对象)。 +5. 所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。 + + + +## 如何实现线程唯一的单例? + +1. 刚刚我们讲了单例类对象是进程唯一的,一个进程只能有一个单例对象。那如何实现一个线程唯一的单例呢? + +2. 我们先来看一下,什么是线程唯一的单例,以及“线程唯一”和“进程唯一”的区别。 + +3. “进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。这段话听起来有点像绕口令,我举个例子来解释一下。 + +4. 假设 IdGenerator 是一个线程唯一的单例类。在线程 A 内,我们可以创建一个单例对象 a。因为线程内唯一,在线程 A 内就不能再创建新的 IdGenerator 对象了,而线程间可以不唯一,所以,在另外一个线程 B 内,我们还可以重新创建一个新的单例对象 b。 + +5. 尽管概念理解起来比较复杂,但线程唯一单例的代码实现很简单,如下所示。在代码中,我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java 语言本身提供了 ThreadLocal 工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal 底层实现原理也是基于下面代码中所示的 HashMap。 + + + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + + private static final ConcurrentHashMap instances + = new ConcurrentHashMap<>(); + + private IdGenerator() {} + + public static IdGenerator getInstance() { + Long currentThreadId = Thread.currentThread().getId(); + instances.putIfAbsent(currentThreadId, new IdGenerator()); + return instances.get(currentThreadId); + } + + public long getId() { + return id.incrementAndGet(); + } +} +``` + + + + + +## 如何实现集群环境下的单例? + +1. 刚刚我们讲了“进程唯一”的单例和“线程唯一”的单例,现在,我们再来看下,“集群唯一”的单例。 +2. 首先,我们还是先来解释一下,什么是“集群唯一”的单例。 +3. 我们还是将它跟“进程唯一”“线程唯一”做个对比。“进程唯一”指的是进程内唯一、进程间不唯一。“线程唯一”指的是线程内唯一、线程间不唯一。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。 +4. 我们知道,经典的单例模式是进程内唯一的,那如何实现一个进程间也唯一的单例呢?如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来就有点难度了。 +5. 具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。 +6. 为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。按照这个思路, +7. 我用伪代码实现了一下这个过程,具体如下所示: + +```java +public class IdGenerator { + private AtomicLong id = new AtomicLong(0); + private static IdGenerator instance; + private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略,比如文件地址*/); + private static DistributedLock lock = new DistributedLock(); + + private IdGenerator() {} + + public synchronized static IdGenerator getInstance() + if (instance == null) { + lock.lock(); + instance = storage.load(IdGenerator.class); + } + return instance; + } + + public synchroinzed void freeInstance() { + storage.save(this, IdGeneator.class); + instance = null; //释放对象 + lock.unlock(); + } + + public long getId() { + return id.incrementAndGet(); + } +} + +// IdGenerator使用举例 +IdGenerator idGeneator = IdGenerator.getInstance(); +long id = idGenerator.getId(); +IdGenerator.freeInstance(); +``` + + + +## 如何实现一个多例模式? + +跟单例模式概念相对应的还有一个多例模式。那如何实现一个多例模式呢?“单例”指的是,一个类只能创建一个对象。对应地,“多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建 3 个对象。如果用代码来简单示例一下的话,就是下面这个样子: + +```java +public class BackendServer { + private long serverNo; + private String serverAddress; + + private static final int SERVER_COUNT = 3; + private static final Map serverInstances = new HashMap<>(); + + static { + serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080")); + serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080")); + serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080")); + } + + private BackendServer(long serverNo, String serverAddress) { + this.serverNo = serverNo; + this.serverAddress = serverAddress; + } + + public BackendServer getInstance(long serverNo) { + return serverInstances.get(serverNo); + } + + public BackendServer getRandomInstance() { + Random r = new Random(); + int no = r.nextInt(SERVER_COUNT)+1; + return serverInstances.get(no); + } +} +``` + + + +实际上,对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。这里的“类型”如何理解呢?我们还是通过一个例子来解释一下,具体代码如下所示。在代码中,logger name 就是刚刚说的“类型”,同一个 logger name 获取到的对象实例是相同的,不同的 logger name 获取到的对象实例是不同的。 + +```java +public class Logger { + private static final ConcurrentHashMap instances + = new ConcurrentHashMap<>(); + + private Logger() {} + + public static Logger getInstance(String loggerName) { + instances.putIfAbsent(loggerName, new Logger()); + return instances.get(loggerName); + } + + public void log() { + //... + } +} + +//l1==l2, l1!=l3 +Logger l1 = Logger.getInstance("User.class"); +Logger l2 = Logger.getInstance("User.class"); +Logger l3 = Logger.getInstance("Order.class"); +``` + + + +这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象,关于这一点,后面就会讲到。实际上,它还有点类似享元模式,两者的区别等到我们讲到享元模式的时候再来分析。除此之外,实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/design_patterns/creational/设计模式-03.02-创建型-工厂&建造者&原型.md b/docs/design_patterns/creational/设计模式-03.02-创建型-工厂&建造者&原型.md new file mode 100644 index 0000000..73e2d34 --- /dev/null +++ b/docs/design_patterns/creational/设计模式-03.02-创建型-工厂&建造者&原型.md @@ -0,0 +1,1310 @@ +--- +title: 设计模式-03.02-创建型-工厂&建造者&原型 +tags: + - 设计模式 + - 工厂 + - 建造者 + - 原型 +categories: + - 设计模式 + - 03.创建型 +keywords: 设计模式,工厂,建造者,原型 +description: 详解常用的工厂模式和建造者模式,以及不常用的原型模式 +cover: 'https://cdn.jsdelivr.net/gh/youthlql/lqlp@master/design_patterns/logo.jpg' +abbrlink: ba432704 +date: 2021-06-27 00:51:58 +--- + + + +# 工厂模式【常用】 + +> 工厂模式很重要,后面的很多架构设计,都是工厂模式联合着其它设计模式使用。 + +1. 一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在 GoF 的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。实际上,前面一种分类方法更加常见,所以,在今天的讲解中,我们沿用第一种分类方法。 + +2. 在这三种细分的工厂模式中,简单工厂、工厂方法原理比较简单,在实际的项目中也比较常用。而抽象工厂的原理稍微复杂点,在实际的项目中相对也不常用。所以,我们今天讲解的重点是前两种工厂模式。对于抽象工厂,稍微了解一下即可。 + +3. 除此之外,我们讲解的重点也不是原理和实现,因为这些都很简单,重点还是带你搞清楚应用场景:什么时候该用工厂模式?相对于直接 new 来创建对象,用工厂模式来创建究竟有什么好处呢? + +## 简单工厂(Simple Factory) + +首先,我们来看,什么是简单工厂模式。我们通过一个例子来解释一下。 + +在下面这段代码中,我们根据配置文件的后缀(json、xml、yaml、properties),选择不同的解析器(JsonRuleConfigParser、XmlRuleConfigParser……),将存储在文件中的配置解析成内存对象 RuleConfig。 + +```java +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) { + parser = new PropertiesRuleConfigParser(); + } else { + throw new InvalidRuleConfigException( + "Rule config file format is not supported: " + ruleConfigFilePath); + } + + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} +``` + +为了让代码逻辑更加清晰,可读性更好,我们要善于将功能独立的代码块封装成函数。按照这个设计思路,我们可以将代码中涉及 parser 创建的部分逻辑剥离出来,抽象成 createParser() 函数。重构之后的代码如下所示: + + + +```java + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParser parser = createParser(ruleConfigFileExtension); + if (parser == null) { + throw new InvalidRuleConfigException( + "Rule config file format is not supported: " + ruleConfigFilePath); + } + + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } + + private IRuleConfigParser createParser(String configFormat) { + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(configFormat)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(configFormat)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(configFormat)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(configFormat)) { + parser = new PropertiesRuleConfigParser(); + } + return parser; + } +} +``` + + + +为了让类的职责更加单一(**设计模式原则中的单一职责**,)、代码更加清晰,我们还可以进一步将 createParser() 函数剥离到一个独立的类中,让这个类只负责对象的创建。而这个类就是我们现在要讲的简单工厂模式类。具体的代码如下所示: + +```java +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension); + if (parser == null) { + throw new InvalidRuleConfigException( + "Rule config file format is not supported: " + ruleConfigFilePath); + } + + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} + +public class RuleConfigParserFactory { + public static IRuleConfigParser createParser(String configFormat) { + IRuleConfigParser parser = null; + if ("json".equalsIgnoreCase(configFormat)) { + parser = new JsonRuleConfigParser(); + } else if ("xml".equalsIgnoreCase(configFormat)) { + parser = new XmlRuleConfigParser(); + } else if ("yaml".equalsIgnoreCase(configFormat)) { + parser = new YamlRuleConfigParser(); + } else if ("properties".equalsIgnoreCase(configFormat)) { + parser = new PropertiesRuleConfigParser(); + } + return parser; + } +} +``` + + + +1. 在上面的代码实现中,我们每次调用 RuleConfigParserFactory 的 createParser() 的时候,都要创建一个新的 parser。 +2. 实际上,如果 parser 可以复用,为了节省内存和对象创建的时间,我们可以将 parser 事先创建好缓存起来。当调用 createParser() 函数的时候,我们从缓存中取出 parser 对象直接使用。这有点类似单例模式和简单工厂模式的结合,具体的代码实现如下所示。在接下来的讲解中,我们把上一种实现方法叫作简单工厂模式的第一种实现方法,把下面这种实现方法叫作简单工厂模式的第二种实现方法。 + + + +```java +public class RuleConfigParserFactory { + private static final Map cachedParsers = new HashMap<>(); + + static { + cachedParsers.put("json", new JsonRuleConfigParser()); + cachedParsers.put("xml", new XmlRuleConfigParser()); + cachedParsers.put("yaml", new YamlRuleConfigParser()); + cachedParsers.put("properties", new PropertiesRuleConfigParser()); + } + + public static IRuleConfigParser createParser(String configFormat) { + if (configFormat == null || configFormat.isEmpty()) { + return null;//返回null还是IllegalArgumentException全凭你自己说了算 + } + IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase()); + return parser; + } +} +``` + + + +1. 对于上面两种简单工厂模式的实现方法,如果我们要添加新的 parser,那势必要改动到 RuleConfigParserFactory 的代码,那这是不是违反开闭原则呢?实际上,如果不是需要频繁地添加新的 parser,只是偶尔修改一下 RuleConfigParserFactory 代码,稍微不符合开闭原则,也是完全可以接受的。 +2. 除此之外,在 RuleConfigParserFactory 的第一种代码实现中,有一组 if 分支判断逻辑,是不是应该用多态或其他设计模式来替代呢?实际上,如果 if 分支并不是很多,代码中有 if 分支也是完全可以接受的。应用多态或设计模式来替代 if 分支判断逻辑,也并不是没有任何缺点的,它虽然提高了代码的扩展性,更加符合**开闭原则**,但也增加了类的个数,牺牲了代码的可读性。关于这一点,我们在后面章节中会详细讲到。 +3. 总结一下,尽管简单工厂模式的代码实现中,有多处 if 分支判断逻辑,违背开闭原则,但权衡扩展性和可读性,这样的代码实现在大多数情况下(比如,不需要频繁地添加 parser,也没有太多的 parser)是没有问题的。 + + + +## 工厂方法(Factory Method) + +如果我们非得要将 if 分支逻辑去掉,那该怎么办呢?比较经典处理方法就是利用多态。按照多态的实现思路,对上面的代码进行重构。重构之后的代码如下所示: + +```java +public interface IRuleConfigParserFactory { + IRuleConfigParser createParser(); +} + +public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new JsonRuleConfigParser(); + } +} + +public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new XmlRuleConfigParser(); + } +} + +public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new YamlRuleConfigParser(); + } +} + +public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory { + @Override + public IRuleConfigParser createParser() { + return new PropertiesRuleConfigParser(); + } +} +``` + + + +实际上,这就是工厂方法模式的典型代码实现。这样当我们新增一种 parser 的时候,只需要新增一个实现了 IRuleConfigParserFactory 接口的 Factory 类即可。所以,工厂方法模式比起简单工厂模式更加符合开闭原则。 + + + +从上面的工厂方法的实现来看,一切都很完美,但是实际上存在挺大的问题。问题存在于这些工厂类的使用上。接下来,我们看一下,如何用这些工厂类来实现 RuleConfigSource 的 load() 函数。具体的代码如下所示: + + + +```java +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + + IRuleConfigParserFactory parserFactory = null; + if ("json".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new JsonRuleConfigParserFactory(); + } else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new XmlRuleConfigParserFactory(); + } else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new YamlRuleConfigParserFactory(); + } else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) { + parserFactory = new PropertiesRuleConfigParserFactory(); + } else { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath); + } + IRuleConfigParser parser = parserFactory.createParser(); + + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} +``` + + + +1. 从上面的代码实现来看,工厂类对象的创建逻辑又耦合进了 load() 函数中,跟我们最初的代码版本非常相似,引入工厂方法非但没有解决问题,反倒让设计变得更加复杂了。那怎么来解决这个问题呢? +2. 我们可以为工厂类再创建一个简单工厂,也就是工厂的工厂,用来创建工厂类对象。这段话听起来有点绕,我把代码实现出来了,你一看就能明白了。其中,RuleConfigParserFactoryMap 类是创建工厂对象的工厂类,getParserFactory() 返回的是缓存好的单例工厂对象。 + +```java +public class RuleConfigSource { + public RuleConfig load(String ruleConfigFilePath) { + String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath); + + IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension); + if (parserFactory == null) { + throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath); + } + IRuleConfigParser parser = parserFactory.createParser(); + + String configText = ""; + //从ruleConfigFilePath文件中读取配置文本到configText中 + RuleConfig ruleConfig = parser.parse(configText); + return ruleConfig; + } + + private String getFileExtension(String filePath) { + //...解析文件名获取扩展名,比如rule.json,返回json + return "json"; + } +} + +//因为工厂类只包含方法,不包含成员变量,完全可以复用, +//不需要每次都创建新的工厂类对象,所以,简单工厂模式的第二种实现思路更加合适。 +public class RuleConfigParserFactoryMap { //工厂的工厂 + private static final Map cachedFactories = new HashMap<>(); + + static { + cachedFactories.put("json", new JsonRuleConfigParserFactory()); + cachedFactories.put("xml", new XmlRuleConfigParserFactory()); + cachedFactories.put("yaml", new YamlRuleConfigParserFactory()); + cachedFactories.put("properties", new PropertiesRuleConfigParserFactory()); + } + + public static IRuleConfigParserFactory getParserFactory(String type) { + if (type == null || type.isEmpty()) { + return null; + } + IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase()); + return parserFactory; + } +} +``` + + + +1. 当我们需要添加新的规则配置解析器的时候,我们只需要创建新的 parser 类和 parser factory 类,并且在 RuleConfigParserFactoryMap 类中,将新的 parser factory 对象添加到 cachedFactories 中即可。代码的改动非常少,基本上符合开闭原则。 +2. 实际上,对于规则配置文件解析这个应用场景来说,工厂模式需要额外创建诸多 Factory 类,也会增加代码的复杂性,而且,每个 Factory 类只是做简单的 new 操作,功能非常单薄(只有一行代码),也没必要设计成独立的类,所以,在这个应用场景下,简单工厂模式简单好用,比工厂方法模式更加合适。 + + + +### 那什么时候该用工厂方法模式,而非简单工厂模式呢? + +1. 我们前面提到,之所以将某个代码块剥离出来,独立为函数或者类,原因是这个代码块的逻辑过于复杂,剥离之后能让代码更加清晰,更加可读、可维护。但是,如果代码块本身并不复杂,就几行代码而已,我们完全没必要将它拆分成单独的函数或者类。 +2. 基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的 new 一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。 +3. 除此之外,在某些场景下,如果对象不可复用,那工厂类每次都要返回不同的对象。如果我们使用简单工厂模式来实现,就只能选择第一种包含 if 分支逻辑的实现方式。如果我们还想避免烦人的 if-else 分支逻辑,这个时候,我们就推荐使用工厂方法模式。 + + + +## 抽象工厂(Abstract Factory) + +1. 讲完了简单工厂、工厂方法,我们再来看抽象工厂模式。抽象工厂模式的应用场景比较特殊,没有前两种常用,所以不是我们学习的重点,你简单了解一下就可以了。 +2. 在简单工厂和工厂方法中,类只有一种分类方式。比如,在规则配置解析那个例子中,解析器类只会根据配置文件格式(Json、Xml、Yaml……)来分类。但是,如果类有两种分类方式,比如,我们既可以按照配置文件格式来分类,也可以按照解析的对象(Rule 规则配置还是 System 系统配置)来分类,那就会对应下面这 8 个 parser 类。 + +```java +针对规则配置的解析器:基于接口IRuleConfigParser +JsonRuleConfigParser +XmlRuleConfigParser +YamlRuleConfigParser +PropertiesRuleConfigParser + +针对系统配置的解析器:基于接口ISystemConfigParser +JsonSystemConfigParser +XmlSystemConfigParser +YamlSystemConfigParser +PropertiesSystemConfigParser +``` + + + +1. 针对这种特殊的场景,如果还是继续用工厂方法来实现的话,我们要针对每个 parser 都编写一个工厂类,也就是要编写 8 个工厂类。如果我们未来还需要增加针对业务配置的解析器(比如 IBizConfigParser),那就要再对应地增加 4 个工厂类。而我们知道,过多的类也会让系统难维护。这个问题该怎么解决呢? +2. 抽象工厂就是针对这种非常特殊的场景而诞生的。我们可以让一个工厂负责创建多个不同类型的对象(IRuleConfigParser、ISystemConfigParser 等),而不是只创建一种 parser 对象。这样就可以有效地减少工厂类的个数。具体的代码实现如下所示: + +```java +public interface IConfigParserFactory { + IRuleConfigParser createRuleParser(); + ISystemConfigParser createSystemParser(); + //此处可以扩展新的parser类型,比如IBizConfigParser +} + +public class JsonConfigParserFactory implements IConfigParserFactory { + @Override + public IRuleConfigParser createRuleParser() { + return new JsonRuleConfigParser(); + } + + @Override + public ISystemConfigParser createSystemParser() { + return new JsonSystemConfigParser(); + } +} + +public class XmlConfigParserFactory implements IConfigParserFactory { + @Override + public IRuleConfigParser createRuleParser() { + return new XmlRuleConfigParser(); + } + + @Override + public ISystemConfigParser createSystemParser() { + return new XmlSystemConfigParser(); + } +} + +// 省略YamlConfigParserFactory和PropertiesConfigParserFactory代码 +``` + + + +## 如何设计实现一个Dependency Injection框架? + + + +当创建对象是一个“大工程”的时候,我们一般会选择使用工厂模式,来封装对象复杂的创建过程,将对象的创建和使用分离,让代码更加清晰。那何为“大工程”呢?上面我们讲了两种情况,一种是创建过程涉及复杂的 if-else 分支判断,另一种是对象创建需要组装多个其他类对象或者需要复杂的初始化过程。 + + + +今天,我们再来讲一个创建对象的“大工程”,依赖注入框架,或者叫依赖注入容器(Dependency Injection Container),简称 DI 容器。在今天的讲解中,我会带你一块搞清楚这样几个问题:DI 容器跟我们讲的工厂模式又有何区别和联系?DI 容器的核心功能有哪些,以及如何实现一个简单的 DI 容器? + + + + + +### 工厂模式和 DI 容器有何区别? + +1. 实际上,DI 容器底层最基本的设计思路就是基于工厂模式的。DI 容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”。 +2. DI 容器相对于我们上面讲的工厂模式的例子来说,它处理的是更大的对象创建工程。上面讲的工厂模式中,一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建,而 DI 容器负责的是整个应用中所有类对象的创建。 +3. 除此之外,DI 容器负责的事情要比单纯的工厂模式要多。比如,它还包括配置的解析、对象生命周期的管理。接下来,我们就详细讲讲,一个简单的 DI 容器应该包含哪些核心功能。 + + + +### DI 容器的核心功能有哪些? + +总结一下,一个简单的 DI 容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理。 + + + +> 首先,我们来看配置解析。 + +1. 在上面讲的工厂模式中,工厂类要创建哪个类对象是事先确定好的,并且是写死在工厂类代码中的。作为一个通用的框架来说,框架代码跟应用代码应该是高度解耦的,DI 容器事先并不知道应用会创建哪些对象,不可能把某个应用要创建的对象写死在框架代码中。所以,我们需要通过一种形式,让应用告知 DI 容器要创建哪些对象。这种形式就是我们要讲的配置。 +2. 我们将需要由 DI 容器来创建的类对象和创建类对象的必要信息(使用哪个构造函数以及对应的构造函数参数都是什么等等),放到配置文件中。容器读取配置文件,根据配置文件提供的信息来创建对象。 +3. 下面是一个典型的 Spring 容器的配置文件。Spring 容器读取这个配置文件,解析出要创建的两个对象:rateLimiter 和 redisCounter,并且得到两者的依赖关系:rateLimiter 依赖 redisCounter。 + + + +```java +public class RateLimiter { + private RedisCounter redisCounter; + public RateLimiter(RedisCounter redisCounter) { + this.redisCounter = redisCounter; + } + public void test() { + System.out.println("Hello World!"); + } + //... +} + +public class RedisCounter { + private String ipAddress; + private int port; + public RedisCounter(String ipAddress, int port) { + this.ipAddress = ipAddress; + this.port = port; + } + //... +} + +配置文件beans.xml: + + + + + + + + + + +``` + + + +> 其次,我们再来看对象创建。 + +1. 在 DI 容器中,如果我们给每个类都对应创建一个工厂类,那项目中类的个数会成倍增加,这会增加代码的维护成本。要解决这个问题并不难。我们只需要将所有类对象的创建都放到一个工厂类中完成就可以了,比如 BeansFactory。 +2. 你可能会说,如果要创建的类对象非常多,BeansFactory 中的代码会不会线性膨胀(代码量跟创建对象的个数成正比)呢?实际上并不会。待会讲到 DI 容器的具体实现的时候,我们会讲“反射”这种机制,它能在程序运行的过程中,动态地加载类、创建对象,不需要事先在代码中写死要创建哪些对象。所以,不管是创建一个对象还是十个对象,BeansFactory 工厂类代码都是一样的。 + + + +> 最后,我们来看对象的生命周期管理。 + +1. 上面我们讲到,简单工厂模式有两种实现方式,一种是每次都返回新创建的对象,另一种是每次都返回同一个事先创建好的对象,也就是所谓的单例对象。在 Spring 框架中,我们可以通过配置 scope 属性,来区分这两种不同类型的对象。scope=prototype 表示返回新创建的对象,scope=singleton 表示返回单例对象。 +2. 除此之外,我们还可以配置对象是否支持懒加载。如果 lazy-init=true,对象在真正被使用到的时候(比如:BeansFactory.getBean(“userService”))才被被创建;如果 lazy-init=false,对象在应用启动的时候就事先创建好。 +3. 不仅如此,我们还可以配置对象的 init-method 和 destroy-method 方法,比如 init-method=loadProperties(),destroy-method=updateConfigFile()。DI 容器在创建好对象之后,会主动调用 init-method 属性指定的方法来初始化对象。在对象被最终销毁之前,DI 容器会主动调用 destroy-method 属性指定的方法来做一些清理工作,比如释放数据库连接池、关闭文件。 + + + +## 如何实现一个简单的 DI 容器? + +用 Java 语言来实现一个简单的 DI 容器,核心逻辑只需要包括这样两个部分:配置文件解析、根据配置文件通过“反射”语法来创建对象。 + + + +### 最小原型设计 + +因为我们主要是讲解设计模式,所以,在今天的讲解中,我们只实现一个 DI 容器的最小原型。像 Spring 框架这样的 DI 容器,它支持的配置格式非常灵活和复杂。为了简化代码实现,重点讲解原理,在最小原型中,我们只支持下面配置文件中涉及的配置语法。 + +```java +配置文件beans.xml + + + + + + + + + + beanDefinitions = beanConfigParser.parse(in); + beansFactory.addBeanDefinitions(beanDefinitions); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + // TODO: log error + } + } + } + } + + @Override + public Object getBean(String beanId) { + return beansFactory.getBean(beanId); + } +} +``` + +从上面的代码中,我们可以看出,ClassPathXmlApplicationContext 负责组装 BeansFactory 和 BeanConfigParser 两个类,串联执行流程:从 classpath 中加载 XML 格式的配置文件,通过 BeanConfigParser 解析为统一的 BeanDefinition 格式,然后,BeansFactory 根据 BeanDefinition 来创建对象。 + + + +### 配置文件解析 + +配置文件解析主要包含 BeanConfigParser 接口和 XmlBeanConfigParser 实现类,负责将配置文件解析为 BeanDefinition 结构,以便 BeansFactory 根据这个结构来创建对象。配置文件的解析比较繁琐,不涉及我们要讲的理论知识,不是我们讲解的重点,所以这里我只给出两个类的大致设计思路,并未给出具体的实现代码。如果感兴趣的话,你可以自行补充完整。具体的代码框架如下所示: + +```java +public interface BeanConfigParser { + List parse(InputStream inputStream); + List parse(String configContent); +} + +public class XmlBeanConfigParser implements BeanConfigParser { + + @Override + public List parse(InputStream inputStream) { + String content = null; + // TODO:... + return parse(content); + } + + @Override + public List parse(String configContent) { + List beanDefinitions = new ArrayList<>(); + // TODO:... + return beanDefinitions; + } + +} + +public class BeanDefinition { + private String id; + private String className; + private List constructorArgs = new ArrayList<>(); + private Scope scope = Scope.SINGLETON; + private boolean lazyInit = false; + // 省略必要的getter/setter/constructors + + public boolean isSingleton() { + return scope.equals(Scope.SINGLETON); + } + + + public static enum Scope { + SINGLETON, + PROTOTYPE + } + + public static class ConstructorArg { + private boolean isRef; + private Class type; + private Object arg; + // 省略必要的getter/setter/constructors + } +} +``` + + + +### 核心工厂类设计 + +1. 最后,我们来看,BeansFactory 是如何设计和实现的。这也是我们这个 DI 容器最核心的一个类了。它负责根据从配置文件解析得到的 BeanDefinition 来创建对象。 +2. 如果对象的 scope 属性是 singleton,那对象创建之后会缓存在 singletonObjects 这样一个 map 中,下次再请求此对象的时候,直接从 map 中取出返回,不需要重新创建。如果对象的 scope 属性是 prototype,那每次请求对象,BeansFactory 都会创建一个新的对象返回。 +3. 实际上,BeansFactory 创建对象用到的主要技术点就是 Java 中的反射语法:一种动态加载类和创建对象的机制。我们知道,JVM 在启动的时候会根据代码自动地加载类、创建对象。至于都要加载哪些类、创建哪些对象,这些都是在代码中写死的,或者说提前写好的。但是,如果某个对象的创建并不是写死在代码中,而是放到配置文件中,我们需要在程序运行期间,动态地根据配置文件来加载类、创建对象,那这部分工作就没法让 JVM 帮我们自动完成了,我们需要利用 Java 提供的反射语法自己去编写代码。 +4. 搞清楚了反射的原理,BeansFactory 的代码就不难看懂了。具体代码实现如下所示: + + + +```java +public class BeansFactory { + private ConcurrentHashMap singletonObjects = new ConcurrentHashMap<>(); + private ConcurrentHashMap beanDefinitions = new ConcurrentHashMap<>(); + + public void addBeanDefinitions(List beanDefinitionList) { + for (BeanDefinition beanDefinition : beanDefinitionList) { + this.beanDefinitions.putIfAbsent(beanDefinition.getId(), beanDefinition); + } + + for (BeanDefinition beanDefinition : beanDefinitionList) { + if (beanDefinition.isLazyInit() == false && beanDefinition.isSingleton()) { + createBean(beanDefinition); + } + } + } + + public Object getBean(String beanId) { + BeanDefinition beanDefinition = beanDefinitions.get(beanId); + if (beanDefinition == null) { + throw new NoSuchBeanDefinitionException("Bean is not defined: " + beanId); + } + return createBean(beanDefinition); + } + + @VisibleForTesting + protected Object createBean(BeanDefinition beanDefinition) { + if (beanDefinition.isSingleton() && singletonObjects.contains(beanDefinition.getId())) { + return singletonObjects.get(beanDefinition.getId()); + } + + Object bean = null; + try { + Class beanClass = Class.forName(beanDefinition.getClassName()); + List args = beanDefinition.getConstructorArgs(); + if (args.isEmpty()) { + bean = beanClass.newInstance(); + } else { + Class[] argClasses = new Class[args.size()]; + Object[] argObjects = new Object[args.size()]; + for (int i = 0; i < args.size(); ++i) { + BeanDefinition.ConstructorArg arg = args.get(i); + if (!arg.getIsRef()) { + argClasses[i] = arg.getType(); + argObjects[i] = arg.getArg(); + } else { + BeanDefinition refBeanDefinition = beanDefinitions.get(arg.getArg()); + if (refBeanDefinition == null) { + throw new NoSuchBeanDefinitionException("Bean is not defined: " + arg.getArg()); + } + argClasses[i] = Class.forName(refBeanDefinition.getClassName()); + argObjects[i] = createBean(refBeanDefinition); + } + } + bean = beanClass.getConstructor(argClasses).newInstance(argObjects); + } + } catch (ClassNotFoundException | IllegalAccessException + | InstantiationException | NoSuchMethodException | InvocationTargetException e) { + throw new BeanCreationFailureException("", e); + } + + if (bean != null && beanDefinition.isSingleton()) { + singletonObjects.putIfAbsent(beanDefinition.getId(), bean); + return singletonObjects.get(beanDefinition.getId()); + } + return bean; + } +} +``` + +1. 执行入口那里调用`addBeanDefinitions` +2. 然后`addBeanDefinitions`再调用createBean利用反射创建对象,如果对象的 scope 属性是 singleton,那对象创建之后会缓存在 singletonObjects 这样一个 map 中 +3. 最后最小原型设计那里再调用getBean从singletonObjects 获取对象。 + + + + + +# 建造者模式【常用】 + +> 只要是标注常用的,基本上用的都比较多,无论篇幅长短,都不要忽视。 + +建造者模式的原理和代码实现非常简单,掌握起来并不难,难点在于应用场景。比如,你有没有考虑过这样几个问题:直接使用构造函数或者配合 set 方法就能创建对象,为什么还需要建造者模式来创建呢?建造者模式和工厂模式都可以创建对象,那它们两个的区别在哪里呢? + +## 为什么需要建造者模式? + + + +1. 在平时的开发中,创建一个对象最常用的方式是,使用 new 关键字调用类的构造函数来完成。我的问题是,什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?你可以先思考一下,下面我通过一个例子来带你看一下。 +2. 假设有这样一道设计面试题:我们需要定义一个资源池配置类 ResourcePoolConfig。这里的资源池,你可以简单理解为线程池、连接池、对象池等。在这个资源池配置类中,有以下几个成员变量,也就是可配置项。现在,请你编写代码实现这个 ResourcePoolConfig 类。 + + + + + +只要你稍微有点开发经验,那实现这样一个类对你来说并不是件难事。最常见、最容易想到的实现思路如下代码所示。因为 maxTotal、maxIdle、minIdle 不是必填变量,所以在创建 ResourcePoolConfig 对象的时候,我们通过往构造函数中,给这几个参数传递 null 值,来表示使用默认值。 + +```java +public class ResourcePoolConfig { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + + public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("name should not be empty."); + } + this.name = name; + + if (maxTotal != null) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("maxTotal should be positive."); + } + this.maxTotal = maxTotal; + } + + if (maxIdle != null) { + if (maxIdle < 0) { + throw new IllegalArgumentException("maxIdle should not be negative."); + } + this.maxIdle = maxIdle; + } + + if (minIdle != null) { + if (minIdle < 0) { + throw new IllegalArgumentException("minIdle should not be negative."); + } + this.minIdle = minIdle; + } + } + //...省略getter方法... +} +``` + +现在,ResourcePoolConfig 只有 4 个可配置项,对应到构造函数中,也只有 4 个参数,参数的个数不多。但是,如果可配置项逐渐增多,变成了 8 个、10 个,甚至更多,那继续沿用现在的设计思路,构造函数的参数列表会变得很长,代码在可读性和易用性上都会变差。在使用构造函数的时候,我们就容易搞错各参数的顺序,传递进错误的参数值,导致非常隐蔽的 bug。 + +```java +// 参数太多,导致可读性差、参数可能传递错误 +ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20,false, true); +``` + + + +解决这个问题的办法你应该也已经想到了,那就是用 set() 函数来给成员变量赋值,以替代冗长的构造函数。我们直接看代码,具体如下所示。其中,配置项 name 是必填的,所以我们把它放到构造函数中设置,强制创建类对象的时候就要填写。其他配置项 maxTotal、maxIdle、minIdle 都不是必填的,所以我们通过 set() 函数来设置,让使用者自主选择填写或者不填写。 + + + +```java +public class ResourcePoolConfig { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + + public ResourcePoolConfig(String name) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("name should not be empty."); + } + this.name = name; + } + + public void setMaxTotal(int maxTotal) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("maxTotal should be positive."); + } + this.maxTotal = maxTotal; + } + + public void setMaxIdle(int maxIdle) { + if (maxIdle < 0) { + throw new IllegalArgumentException("maxIdle should not be negative."); + } + this.maxIdle = maxIdle; + } + + public void setMinIdle(int minIdle) { + if (minIdle < 0) { + throw new IllegalArgumentException("minIdle should not be negative."); + } + this.minIdle = minIdle; + } + //...省略getter方法... +} +``` + + + +接下来,我们来看新的 ResourcePoolConfig 类该如何使用。我写了一个示例代码,如下所示。没有了冗长的函数调用和参数列表,代码在可读性和易用性上提高了很多。 + +```java +// ResourcePoolConfig使用举例 +ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool"); +config.setMaxTotal(16); +config.setMaxIdle(8); +``` + + + +至此,我们仍然没有用到建造者模式,通过构造函数设置必填项,通过 set() 方法设置可选配置项,就能实现我们的设计需求。如果我们把问题的难度再加大点,比如,还需要解决下面这三个问题,那现在的设计思路就不能满足了。 + +- 我们刚刚讲到,name 是必填的,所以,我们把它放到构造函数中,强制创建对象的时候就设置。如果必填的配置项有很多,把这些必填配置项都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填项也通过 set() 方法设置,那校验这些必填项是否已经填写的逻辑就无处安放了。 +- 除此之外,假设配置项之间有一定的依赖关系,比如,如果用户设置了 maxTotal、maxIdle、minIdle 其中一个,就必须显式地设置另外两个;或者配置项之间有一定的约束条件,比如,maxIdle 和 minIdle 要小于等于 maxTotal。如果我们继续使用现在的设计思路,那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了。 +- 如果我们希望 ResourcePoolConfig 类对象是不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值。要实现这个功能,我们就不能在 ResourcePoolConfig 类中暴露 set() 方法。 + +为了解决这些问题,建造者模式就派上用场了。我们可以把校验逻辑放置到 Builder 类中,先创建建造者,并且通过 set() 方法设置建造者的变量值,然后在使用 build() 方法真正创建对象之前,做集中的校验,校验通过之后才会创建对象。除此之外,我们把 ResourcePoolConfig 的构造函数改为 private 私有权限。这样我们就只能通过建造者来创建 ResourcePoolConfig 类对象。并且,ResourcePoolConfig 没有提供任何 set() 方法,这样我们创建出来的对象就是不可变对象了。我们用建造者模式重新实现了上面的需求,具体的代码如下所示: + +```java +public class ResourcePoolConfig { + private String name; + private int maxTotal; + private int maxIdle; + private int minIdle; + + private ResourcePoolConfig(Builder builder) { + this.name = builder.name; + this.maxTotal = builder.maxTotal; + this.maxIdle = builder.maxIdle; + this.minIdle = builder.minIdle; + } + //...省略getter方法... + + //我们将Builder类设计成了ResourcePoolConfig的内部类。 + //我们也可以将Builder类设计成独立的非内部类ResourcePoolConfigBuilder。 + public static class Builder { + private static final int DEFAULT_MAX_TOTAL = 8; + private static final int DEFAULT_MAX_IDLE = 8; + private static final int DEFAULT_MIN_IDLE = 0; + + private String name; + private int maxTotal = DEFAULT_MAX_TOTAL; + private int maxIdle = DEFAULT_MAX_IDLE; + private int minIdle = DEFAULT_MIN_IDLE; + + public ResourcePoolConfig build() { + // 校验逻辑放到这里来做,包括必填项校验、依赖关系校验、约束条件校验等 + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("..."); + } + if (maxIdle > maxTotal) { + throw new IllegalArgumentException("..."); + } + if (minIdle > maxTotal || minIdle > maxIdle) { + throw new IllegalArgumentException("..."); + } + + return new ResourcePoolConfig(this); + } + + public Builder setName(String name) { + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("..."); + } + this.name = name; + return this; + } + + public Builder setMaxTotal(int maxTotal) { + if (maxTotal <= 0) { + throw new IllegalArgumentException("..."); + } + this.maxTotal = maxTotal; + return this; + } + + public Builder setMaxIdle(int maxIdle) { + if (maxIdle < 0) { + throw new IllegalArgumentException("..."); + } + this.maxIdle = maxIdle; + return this; + } + + public Builder setMinIdle(int minIdle) { + if (minIdle < 0) { + throw new IllegalArgumentException("..."); + } + this.minIdle = minIdle; + return this; + } + } +} + +// 这段代码会抛出IllegalArgumentException,因为minIdle>maxIdle +ResourcePoolConfig config = new ResourcePoolConfig.Builder() + .setName("dbconnectionpool") + .setMaxTotal(16) + .setMaxIdle(10) + .setMinIdle(12) + .build(); +``` + + + +实际上,使用建造者模式创建对象,还能避免对象存在无效状态。我再举个例子解释一下。比如我们定义了一个长方形类,如果不使用建造者模式,采用先创建后 set 的方式,那就会导致在第一个 set 之后,对象处于无效状态。具体代码如下所示 + +```java +Rectangle r = new Rectange(); // r is invalid +r.setWidth(2); // r is invalid +r.setHeight(3); // r is valid +``` + +> 这里是说,长方形必须同时具备宽、高两个属性才是一个有效的长方形。只有其中一个属性,这个长方形对象就没有意义,是无效的。 + +1. 为了避免这种无效状态的存在,我们就需要使用构造函数一次性初始化好所有的成员变量。如果构造函数参数过多,我们就需要考虑使用建造者模式,先设置建造者的变量,然后再一次性地创建对象,让对象一直处于有效状态。(建造者主要解决参数过多、参数检验、控制对象创建后不可变的问题) +2. 实际上,如果我们并不是很关心对象是否有短暂的无效状态,也不是太在意对象是否是可变的。比如,对象只是用来映射数据库读出来的数据,那我们直接暴露 set() 方法来设置类的成员变量值是完全没问题的。而且,使用建造者模式来构建对象,代码实际上是有点重复的,ResourcePoolConfig 类中的成员变量,要在 Builder 类中重新再定义一遍。 + + + + + +## 与工厂模式有何区别? + + + +1. 从上面的讲解中,我们可以看出,建造者模式是让建造者类来负责对象的创建工作。上面讲到的工厂模式,是由工厂类来负责对象创建的工作。那它们之间有什么区别呢? +2. 实际上,工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。 + + + +网上有一个经典的例子很好地解释了两者的区别: + +> 顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。 + +实际上,我们也不要太学院派,非得把工厂模式、建造者模式分得那么清楚,我们需要知道的是,每个模式为什么这么设计,能解决什么问题。只有了解了这些最本质的东西,我们才能不生搬硬套,才能灵活应用,甚至可以混用各种模式创造出新的模式,来解决特定场景的问题。 + + + + + + + +# 原型模式【不常用】 + +今天的讲解跟具体某一语言的语法机制无关,而是通过一个 clone 散列表的例子带你搞清楚:原型模式的应用场景,以及它的两种实现方式:深拷贝和浅拷贝。虽然原型模式的原理和代码实现非常简单,但今天举的例子还是稍微有点复杂的 + + + +## 原型模式的原理与应用 + +如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式(Prototype Design Pattern),简称原型模式。 + + + +> 那何为“对象的创建成本比较大”? + +1. 实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。 +2. 但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从 RPC、网络、数据库、文件系统等非常慢速的 IO 中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作。 + + + +> 这么说还是比较理论,接下来,我们通过一个例子来解释一下刚刚这段话。 + + + +1. 假设数据库中存储了大约 10 万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统 A 在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引。 +2. 如果你熟悉的是 Java 语言,可以直接使用语言中提供的 HashMap 容器来实现。其中,HashMap 的 key 为搜索关键词,value 为关键词详细信息(比如搜索次数)。我们只需要将数据从数据库中读取出来,放入 HashMap 就可以了。 +3. 不过,我们还有另外一个系统 B,专门用来分析搜索日志,定期(比如间隔 10 分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对 v2 版本的数据进行更新,得到 v3 版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为。 + + + + + +1. 为了保证系统 A 中数据的实时性(不一定非常实时,但数据也不能太旧),系统 A 需要定期根据数据库中的数据,更新内存中的索引和数据。 +2. 我们该如何实现这个需求呢?实际上,也不难。我们只需要在系统 A 中,记录当前数据的版本 Va 对应的更新时间 Ta,从数据库中捞出更新时间大于 Ta 的所有搜索关键词,也就是找出 Va 版本与最新版本数据的“差集”,然后针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,我们就将它插入到散列表中。 +3. 按照这个设计思路,我给出的示例代码如下所示: + +```java +public class Demo { + private ConcurrentHashMap currentKeywords = new ConcurrentHashMap<>(); + private long lastUpdateTime = -1; + + public void refresh() { + // 从数据库中取出更新时间>lastUpdateTime的数据,放入到currentKeywords中 + List toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); + long maxNewUpdatedTime = lastUpdateTime; + for (SearchWord searchWord : toBeUpdatedSearchWords) { + if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { + maxNewUpdatedTime = searchWord.getLastUpdateTime(); + } + if (currentKeywords.containsKey(searchWord.getKeyword())) { + currentKeywords.replace(searchWord.getKeyword(), searchWord); + } else { + currentKeywords.put(searchWord.getKeyword(), searchWord); + } + } + + lastUpdateTime = maxNewUpdatedTime; + } + + private List getSearchWords(long lastUpdateTime) { + // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 + return null; + } +} +``` + + + +不过,现在,我们有一个特殊的要求:任何时刻,系统 A 中的所有数据都必须是同一个版本的,要么都是版本 a,要么都是版本 b,不能有的是版本 a,有的是版本 b。那刚刚的更新方式就不能满足这个要求了(因为数据很多,一个一个的for循环肯定会出现不同数据版本的问题)。除此之外,我们还要求:在更新内存数据的时候,系统 A 不能处于不可用状态,也就是不能停机更新数据。 + + + +1. 那我们该如何实现现在这个需求呢? + +2. 实际上,也不难。我们把正在使用的数据的版本定义为“服务版本”,当我们要更新内存中的数据的时候,我们并不是直接在服务版本(假设是版本 a 数据)上更新,而是重新创建另一个版本数据(假设是版本 b 数据),等新的版本数据建好之后,再一次性地将服务版本从版本 a 切换到版本 b。这样既保证了数据一直可用,又避免了中间状态的存在。 +3. 按照这个设计思路,我给出的示例代码如下所示: + +```java +public class Demo { + private HashMap currentKeywords=new HashMap<>(); + + public void refresh() { + HashMap newKeywords = new LinkedHashMap<>(); + + // 从数据库中取出所有的数据,放入到newKeywords中 + List toBeUpdatedSearchWords = getSearchWords(); + for (SearchWord searchWord : toBeUpdatedSearchWords) { + newKeywords.put(searchWord.getKeyword(), searchWord); + } + + currentKeywords = newKeywords; + } + + private List getSearchWords() { + // TODO: 从数据库中取出所有的数据 + return null; + } +} +``` + + + +1. 不过,在上面的代码实现中,newKeywords 构建的成本比较高。我们需要将这 10 万条数据从数据库中读出,然后计算哈希值,构建 newKeywords。这个过程显然是比较耗时。为了提高效率,原型模式就派上用场了。 +2. 我们拷贝 currentKeywords 数据到 newKeywords 中,然后从数据库中只捞出新增或者有更新的关键词,更新到 newKeywords 中。而相对于 10 万条数据来说,每次新增或者更新的关键词个数是比较少的,所以,这种策略大大提高了数据更新的效率。 +3. 按照这个设计思路,我给出的示例代码如下所示: + +```java +public class Demo { + private HashMap currentKeywords=new HashMap<>(); + private long lastUpdateTime = -1; + + public void refresh() { + // 原型模式就这么简单,拷贝已有对象的数据,更新少量差值 + HashMap newKeywords = (HashMap) currentKeywords.clone(); + + // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中 + List toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); + long maxNewUpdatedTime = lastUpdateTime; + for (SearchWord searchWord : toBeUpdatedSearchWords) { + if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { + maxNewUpdatedTime = searchWord.getLastUpdateTime(); + } + if (newKeywords.containsKey(searchWord.getKeyword())) { + SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword()); + oldSearchWord.setCount(searchWord.getCount()); + oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime()); + } else { + newKeywords.put(searchWord.getKeyword(), searchWord); + } + } + + lastUpdateTime = maxNewUpdatedTime; + currentKeywords = newKeywords; + } + + private List getSearchWords(long lastUpdateTime) { + // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 + return null; + } +} +``` + +1. 这里我们利用了 Java 中的 clone() 语法来复制一个对象。如果你熟悉的语言没有这个语法,那把数据从 currentKeywords 中一个个取出来,然后再重新计算哈希值,放入到 newKeywords 中也是可以接受的。毕竟,最耗时的还是从数据库中取数据的操作。相对于数据库的 IO 操作来说,内存操作和 CPU 计算的耗时都是可以忽略的。 +2. 不过,不知道你有没有发现,实际上,刚刚的代码实现是有问题的。要弄明白到底有什么问题,我们需要先了解另外两个概念:深拷贝(Deep Copy)和浅拷贝(Shallow Copy)。 + + + + + +## 原型模式的实现方式:深拷贝和浅拷贝 + +> 1、关于深拷贝浅拷贝,我觉得这篇文章讲的不错:https://blog.csdn.net/baiye_xing/article/details/71788741 + + + +> 我自己的总结: +> +> **浅拷贝**:对一个对象进行拷贝时,这个对象对应的类里的成员变量。 +> +> - 对于数据类型是基本数据类型的成员变量,浅拷贝会直接进行值拷贝,也就是将该属性值复制一份给新的对象。因为是两份不同的数据,所以对其中一个对象的该成员变量值进行修改,不会影响另一个对象拷贝得到的数据 +> - 对于数据类型是引用数据类型的成员变量(也就是子对象,或者数组啥的),也就是只是将该成员变量的引用值(引用拷贝【并发引用传递,Java本质还是值传递】)复制一份给新的对象。因为实际上两个对象的该成员变量都指向同一个实例。在这种情况下,在一个对象中修改该成员变量会影响到另一个对象的该成员变量值。 +> +> 1. **深拷贝**:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。 +> 2. 也就是说浅拷贝对于子对象只是拷贝了引用值,并没有真正的拷贝整个对象。 +> +> **深拷贝实现思路:** +> +> 1. 对于每个子对象都实现Cloneable 接口,并重写clone方法。最后在最顶层的类的重写的 clone 方法中调用所有子对象的 clone 方法即可实现深拷贝。【简单的说就是:每一层的每个子对象都进行浅拷贝=深拷贝】 +> +> 2. 利用序列化。【先对对象进行序列化,紧接着马上反序列化出 】 + + + +我们来看,在内存中,用散列表组织的搜索关键词信息是如何存储的。我画了一张示意图,大致结构如下所示。从图中我们可以发现,散列表索引中,每个结点存储的 key 是搜索关键词,value 是 SearchWord 对象的内存地址。SearchWord 对象本身存储在散列表之外的内存空间中。 + + + +浅拷贝和深拷贝的区别在于,浅拷贝只会复制图中的索引(散列表),不会复制数据(SearchWord 对象)本身。相反,深拷贝不仅仅会复制索引,还会复制数据本身。浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord 对象),而深拷贝得到的是一份完完全全独立的对象。具体的对比如下图所示: + + + + + + + + + +1. 在 Java 语言中,Object 类的 clone() 方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身。 +2. 在上面的代码中,我们通过调用 HashMap 上的 clone() 浅拷贝方法来实现原型模式。当我们通过 newKeywords 更新 SearchWord 对象的时候(比如,更新“设计模式”这个搜索关键词的访问次数),newKeywords 和 currentKeywords 因为指向相同的一组 SearchWord 对象,就会导致 currentKeywords 中指向的 SearchWord,有的是老版本的,有的是新版本的,就没法满足我们之前的需求:currentKeywords 中的数据在任何时刻都是同一个版本的,不存在介于老版本与新版本之间的中间状态。 + +3. 现在,我们又该如何来解决这个问题呢? + +4. 我们可以将浅拷贝替换为深拷贝。newKeywords 不仅仅复制 currentKeywords 的索引,还把 SearchWord 对象也复制一份出来,这样 newKeywords 和 currentKeywords 就指向不同的 SearchWord 对象,也就不存在更新 newKeywords 的数据会导致 currentKeywords 的数据也被更新的问题了。 +5. 那如何实现深拷贝呢?总结一下的话,有下面两种方法。 + + + +第一种方法:递归拷贝对象、对象的引用对象以及引用对象的引用对象……直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止。根据这个思路对之前的代码进行重构。重构之后的代码如下所示: + +```java +public class Demo { + private HashMap currentKeywords=new HashMap<>(); + private long lastUpdateTime = -1; + + public void refresh() { + // Deep copy + HashMap newKeywords = new HashMap<>(); + for (HashMap.Entry e : currentKeywords.entrySet()) { + SearchWord searchWord = e.getValue(); + SearchWord newSearchWord = new SearchWord( + searchWord.getKeyword(), searchWord.getCount(), searchWord.getLastUpdateTime()); + newKeywords.put(e.getKey(), newSearchWord); + } + + // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中 + List toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); + long maxNewUpdatedTime = lastUpdateTime; + for (SearchWord searchWord : toBeUpdatedSearchWords) { + if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { + maxNewUpdatedTime = searchWord.getLastUpdateTime(); + } + if (newKeywords.containsKey(searchWord.getKeyword())) { + SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword()); + oldSearchWord.setCount(searchWord.getCount()); + oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime()); + } else { + newKeywords.put(searchWord.getKeyword(), searchWord); + } + } + + lastUpdateTime = maxNewUpdatedTime; + currentKeywords = newKeywords; + } + + private List getSearchWords(long lastUpdateTime) { + // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 + return null; + } + +} +``` + + + +第二种方法:先将对象序列化,然后再反序列化成新的对象。具体的示例代码如下所示: + +```java +public Object deepCopy(Object object) { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + ObjectOutputStream oo = new ObjectOutputStream(bo); + oo.writeObject(object); + + ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray()); + ObjectInputStream oi = new ObjectInputStream(bi); + + return oi.readObject(); +} +``` + +1. 刚刚的两种实现方法,不管采用哪种,深拷贝都要比浅拷贝耗时、耗内存空间。针对我们这个应用场景,有没有更快、更省内存的实现方式呢? +2. 我们可以先采用浅拷贝的方式创建 newKeywords。对于需要更新的 SearchWord 对象,我们再使用深度拷贝的方式创建一份新的对象,替换 newKeywords 中的老对象。毕竟需要更新的数据是很少的。这种方式即利用了浅拷贝节省时间、空间的优点,又能保证 currentKeywords 中的中数据都是老版本的数据。具体的代码实现如下所示。这也是标题中讲到的,在我们这个应用场景下,最快速 clone 散列表的方式。 + + + +```java +public class Demo { + private HashMap currentKeywords=new HashMap<>(); + private long lastUpdateTime = -1; + + public void refresh() { + // Shallow copy + HashMap newKeywords = (HashMap) currentKeywords.clone(); + + // 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中 + List toBeUpdatedSearchWords = getSearchWords(lastUpdateTime); + long maxNewUpdatedTime = lastUpdateTime; + for (SearchWord searchWord : toBeUpdatedSearchWords) { + if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) { + maxNewUpdatedTime = searchWord.getLastUpdateTime(); + } + if (newKeywords.containsKey(searchWord.getKeyword())) { + newKeywords.remove(searchWord.getKeyword()); + } + newKeywords.put(searchWord.getKeyword(), searchWord); + } + + lastUpdateTime = maxNewUpdatedTime; + currentKeywords = newKeywords; + } + + private List getSearchWords(long lastUpdateTime) { + // TODO: 从数据库中取出更新时间>lastUpdateTime的数据 + return null; + } +} +``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +