mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 14:13:46 +08:00
mod
This commit is contained in:
364
极客时间专栏/设计模式之美/设计模式与范式:创建型/41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?.md
Normal file
364
极客时间专栏/设计模式之美/设计模式与范式:创建型/41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?.md
Normal file
@@ -0,0 +1,364 @@
|
||||
<audio id="audio" title="41 | 单例模式(上):为什么说支持懒加载的双重检测不比饿汉式更优?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/5b/c040683ccf363d91563d018ad7f7715b.mp3"></audio>
|
||||
|
||||
从今天开始,我们正式进入到设计模式的学习。我们知道,经典的设计模式有23种。其中,常用的并不是很多。据我的工作经验来看,常用的可能都不到一半。如果随便抓一个程序员,让他说一说最熟悉的3种设计模式,那其中肯定会包含今天要讲的单例模式。
|
||||
|
||||
网上有很多讲解单例模式的文章,但大部分都侧重讲解,如何来实现一个线程安全的单例。我今天也会讲到各种单例的实现方法,但是,这并不是我们专栏学习的重点,我重点还是希望带你搞清楚下面这样几个问题(第一个问题会在今天讲解,后面三个问题放到下一节课中讲解)。
|
||||
|
||||
- 为什么要使用单例?
|
||||
- 单例存在哪些问题?
|
||||
- 单例与静态类的区别?
|
||||
- 有何替代的解决方案?
|
||||
|
||||
话不多说,让我们带着这些问题,正式开始今天的学习吧!
|
||||
|
||||
## 为什么要使用单例?
|
||||
|
||||
**单例设计模式**(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
|
||||
|
||||
对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。
|
||||
|
||||
### 实战案例一:处理资源访问冲突
|
||||
|
||||
我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的Logger类。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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(mesasge);
|
||||
}
|
||||
}
|
||||
|
||||
// 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文件中,那就有可能存在日志信息互相覆盖的情况。
|
||||
|
||||
为什么会出现互相覆盖呢?我们可以这么类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了2,而是只加了1。同理,这里的log.txt文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/c2/2b0e6141d10399430c59169af4edc3c2.jpg" alt="">
|
||||
|
||||
那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给log()函数加互斥锁(Java中可以通过synchronized的关键字),同一时刻只允许一个线程调用执行log()函数。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用log()函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行log()函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/29/203eb5070c3820b48500d4ab95732f29.jpg" alt="">
|
||||
|
||||
我这里稍微补充一下,在刚刚的讲解和给出的代码中,我故意“隐瞒”了一个事实:我们给log()函数加不加对象级别的锁,其实都没有关系。因为FileWriter本身就是线程安全的,它的内部实现中本身就加了对象级别的锁,因此,在外层调用write()函数的时候,再加对象级别的锁实际上是多此一举。因为不同的Logger对象不共享FileWriter对象,所以,FileWriter对象级别的锁也解决不了数据写入互相覆盖的问题。
|
||||
|
||||
那我们该怎么解决这个问题呢?实际上,要想解决这个问题也不难,我们只需要把对象级别的锁,换成类级别的锁就可以了。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用log()函数,而导致的日志覆盖问题。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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单例类。具体代码如下所示:
|
||||
|
||||
```
|
||||
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号码生成器([第34讲](https://time.geekbang.org/column/article/190979)中我们讲的是唯一ID生成器,这里讲的是唯一递增ID生成器),如果程序中有两个对象,那就会存在生成重复ID的情况,所以,我们应该将ID生成器类设计为单例。
|
||||
|
||||
```
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
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();
|
||||
|
||||
```
|
||||
|
||||
实际上,今天讲到的两个代码实例(Logger、IdGenerator),设计的都并不优雅,还存在一些问题。至于有什么问题以及如何改造,今天我暂时卖个关子,下一节课我会详细讲解。
|
||||
|
||||
## 如何实现一个单例?
|
||||
|
||||
尽管介绍如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,我这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:
|
||||
|
||||
- 构造函数需要是private访问权限的,这样才能避免外部通过new创建实例;
|
||||
- 考虑对象创建时的线程安全问题;
|
||||
- 考虑是否支持延迟加载;
|
||||
- 考虑getInstance()性能是否高(是否加锁)。
|
||||
|
||||
如果你对这块已经很熟悉了,你可以当作复习。注意,下面的几种单例实现方式是针对Java语言语法的,如果你熟悉的是其他语言,不妨对比Java的这几种实现方式,自己试着总结一下,利用你熟悉的语言,该如何实现。
|
||||
|
||||
### 1.饿汉式
|
||||
|
||||
饿汉式的实现方式比较简单。在类加载的时候,instance静态实例就已经创建并初始化好了,所以,instance实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到IdGenerator的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。
|
||||
|
||||
如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
|
||||
|
||||
如果实例占用资源多,按照fail-fast的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如Java中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。
|
||||
|
||||
### 2.懒汉式
|
||||
|
||||
有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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),导致这个函数的并发度很低。量化一下的话,并发度是1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
|
||||
|
||||
### 3.双重检测
|
||||
|
||||
饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。
|
||||
|
||||
在这种实现方式中,只要instance被创建之后,即便再调用getInstance()函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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操作和初始化操作设计为原子操作,就自然能禁止重排序)。关于这点的详细解释,跟特定语言有关,我就不展开讲了,感兴趣的同学可以自行研究一下。
|
||||
|
||||
### 4.静态内部类
|
||||
|
||||
我们再来看一种比双重检测更加简单的实现方法,那就是利用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来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
|
||||
|
||||
### 5.枚举
|
||||
|
||||
最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过Java枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:
|
||||
|
||||
```
|
||||
public enum IdGenerator {
|
||||
INSTANCE;
|
||||
private AtomicLong id = new AtomicLong(0);
|
||||
|
||||
public long getId() {
|
||||
return id.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们来总结回顾一下,你需要掌握的重点内容。
|
||||
|
||||
**1.单例的定义**
|
||||
|
||||
单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
|
||||
|
||||
**2.单例的用处**
|
||||
|
||||
从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。
|
||||
|
||||
**3.单例的实现**
|
||||
|
||||
单例有下面几种经典的实现方式。
|
||||
|
||||
- 饿汉式
|
||||
|
||||
饿汉式的实现方式,在类加载的期间,就已经将instance静态实例初始化好了,所以,instance实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。
|
||||
|
||||
- 懒汉式
|
||||
|
||||
懒汉式相对于饿汉式的优势是支持延迟加载。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。
|
||||
|
||||
- 双重检测
|
||||
|
||||
双重检测实现方式既支持延迟加载、又支持高并发的单例实现方式。只要instance被创建之后,再调用getInstance()函数都不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。
|
||||
|
||||
- 静态内部类
|
||||
|
||||
利用Java的静态内部类来实现单例。这种实现方式,既支持延迟加载,也支持高并发,实现起来也比双重检测简单。
|
||||
|
||||
- 枚举
|
||||
|
||||
最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过Java枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 在你所熟悉的编程语言的类库中,有哪些类是单例类?又为什么要设计成单例类呢?
|
||||
1. 在第一个实战案例中,除了我们讲到的类级别锁、分布式锁、并发队列、单例模式等解决方案之外,实际上还有一种非常简单的解决日志互相覆盖问题的方法,你想到了吗?
|
||||
|
||||
可以在留言区说一说,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
272
极客时间专栏/设计模式之美/设计模式与范式:创建型/42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?.md
Normal file
272
极客时间专栏/设计模式之美/设计模式与范式:创建型/42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?.md
Normal file
@@ -0,0 +1,272 @@
|
||||
<audio id="audio" title="42 | 单例模式(中):我为什么不推荐使用单例模式?又有何替代方案?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/53/71/53cff65df464dc98c8f4de7f5acd6271.mp3"></audio>
|
||||
|
||||
上一节课中,我们通过两个实战案例,讲解了单例模式的一些应用场景,比如,避免资源访问冲突、表示业务概念上的全局唯一类。除此之外,我们还学习了Java语言中,单例模式的几种实现方法。如果你熟悉的是其他编程语言,不知道你课后有没有自己去对照着实现一下呢?
|
||||
|
||||
尽管单例是一个很常用的设计模式,在实际的开发中,我们也确实经常用到它,但是,有些人认为单例是一种反模式(anti-pattern),并不推荐使用。所以,今天,我就针对这个说法详细地讲讲这几个问题:单例这种设计模式存在哪些问题?为什么会被称为反模式?如果不用单例,该如何表示全局唯一类?有何替代的解决方案?
|
||||
|
||||
话不多说,让我们带着这些问题,正式开始今天的学习吧!
|
||||
|
||||
## 单例存在哪些问题?
|
||||
|
||||
大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似IdGenerator.getInstance().getId()这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题。接下来,我们就具体看看到底有哪些问题。
|
||||
|
||||
### 1.单例对OOP特性的支持不友好
|
||||
|
||||
我们知道,OOP的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们还是通过IdGenerator这个例子来讲解。
|
||||
|
||||
```
|
||||
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类的地方,这样代码的改动就会比较大。
|
||||
|
||||
```
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。
|
||||
|
||||
### 2.单例会隐藏类之间的依赖关系
|
||||
|
||||
我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。
|
||||
|
||||
通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。
|
||||
|
||||
### 3.单例对代码的扩展性不友好
|
||||
|
||||
我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?
|
||||
|
||||
实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。
|
||||
|
||||
在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些SQL语句运行得非常慢。这些SQL语句在执行的时候,长时间占用数据库连接资源,导致其他SQL请求无法响应。为了解决这个问题,我们希望将慢SQL与其他SQL隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢SQL独享一个数据库连接池,其他SQL独享另外一个数据库连接池,这样就能避免慢SQL影响到其他SQL的执行。
|
||||
|
||||
如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。
|
||||
|
||||
### 4.单例对代码的可测试性不友好
|
||||
|
||||
单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如DB,我们在写单元测试的时候,希望能通过mock的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现mock替换。
|
||||
|
||||
除此之外,如果单例类持有成员变量(比如IdGenerator中的id成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。关于这一点,你可以回过头去看下[第29讲](https://time.geekbang.org/column/article/186691)中的“其他常见的Anti-Patterns:全局变量”那部分的代码示例和讲解。
|
||||
|
||||
### 5.单例不支持有参数的构造函数
|
||||
|
||||
单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。
|
||||
|
||||
第一种解决思路是:创建完实例之后,再调用init()函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用init()方法,然后才能调用getInstance()方法,否则代码会抛出异常。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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()方法中。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?留给你自己思考,你可以在留言区说说你的解决思路。
|
||||
|
||||
```
|
||||
Singleton singleton1 = Singleton.getInstance(10, 50);
|
||||
Singleton singleton2 = Singleton.getInstance(20, 30);
|
||||
|
||||
```
|
||||
|
||||
第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config是一个存储了paramA和paramB值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。
|
||||
|
||||
```
|
||||
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唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子:
|
||||
|
||||
```
|
||||
// 静态方法实现方式
|
||||
public class IdGenerator {
|
||||
private static AtomicLong id = new AtomicLong(0);
|
||||
|
||||
public static long getId() {
|
||||
return id.incrementAndGet();
|
||||
}
|
||||
}
|
||||
// 使用举例
|
||||
long id = IdGenerator.getId();
|
||||
|
||||
```
|
||||
|
||||
不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。具体的代码如下所示:
|
||||
|
||||
```
|
||||
// 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);
|
||||
|
||||
```
|
||||
|
||||
基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对OOP特性、扩展性、可测性不友好等问题,还是无法解决。
|
||||
|
||||
所以,如果要完全解决这些问题,我们可能要从根上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC容器(比如Spring IOC容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似Java中内存对象的释放由JVM来负责,而C++中由程序员自己负责,道理是一样的。
|
||||
|
||||
对于替代方案工厂模式、IOC容器的详细讲解,我们放到后面的章节中讲解。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
|
||||
|
||||
**1.单例存在哪些问题?**
|
||||
|
||||
- 单例对OOP特性的支持不友好
|
||||
- 单例会隐藏类之间的依赖关系
|
||||
- 单例对代码的扩展性不友好
|
||||
- 单例对代码的可测试性不友好
|
||||
- 单例不支持有参数的构造函数
|
||||
|
||||
**2.单例有什么替代解决方案?**
|
||||
|
||||
为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决我们之前提到的问题。如果要完全解决这些问题,我们可能要从根上,寻找其他方式来实现全局唯一类了。比如,通过工厂模式、IOC容器(比如Spring IOC容器)来保证,由程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。
|
||||
|
||||
有人把单例当作反模式,主张杜绝在项目中使用。我个人觉得这有点极端。模式没有对错,关键看你怎么用。如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局的类,我们在其他地方new的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1.如果项目中已经用了很多单例模式,比如下面这段代码,我们该如何在尽量减少代码改动的情况下,通过重构代码来提高代码的可测试性呢?
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private UserRepo userRepo; // 通过构造哈函数或IOC容器依赖注入
|
||||
|
||||
public boolean validateCachedUser(long userId) {
|
||||
User cachedUser = CacheManager.getInstance().getUser(userId);
|
||||
User actualUser = userRepo.getUser(userId);
|
||||
// 省略核心逻辑:对比cachedUser和actualUser...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
2.在单例支持参数传递的第二种解决方案中,如果我们两次执行getInstance(paramA, paramB)方法,第二次传递进去的参数是不生效的,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?
|
||||
|
||||
```
|
||||
Singleton singleton1 = Singleton.getInstance(10, 50);
|
||||
Singleton singleton2 = Singleton.getInstance(20, 30);
|
||||
|
||||
```
|
||||
|
||||
欢迎留言和我分享你的思考和见解。如果有收获,也欢迎你把文章分享给你的朋友。
|
||||
205
极客时间专栏/设计模式之美/设计模式与范式:创建型/43 | 单例模式(下):如何设计实现一个集群环境下的分布式单例模式?.md
Normal file
205
极客时间专栏/设计模式之美/设计模式与范式:创建型/43 | 单例模式(下):如何设计实现一个集群环境下的分布式单例模式?.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="43 | 单例模式(下):如何设计实现一个集群环境下的分布式单例模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/f4/a3fe2116433692f4cd0bbd3dffd91ff4.mp3"></audio>
|
||||
|
||||
上两节课中,我们针对单例模式,讲解了单例的应用场景、几种常见的代码实现和存在的问题,并粗略给出了替换单例模式的方法,比如工厂模式、IOC容器。今天,我们再进一步扩展延伸一下,一块讨论一下下面这几个问题:
|
||||
|
||||
- 如何理解单例模式中的唯一性?
|
||||
- 如何实现线程唯一的单例?
|
||||
- 如何实现集群环境下的单例?
|
||||
- 如何实现一个多例模式?
|
||||
|
||||
今天的内容稍微有点“烧脑”,希望你在看的过程中多思考一下。话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 如何理解单例模式中的唯一性?
|
||||
|
||||
首先,我们重新看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。”
|
||||
|
||||
定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。这里有点不好理解,我来详细地解释一下。
|
||||
|
||||
我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时所说的“可执行文件”(比如Windows下的exe文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,你完全可以简单地理解为就是代码本身。
|
||||
|
||||
当我们使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的User user = new User();这条语句的时候,它就在自己的地址空间中创建一个user临时变量和一个User对象。
|
||||
|
||||
进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个fork()语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如user临时变量、User对象)。
|
||||
|
||||
所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
|
||||
|
||||
## 如何实现线程唯一的单例?
|
||||
|
||||
刚刚我们讲了单例类对象是进程唯一的,一个进程只能有一个单例对象。那如何实现一个线程唯一的单例呢?
|
||||
|
||||
我们先来看一下,什么是线程唯一的单例,以及“线程唯一”和“进程唯一”的区别。
|
||||
|
||||
“进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。这段话听起来有点像绕口令,我举个例子来解释一下。
|
||||
|
||||
假设IdGenerator是一个线程唯一的单例类。在线程A内,我们可以创建一个单例对象a。因为线程内唯一,在线程A内就不能再创建新的IdGenerator对象了,而线程间可以不唯一,所以,在另外一个线程B内,我们还可以重新创建一个新的单例对象b。
|
||||
|
||||
尽管概念理解起来比较复杂,但线程唯一单例的代码实现很简单,如下所示。在代码中,我们通过一个HashMap来存储对象,其中key是线程ID,value是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java语言本身提供了ThreadLocal工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal底层实现原理也是基于下面代码中所示的HashMap。
|
||||
|
||||
```
|
||||
public class IdGenerator {
|
||||
private AtomicLong id = new AtomicLong(0);
|
||||
|
||||
private static final ConcurrentHashMap<Long, IdGenerator> 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();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 如何实现集群环境下的单例?
|
||||
|
||||
刚刚我们讲了“进程唯一”的单例和“线程唯一”的单例,现在,我们再来看下,“集群唯一”的单例。
|
||||
|
||||
首先,我们还是先来解释一下,什么是“集群唯一”的单例。
|
||||
|
||||
我们还是将它跟“进程唯一”“线程唯一”做个对比。“进程唯一”指的是进程内唯一、进程间不唯一。“线程唯一”指的是线程内唯一、线程间不唯一。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
|
||||
|
||||
我们知道,经典的单例模式是进程内唯一的,那如何实现一个进程间也唯一的单例呢?如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来就有点难度了。
|
||||
|
||||
具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。
|
||||
|
||||
为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。
|
||||
|
||||
按照这个思路,我用伪代码实现了一下这个过程,具体如下所示:
|
||||
|
||||
```
|
||||
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个对象。如果用代码来简单示例一下的话,就是下面这个样子:
|
||||
|
||||
```
|
||||
public class BackendServer {
|
||||
private long serverNo;
|
||||
private String serverAddress;
|
||||
|
||||
private static final int SERVER_COUNT = 3;
|
||||
private static final Map<Long, BackendServer> 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获取到的对象实例是不同的。
|
||||
|
||||
```
|
||||
public class Logger {
|
||||
private static final ConcurrentHashMap<String, Logger> 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");
|
||||
|
||||
```
|
||||
|
||||
这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象,关于这一点,下一节课中就会讲到。实际上,它还有点类似享元模式,两者的区别等到我们讲到享元模式的时候再来分析。除此之外,实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要掌握的重点内容。
|
||||
|
||||
今天的内容比较偏理论,在实际的项目开发中,没有太多的应用。讲解的目的,主要还是拓展你的思路,锻炼你的逻辑思维能力,加深你对单例的认识。
|
||||
|
||||
**1.如何理解单例模式的唯一性?**
|
||||
|
||||
单例类中对象的唯一性的作用范围是“进程唯一”的。“进程唯一”指的是进程内唯一,进程间不唯一;“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”就意味着线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。“集群唯一”指的是进程内唯一、进程间也唯一。
|
||||
|
||||
**2.如何实现线程唯一的单例?**
|
||||
|
||||
我们通过一个HashMap来存储对象,其中key是线程ID,value是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java语言本身提供了ThreadLocal并发工具类,可以更加轻松地实现线程唯一单例。
|
||||
|
||||
**3.如何实现集群环境下的单例?**
|
||||
|
||||
我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。为了保证任何时刻在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,需要显式地将对象从内存中删除,并且释放对对象的加锁。
|
||||
|
||||
**4.如何实现一个多例模式?**
|
||||
|
||||
“单例”指的是一个类只能创建一个对象。对应地,“多例”指的就是一个类可以创建多个对象,但是个数是有限制的,比如只能创建3个对象。多例的实现也比较简单,通过一个Map来存储对象类型和对象之间的对应关系,来控制对象的个数。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在文章中,我们讲到单例唯一性的作用范围是进程,实际上,对于Java语言来说,单例类对象的唯一性的作用范围并非进程,而是类加载器(Class Loader),你能自己研究并解释一下为什么吗?
|
||||
|
||||
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
391
极客时间专栏/设计模式之美/设计模式与范式:创建型/44 | 工厂模式(上):我为什么说没事不要随便用工厂模式创建对象?.md
Normal file
391
极客时间专栏/设计模式之美/设计模式与范式:创建型/44 | 工厂模式(上):我为什么说没事不要随便用工厂模式创建对象?.md
Normal file
@@ -0,0 +1,391 @@
|
||||
<audio id="audio" title="44 | 工厂模式(上):我为什么说没事不要随便用工厂模式创建对象?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/60/3a4196149ba12e82b8281b2fb5344c60.mp3"></audio>
|
||||
|
||||
上几节课我们讲了单例模式,今天我们再来讲另外一个比较常用的创建型模式:工厂模式(Factory Design Pattern)。
|
||||
|
||||
一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在GoF的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。实际上,前面一种分类方法更加常见,所以,在今天的讲解中,我们沿用第一种分类方法。
|
||||
|
||||
在这三种细分的工厂模式中,简单工厂、工厂方法原理比较简单,在实际的项目中也比较常用。而抽象工厂的原理稍微复杂点,在实际的项目中相对也不常用。所以,我们今天讲解的重点是前两种工厂模式。对于抽象工厂,你稍微了解一下即可。
|
||||
|
||||
除此之外,我们讲解的重点也不是原理和实现,因为这些都很简单,重点还是带你搞清楚应用场景:什么时候该用工厂模式?相对于直接new来创建对象,用工厂模式来创建究竟有什么好处呢?
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 简单工厂(Simple Factory)
|
||||
|
||||
首先,我们来看,什么是简单工厂模式。我们通过一个例子来解释一下。
|
||||
|
||||
在下面这段代码中,我们根据配置文件的后缀(json、xml、yaml、properties),选择不同的解析器(JsonRuleConfigParser、XmlRuleConfigParser……),将存储在文件中的配置解析成内存对象RuleConfig。
|
||||
|
||||
```
|
||||
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()函数。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
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()函数剥离到一个独立的类中,让这个类只负责对象的创建。而这个类就是我们现在要讲的简单工厂模式类。具体的代码如下所示:
|
||||
|
||||
```
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
大部分工厂类都是以“Factory”这个单词结尾的,但也不是必须的,比如Java中的DateFormat、Calender。除此之外,工厂类中创建对象的方法一般都是create开头,比如代码中的createParser(),但有的也命名为getInstance()、createInstance()、newInstance(),有的甚至命名为valueOf()(比如Java String类的valueOf()函数)等等,这个我们根据具体的场景和习惯来命名就好。
|
||||
|
||||
在上面的代码实现中,我们每次调用RuleConfigParserFactory的createParser()的时候,都要创建一个新的parser。实际上,如果parser可以复用,为了节省内存和对象创建的时间,我们可以将parser事先创建好缓存起来。当调用createParser()函数的时候,我们从缓存中取出parser对象直接使用。
|
||||
|
||||
这有点类似单例模式和简单工厂模式的结合,具体的代码实现如下所示。在接下来的讲解中,我们把上一种实现方法叫作简单工厂模式的第一种实现方法,把下面这种实现方法叫作简单工厂模式的第二种实现方法。
|
||||
|
||||
```
|
||||
public class RuleConfigParserFactory {
|
||||
private static final Map<String, RuleConfigParser> 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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于上面两种简单工厂模式的实现方法,如果我们要添加新的parser,那势必要改动到RuleConfigParserFactory的代码,那这是不是违反开闭原则呢?实际上,如果不是需要频繁地添加新的parser,只是偶尔修改一下RuleConfigParserFactory代码,稍微不符合开闭原则,也是完全可以接受的。
|
||||
|
||||
除此之外,在RuleConfigParserFactory的第一种代码实现中,有一组if分支判断逻辑,是不是应该用多态或其他设计模式来替代呢?实际上,如果if分支并不是很多,代码中有if分支也是完全可以接受的。应用多态或设计模式来替代if分支判断逻辑,也并不是没有任何缺点的,它虽然提高了代码的扩展性,更加符合开闭原则,但也增加了类的个数,牺牲了代码的可读性。关于这一点,我们在后面章节中会详细讲到。
|
||||
|
||||
总结一下,尽管简单工厂模式的代码实现中,有多处if分支判断逻辑,违背开闭原则,但权衡扩展性和可读性,这样的代码实现在大多数情况下(比如,不需要频繁地添加parser,也没有太多的parser)是没有问题的。
|
||||
|
||||
## 工厂方法(Factory Method)
|
||||
|
||||
如果我们非得要将if分支逻辑去掉,那该怎么办呢?比较经典处理方法就是利用多态。按照多态的实现思路,对上面的代码进行重构。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
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()函数。具体的代码如下所示:
|
||||
|
||||
```
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的代码实现来看,工厂类对象的创建逻辑又耦合进了load()函数中,跟我们最初的代码版本非常相似,引入工厂方法非但没有解决问题,反倒让设计变得更加复杂了。那怎么来解决这个问题呢?
|
||||
|
||||
**我们可以为工厂类再创建一个简单工厂,也就是工厂的工厂,用来创建工厂类对象。**这段话听起来有点绕,我把代码实现出来了,你一看就能明白了。其中,RuleConfigParserFactoryMap类是创建工厂对象的工厂类,getParserFactory()返回的是缓存好的单例工厂对象。
|
||||
|
||||
```
|
||||
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<String, IRuleConfigParserFactory> 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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当我们需要添加新的规则配置解析器的时候,我们只需要创建新的parser类和parser factory类,并且在RuleConfigParserFactoryMap类中,将新的parser factory对象添加到cachedFactories中即可。代码的改动非常少,基本上符合开闭原则。
|
||||
|
||||
实际上,对于规则配置文件解析这个应用场景来说,工厂模式需要额外创建诸多Factory类,也会增加代码的复杂性,而且,每个Factory类只是做简单的new操作,功能非常单薄(只有一行代码),也没必要设计成独立的类,所以,在这个应用场景下,简单工厂模式简单好用,比工厂方法模式更加合适。
|
||||
|
||||
**那什么时候该用工厂方法模式,而非简单工厂模式呢?**
|
||||
|
||||
我们前面提到,之所以将某个代码块剥离出来,独立为函数或者类,原因是这个代码块的逻辑过于复杂,剥离之后能让代码更加清晰,更加可读、可维护。但是,如果代码块本身并不复杂,就几行代码而已,我们完全没必要将它拆分成单独的函数或者类。
|
||||
|
||||
基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的new一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。
|
||||
|
||||
除此之外,在某些场景下,如果对象不可复用,那工厂类每次都要返回不同的对象。如果我们使用简单工厂模式来实现,就只能选择第一种包含if分支逻辑的实现方式。如果我们还想避免烦人的if-else分支逻辑,这个时候,我们就推荐使用工厂方法模式。
|
||||
|
||||
## 抽象工厂(Abstract Factory)
|
||||
|
||||
讲完了简单工厂、工厂方法,我们再来看抽象工厂模式。抽象工厂模式的应用场景比较特殊,没有前两种常用,所以不是我们本节课学习的重点,你简单了解一下就可以了。
|
||||
|
||||
在简单工厂和工厂方法中,类只有一种分类方式。比如,在规则配置解析那个例子中,解析器类只会根据配置文件格式(Json、Xml、Yaml……)来分类。但是,如果类有两种分类方式,比如,我们既可以按照配置文件格式来分类,也可以按照解析的对象(Rule规则配置还是System系统配置)来分类,那就会对应下面这8个parser类。
|
||||
|
||||
```
|
||||
针对规则配置的解析器:基于接口IRuleConfigParser
|
||||
JsonRuleConfigParser
|
||||
XmlRuleConfigParser
|
||||
YamlRuleConfigParser
|
||||
PropertiesRuleConfigParser
|
||||
|
||||
针对系统配置的解析器:基于接口ISystemConfigParser
|
||||
JsonSystemConfigParser
|
||||
XmlSystemConfigParser
|
||||
YamlSystemConfigParser
|
||||
PropertiesSystemConfigParser
|
||||
|
||||
```
|
||||
|
||||
针对这种特殊的场景,如果还是继续用工厂方法来实现的话,我们要针对每个parser都编写一个工厂类,也就是要编写8个工厂类。如果我们未来还需要增加针对业务配置的解析器(比如IBizConfigParser),那就要再对应地增加4个工厂类。而我们知道,过多的类也会让系统难维护。这个问题该怎么解决呢?
|
||||
|
||||
抽象工厂就是针对这种非常特殊的场景而诞生的。我们可以让一个工厂负责创建多个不同类型的对象(IRuleConfigParser、ISystemConfigParser等),而不是只创建一种parser对象。这样就可以有效地减少工厂类的个数。具体的代码实现如下所示:
|
||||
|
||||
```
|
||||
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代码
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
在今天讲的三种工厂模式中,简单工厂和工厂方法比较常用,抽象工厂的应用场景比较特殊,所以很少用到,不是我们学习的重点。所以,下面我重点对前两种工厂模式的应用场景进行总结。
|
||||
|
||||
当创建逻辑比较复杂,是一个“大工程”的时候,我们就考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用相分离。何为创建逻辑比较复杂呢?我总结了下面两种情况。
|
||||
|
||||
- 第一种情况:类似规则配置解析的例子,代码中存在if-else分支判断,动态地根据不同的类型创建不同的对象。针对这种情况,我们就考虑使用工厂模式,将这一大坨if-else创建对象的代码抽离出来,放到工厂类中。
|
||||
- 还有一种情况,尽管我们不需要根据不同的类型创建不同的对象,但是,单个对象本身的创建过程比较复杂,比如前面提到的要组合其他类对象,做各种初始化操作。在这种情况下,我们也可以考虑使用工厂模式,将对象的创建过程封装到工厂类中。
|
||||
|
||||
对于第一种情况,当每个对象的创建逻辑都比较简单的时候,我推荐使用简单工厂模式,将多个对象的创建逻辑放到一个工厂类中。当每个对象的创建逻辑都比较复杂的时候,为了避免设计一个过于庞大的简单工厂类,我推荐使用工厂方法模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。同理,对于第二种情况,因为单个对象本身的创建逻辑就比较复杂,所以,我建议使用工厂方法模式。
|
||||
|
||||
除了刚刚提到的这几种情况之外,如果创建对象的逻辑并不复杂,那我们就直接通过new来创建对象就可以了,不需要使用工厂模式。
|
||||
|
||||
现在,我们上升一个思维层面来看工厂模式,它的作用无外乎下面这四个。这也是判断要不要使用工厂模式的最本质的参考标准。
|
||||
|
||||
- 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。
|
||||
- 代码复用:创建代码抽离到独立的工厂类之后可以复用。
|
||||
- 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。
|
||||
- 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 工厂模式是一种非常常用的设计模式,在很多开源项目、工具类中到处可见,比如Java中的Calendar、DateFormat类。除此之外,你还知道哪些用工厂模式实现类?可以留言说一说它们为什么要设计成工厂模式类?
|
||||
1. 实际上,简单工厂模式还叫作静态工厂方法模式(Static Factory Method Pattern)。之所以叫静态工厂方法模式,是因为其中创建对象的方法是静态的。那为什么要设置成静态的呢?设置成静态的,在使用的时候,是否会影响到代码的可测试性呢?
|
||||
|
||||
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,319 @@
|
||||
<audio id="audio" title="45 | 工厂模式(下):如何设计实现一个Dependency Injection框架?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/ff/9656485234582086d0fdd37ed1bc4fff.mp3"></audio>
|
||||
|
||||
在上一节课我们讲到,当创建对象是一个“大工程”的时候,我们一般会选择使用工厂模式,来封装对象复杂的创建过程,将对象的创建和使用分离,让代码更加清晰。那何为“大工程”呢?上一节课中我们讲了两种情况,一种是创建过程涉及复杂的if-else分支判断,另一种是对象创建需要组装多个其他类对象或者需要复杂的初始化过程。
|
||||
|
||||
今天,我们再来讲一个创建对象的“大工程”,依赖注入框架,或者叫依赖注入容器(Dependency Injection Container),简称DI容器。在今天的讲解中,我会带你一块搞清楚这样几个问题:DI容器跟我们讲的工厂模式又有何区别和联系?DI容器的核心功能有哪些,以及如何实现一个简单的DI容器?
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 工厂模式和DI容器有何区别?
|
||||
|
||||
实际上,DI容器底层最基本的设计思路就是基于工厂模式的。DI容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”。
|
||||
|
||||
DI容器相对于我们上节课讲的工厂模式的例子来说,它处理的是更大的对象创建工程。上节课讲的工厂模式中,一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建,而DI容器负责的是整个应用中所有类对象的创建。
|
||||
|
||||
除此之外,DI容器负责的事情要比单纯的工厂模式要多。比如,它还包括配置的解析、对象生命周期的管理。接下来,我们就详细讲讲,一个简单的DI容器应该包含哪些核心功能。
|
||||
|
||||
## DI容器的核心功能有哪些?
|
||||
|
||||
总结一下,一个简单的DI容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理。
|
||||
|
||||
**首先,我们来看配置解析。**
|
||||
|
||||
在上节课讲的工厂模式中,工厂类要创建哪个类对象是事先确定好的,并且是写死在工厂类代码中的。作为一个通用的框架来说,框架代码跟应用代码应该是高度解耦的,DI容器事先并不知道应用会创建哪些对象,不可能把某个应用要创建的对象写死在框架代码中。所以,我们需要通过一种形式,让应用告知DI容器要创建哪些对象。这种形式就是我们要讲的配置。
|
||||
|
||||
我们将需要由DI容器来创建的类对象和创建类对象的必要信息(使用哪个构造函数以及对应的构造函数参数都是什么等等),放到配置文件中。容器读取配置文件,根据配置文件提供的信息来创建对象。
|
||||
|
||||
下面是一个典型的Spring容器的配置文件。Spring容器读取这个配置文件,解析出要创建的两个对象:rateLimiter和redisCounter,并且得到两者的依赖关系:rateLimiter依赖redisCounter。
|
||||
|
||||
```
|
||||
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:
|
||||
<beans>
|
||||
<bean id="rateLimiter" class="com.xzg.RateLimiter">
|
||||
<constructor-arg ref="redisCounter"/>
|
||||
</bean>
|
||||
|
||||
<bean id="redisCounter" class="com.xzg.redisCounter">
|
||||
<constructor-arg type="String" value="127.0.0.1">
|
||||
<constructor-arg type="int" value=1234>
|
||||
</bean>
|
||||
</beans>
|
||||
|
||||
```
|
||||
|
||||
**其次,我们再来看对象创建。**
|
||||
|
||||
在DI容器中,如果我们给每个类都对应创建一个工厂类,那项目中类的个数会成倍增加,这会增加代码的维护成本。要解决这个问题并不难。我们只需要将所有类对象的创建都放到一个工厂类中完成就可以了,比如BeansFactory。
|
||||
|
||||
你可能会说,如果要创建的类对象非常多,BeansFactory中的代码会不会线性膨胀(代码量跟创建对象的个数成正比)呢?实际上并不会。待会讲到DI容器的具体实现的时候,我们会讲“反射”这种机制,它能在程序运行的过程中,动态地加载类、创建对象,不需要事先在代码中写死要创建哪些对象。所以,不管是创建一个对象还是十个对象,BeansFactory工厂类代码都是一样的。
|
||||
|
||||
**最后,我们来看对象的生命周期管理。**
|
||||
|
||||
上一节课我们讲到,简单工厂模式有两种实现方式,一种是每次都返回新创建的对象,另一种是每次都返回同一个事先创建好的对象,也就是所谓的单例对象。在Spring框架中,我们可以通过配置scope属性,来区分这两种不同类型的对象。scope=prototype表示返回新创建的对象,scope=singleton表示返回单例对象。
|
||||
|
||||
除此之外,我们还可以配置对象是否支持懒加载。如果lazy-init=true,对象在真正被使用到的时候(比如:BeansFactory.getBean(“userService”))才被被创建;如果lazy-init=false,对象在应用启动的时候就事先创建好。
|
||||
|
||||
不仅如此,我们还可以配置对象的init-method和destroy-method方法,比如init-method=loadProperties(),destroy-method=updateConfigFile()。DI容器在创建好对象之后,会主动调用init-method属性指定的方法来初始化对象。在对象被最终销毁之前,DI容器会主动调用destroy-method属性指定的方法来做一些清理工作,比如释放数据库连接池、关闭文件。
|
||||
|
||||
## 如何实现一个简单的DI容器?
|
||||
|
||||
实际上,用Java语言来实现一个简单的DI容器,核心逻辑只需要包括这样两个部分:配置文件解析、根据配置文件通过“反射”语法来创建对象。
|
||||
|
||||
### 1.最小原型设计
|
||||
|
||||
因为我们主要是讲解设计模式,所以,在今天的讲解中,我们只实现一个DI容器的最小原型。像Spring框架这样的DI容器,它支持的配置格式非常灵活和复杂。为了简化代码实现,重点讲解原理,在最小原型中,我们只支持下面配置文件中涉及的配置语法。
|
||||
|
||||
```
|
||||
配置文件beans.xml
|
||||
<beans>
|
||||
<bean id="rateLimiter" class="com.xzg.RateLimiter">
|
||||
<constructor-arg ref="redisCounter"/>
|
||||
</bean>
|
||||
|
||||
<bean id="redisCounter" class="com.xzg.redisCounter" scope="singleton" lazy-init="true">
|
||||
<constructor-arg type="String" value="127.0.0.1">
|
||||
<constructor-arg type="int" value=1234>
|
||||
</bean>
|
||||
</bean
|
||||
|
||||
```
|
||||
|
||||
最小原型的使用方式跟Spring框架非常类似,示例代码如下所示:
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
public static void main(String[] args) {
|
||||
ApplicationContext applicationContext = new ClassPathXmlApplicationContext(
|
||||
"beans.xml");
|
||||
RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
|
||||
rateLimiter.test();
|
||||
//...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 2.提供执行入口
|
||||
|
||||
前面我们讲到,面向对象设计的最后一步是:组装类并提供执行入口。在这里,执行入口就是一组暴露给外部使用的接口和类。
|
||||
|
||||
通过刚刚的最小原型使用示例代码,我们可以看出,执行入口主要包含两部分:ApplicationContext和ClassPathXmlApplicationContext。其中,ApplicationContext是接口,ClassPathXmlApplicationContext是接口的实现类。两个类具体实现如下所示:
|
||||
|
||||
```
|
||||
public interface ApplicationContext {
|
||||
Object getBean(String beanId);
|
||||
}
|
||||
|
||||
public class ClassPathXmlApplicationContext implements ApplicationContext {
|
||||
private BeansFactory beansFactory;
|
||||
private BeanConfigParser beanConfigParser;
|
||||
|
||||
public ClassPathXmlApplicationContext(String configLocation) {
|
||||
this.beansFactory = new BeansFactory();
|
||||
this.beanConfigParser = new XmlBeanConfigParser();
|
||||
loadBeanDefinitions(configLocation);
|
||||
}
|
||||
|
||||
private void loadBeanDefinitions(String configLocation) {
|
||||
InputStream in = null;
|
||||
try {
|
||||
in = this.getClass().getResourceAsStream("/" + configLocation);
|
||||
if (in == null) {
|
||||
throw new RuntimeException("Can not find config file: " + configLocation);
|
||||
}
|
||||
List<BeanDefinition> 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来创建对象。
|
||||
|
||||
### 3.配置文件解析
|
||||
|
||||
配置文件解析主要包含BeanConfigParser接口和XmlBeanConfigParser实现类,负责将配置文件解析为BeanDefinition结构,以便BeansFactory根据这个结构来创建对象。
|
||||
|
||||
配置文件的解析比较繁琐,不涉及我们专栏要讲的理论知识,不是我们讲解的重点,所以这里我只给出两个类的大致设计思路,并未给出具体的实现代码。如果感兴趣的话,你可以自行补充完整。具体的代码框架如下所示:
|
||||
|
||||
```
|
||||
public interface BeanConfigParser {
|
||||
List<BeanDefinition> parse(InputStream inputStream);
|
||||
List<BeanDefinition> parse(String configContent);
|
||||
}
|
||||
|
||||
public class XmlBeanConfigParser implements BeanConfigParser {
|
||||
|
||||
@Override
|
||||
public List<BeanDefinition> parse(InputStream inputStream) {
|
||||
String content = null;
|
||||
// TODO:...
|
||||
return parse(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BeanDefinition> parse(String configContent) {
|
||||
List<BeanDefinition> beanDefinitions = new ArrayList<>();
|
||||
// TODO:...
|
||||
return beanDefinitions;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public class BeanDefinition {
|
||||
private String id;
|
||||
private String className;
|
||||
private List<ConstructorArg> 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
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 4.核心工厂类设计
|
||||
|
||||
最后,我们来看,BeansFactory是如何设计和实现的。这也是我们这个DI容器最核心的一个类了。它负责根据从配置文件解析得到的BeanDefinition来创建对象。
|
||||
|
||||
如果对象的scope属性是singleton,那对象创建之后会缓存在singletonObjects这样一个map中,下次再请求此对象的时候,直接从map中取出返回,不需要重新创建。如果对象的scope属性是prototype,那每次请求对象,BeansFactory都会创建一个新的对象返回。
|
||||
|
||||
实际上,BeansFactory创建对象用到的主要技术点就是Java中的反射语法:一种动态加载类和创建对象的机制。我们知道,JVM在启动的时候会根据代码自动地加载类、创建对象。至于都要加载哪些类、创建哪些对象,这些都是在代码中写死的,或者说提前写好的。但是,如果某个对象的创建并不是写死在代码中,而是放到配置文件中,我们需要在程序运行期间,动态地根据配置文件来加载类、创建对象,那这部分工作就没法让JVM帮我们自动完成了,我们需要利用Java提供的反射语法自己去编写代码。
|
||||
|
||||
搞清楚了反射的原理,BeansFactory的代码就不难看懂了。具体代码实现如下所示:
|
||||
|
||||
```
|
||||
public class BeansFactory {
|
||||
private ConcurrentHashMap<String, Object> singletonObjects = new ConcurrentHashMap<>();
|
||||
private ConcurrentHashMap<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>();
|
||||
|
||||
public void addBeanDefinitions(List<BeanDefinition> 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<BeanDefinition.ConstructorArg> 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;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
DI容器在一些软件开发中已经成为了标配,比如Spring IOC、Google Guice。但是,大部分人可能只是把它当作一个黑盒子来使用,并未真正去了解它的底层是如何实现的。当然,如果只是做一些简单的小项目,简单会用就足够了,但是,如果我们面对的是非常复杂的系统,当系统出现问题的时候,对底层原理的掌握程度,决定了我们排查问题的能力,直接影响到我们排查问题的效率。
|
||||
|
||||
今天,我们讲解了一个简单的DI容器的实现原理,其核心逻辑主要包括:配置文件解析,以及根据配置文件通过“反射”语法来创建对象。其中,创建对象的过程就应用到了我们在学的工厂模式。对象创建、组装、管理完全有DI容器来负责,跟具体业务代码解耦,让程序员聚焦在业务代码的开发上。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
BeansFactory类中的createBean()函数是一个递归函数。当构造函数的参数是ref类型时,会递归地创建ref属性指向的对象。如果我们在配置文件中错误地配置了对象之间的依赖关系,导致存在循环依赖,那BeansFactory的createBean()函数是否会出现堆栈溢出?又该如何解决这个问题呢?
|
||||
|
||||
你可以可以在留言区说一说,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,278 @@
|
||||
<audio id="audio" title="46 | 建造者模式:详解构造函数、set方法、建造者模式三种对象创建方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/2e/29c4e8aaae344f12ee3c7090a19f4d2e.mp3"></audio>
|
||||
|
||||
上两节课中,我们学习了工厂模式,讲了工厂模式的应用场景,并带你实现了一个简单的DI容器。今天,我们再来学习另外一个比较常用的创建型设计模式,**Builder模式**,中文翻译为**建造者模式**或者**构建者模式**,也有人叫它**生成器模式**。
|
||||
|
||||
实际上,建造者模式的原理和代码实现非常简单,掌握起来并不难,难点在于应用场景。比如,你有没有考虑过这样几个问题:直接使用构造函数或者配合set方法就能创建对象,为什么还需要建造者模式来创建呢?建造者模式和工厂模式都可以创建对象,那它们两个的区别在哪里呢?
|
||||
|
||||
话不多说,带着上面两个问题,让我们开始今天的学习吧!
|
||||
|
||||
## 为什么需要建造者模式?
|
||||
|
||||
在平时的开发中,创建一个对象最常用的方式是,使用new关键字调用类的构造函数来完成。我的问题是,什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?你可以先思考一下,下面我通过一个例子来带你看一下。
|
||||
|
||||
假设有这样一道设计面试题:我们需要定义一个资源池配置类ResourcePoolConfig。这里的资源池,你可以简单理解为线程池、连接池、对象池等。在这个资源池配置类中,有以下几个成员变量,也就是可配置项。现在,请你编写代码实现这个ResourcePoolConfig类。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/21/59/21f970b7c0d6b5afa6aa09ca14f55059.jpg" alt="">
|
||||
|
||||
只要你稍微有点开发经验,那实现这样一个类对你来说并不是件难事。最常见、最容易想到的实现思路如下代码所示。因为maxTotal、maxIdle、minIdle不是必填变量,所以在创建ResourcePoolConfig对象的时候,我们通过往构造函数中,给这几个参数传递null值,来表示使用默认值。
|
||||
|
||||
```
|
||||
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。
|
||||
|
||||
```
|
||||
// 参数太多,导致可读性差、参数可能传递错误
|
||||
ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20,false, true);
|
||||
|
||||
```
|
||||
|
||||
解决这个问题的办法你应该也已经想到了,那就是用set()函数来给成员变量赋值,以替代冗长的构造函数。我们直接看代码,具体如下所示。其中,配置项name是必填的,所以我们把它放到构造函数中设置,强制创建类对象的时候就要填写。其他配置项maxTotal、maxIdle、minIdle都不是必填的,所以我们通过set()函数来设置,让使用者自主选择填写或者不填写。
|
||||
|
||||
```
|
||||
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类该如何使用。我写了一个示例代码,如下所示。没有了冗长的函数调用和参数列表,代码在可读性和易用性上提高了很多。
|
||||
|
||||
```
|
||||
// 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()方法,这样我们创建出来的对象就是不可变对象了。
|
||||
|
||||
我们用建造者模式重新实现了上面的需求,具体的代码如下所示:
|
||||
|
||||
```
|
||||
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之后,对象处于无效状态。具体代码如下所示:
|
||||
|
||||
```
|
||||
Rectangle r = new Rectange(); // r is invalid
|
||||
r.setWidth(2); // r is invalid
|
||||
r.setHeight(3); // r is valid
|
||||
|
||||
```
|
||||
|
||||
为了避免这种无效状态的存在,我们就需要使用构造函数一次性初始化好所有的成员变量。如果构造函数参数过多,我们就需要考虑使用建造者模式,先设置建造者的变量,然后再一次性地创建对象,让对象一直处于有效状态。
|
||||
|
||||
实际上,如果我们并不是很关心对象是否有短暂的无效状态,也不是太在意对象是否是可变的。比如,对象只是用来映射数据库读出来的数据,那我们直接暴露set()方法来设置类的成员变量值是完全没问题的。而且,使用建造者模式来构建对象,代码实际上是有点重复的,ResourcePoolConfig类中的成员变量,要在Builder类中重新再定义一遍。
|
||||
|
||||
## 与工厂模式有何区别?
|
||||
|
||||
从上面的讲解中,我们可以看出,建造者模式是让建造者类来负责对象的创建工作。上一节课中讲到的工厂模式,是由工厂类来负责对象创建的工作。那它们之间有什么区别呢?
|
||||
|
||||
实际上,工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。
|
||||
|
||||
网上有一个经典的例子很好地解释了两者的区别。
|
||||
|
||||
顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。
|
||||
|
||||
实际上,我们也不要太学院派,非得把工厂模式、建造者模式分得那么清楚,我们需要知道的是,每个模式为什么这么设计,能解决什么问题。**只有了解了这些最本质的东西,我们才能不生搬硬套,才能灵活应用,甚至可以混用各种模式创造出新的模式,来解决特定场景的问题。**
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
建造者模式的原理和实现比较简单,重点是掌握应用场景,避免过度使用。
|
||||
|
||||
如果一个类中有很多属性,为了避免构造函数的参数列表过长,影响代码的可读性和易用性,我们可以通过构造函数配合set()方法来解决。但是,如果存在下面情况中的任意一种,我们就要考虑使用建造者模式了。
|
||||
|
||||
- 我们把类的必填属性放到构造函数中,强制创建对象的时候就设置。如果必填的属性有很多,把这些必填属性都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填属性通过set()方法设置,那校验这些必填属性是否已经填写的逻辑就无处安放了。
|
||||
- 如果类的属性之间有一定的依赖关系或者约束条件,我们继续使用构造函数配合set()方法的设计思路,那这些依赖关系或约束条件的校验逻辑就无处安放了。
|
||||
- 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,我们就不能在类中暴露set()方法。构造函数配合set()方法来设置属性值的方式就不适用了。
|
||||
|
||||
除此之外,在今天的讲解中,我们还对比了工厂模式和建造者模式的区别。工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,可以通过设置不同的可选参数,“定制化”地创建不同的对象。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在下面的ConstructorArg类中,当isRef为true的时候,arg表示String类型的refBeanId,type不需要设置;当isRef为false的时候,arg、type都需要设置。请根据这个需求,完善ConstructorArg类。
|
||||
|
||||
```
|
||||
public class ConstructorArg {
|
||||
private boolean isRef;
|
||||
private Class type;
|
||||
private Object arg;
|
||||
// TODO: 待完善...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎留言和我分享你的想法,如果有收获,你也可以把这篇文章分享给你的朋友。
|
||||
312
极客时间专栏/设计模式之美/设计模式与范式:创建型/47 | 原型模式:如何最快速地clone一个HashMap散列表?.md
Normal file
312
极客时间专栏/设计模式之美/设计模式与范式:创建型/47 | 原型模式:如何最快速地clone一个HashMap散列表?.md
Normal file
@@ -0,0 +1,312 @@
|
||||
<audio id="audio" title="47 | 原型模式:如何最快速地clone一个HashMap散列表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/46/7ca5582641d1d9f4e672785e11e00f46.mp3"></audio>
|
||||
|
||||
对于创建型模式,前面我们已经讲了单例模式、工厂模式、建造者模式,今天我们来讲最后一个:原型模式。
|
||||
|
||||
对于熟悉JavaScript语言的前端程序员来说,原型模式是一种比较常用的开发模式。这是因为,有别于Java、C++等基于类的面向对象编程语言,JavaScript是一种基于原型的面向对象编程语言。即便JavaScript现在也引入了类的概念,但它也只是基于原型的语法糖而已。不过,如果你熟悉的是Java、C++等这些编程语言,那在实际的开发中,就很少用到原型模式了。
|
||||
|
||||
今天的讲解跟具体某一语言的语法机制无关,而是通过一个clone散列表的例子带你搞清楚:原型模式的应用场景,以及它的两种实现方式:深拷贝和浅拷贝。虽然原型模式的原理和代码实现非常简单,但今天举的例子还是稍微有点复杂的,你要跟上我的思路,多动脑思考一下。
|
||||
|
||||
话不多说,让我们正式开始今天的学习吧!
|
||||
|
||||
## 原型模式的原理与应用
|
||||
|
||||
如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作**原型设计模式**(Prototype Design Pattern),简称**原型模式**。
|
||||
|
||||
**那何为“对象的创建成本比较大”?**
|
||||
|
||||
实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。
|
||||
|
||||
但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从RPC、网络、数据库、文件系统等非常慢速的IO中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作。
|
||||
|
||||
**这么说还是比较理论,接下来,我们通过一个例子来解释一下刚刚这段话。**
|
||||
|
||||
假设数据库中存储了大约10万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统A在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引。
|
||||
|
||||
如果你熟悉的是Java语言,可以直接使用语言中提供的HashMap容器来实现。其中,HashMap的key为搜索关键词,value为关键词详细信息(比如搜索次数)。我们只需要将数据从数据库中读取出来,放入HashMap就可以了。
|
||||
|
||||
不过,我们还有另外一个系统B,专门用来分析搜索日志,定期(比如间隔10分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对v2版本的数据进行更新,得到v3版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/0e/05233c28ddda51b81dfd946ffdef640e.jpg" alt="">
|
||||
|
||||
为了保证系统A中数据的实时性(不一定非常实时,但数据也不能太旧),系统A需要定期根据数据库中的数据,更新内存中的索引和数据。
|
||||
|
||||
我们该如何实现这个需求呢?
|
||||
|
||||
实际上,也不难。我们只需要在系统A中,记录当前数据的版本Va对应的更新时间Ta,从数据库中捞出更新时间大于Ta的所有搜索关键词,也就是找出Va版本与最新版本数据的“差集”,然后针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,我们就将它插入到散列表中。
|
||||
|
||||
按照这个设计思路,我给出的示例代码如下所示:
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>();
|
||||
private long lastUpdateTime = -1;
|
||||
|
||||
public void refresh() {
|
||||
// 从数据库中取出更新时间>lastUpdateTime的数据,放入到currentKeywords中
|
||||
List<SearchWord> 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<SearchWord> getSearchWords(long lastUpdateTime) {
|
||||
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,现在,我们有一个特殊的要求:任何时刻,系统A中的所有数据都必须是同一个版本的,要么都是版本a,要么都是版本b,不能有的是版本a,有的是版本b。那刚刚的更新方式就不能满足这个要求了。除此之外,我们还要求:在更新内存数据的时候,系统A不能处于不可用状态,也就是不能停机更新数据。
|
||||
|
||||
那我们该如何实现现在这个需求呢?
|
||||
|
||||
实际上,也不难。我们把正在使用的数据的版本定义为“服务版本”,当我们要更新内存中的数据的时候,我们并不是直接在服务版本(假设是版本a数据)上更新,而是重新创建另一个版本数据(假设是版本b数据),等新的版本数据建好之后,再一次性地将服务版本从版本a切换到版本b。这样既保证了数据一直可用,又避免了中间状态的存在。
|
||||
|
||||
按照这个设计思路,我给出的示例代码如下所示:
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
|
||||
|
||||
public void refresh() {
|
||||
HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();
|
||||
|
||||
// 从数据库中取出所有的数据,放入到newKeywords中
|
||||
List<SearchWord> toBeUpdatedSearchWords = getSearchWords();
|
||||
for (SearchWord searchWord : toBeUpdatedSearchWords) {
|
||||
newKeywords.put(searchWord.getKeyword(), searchWord);
|
||||
}
|
||||
|
||||
currentKeywords = newKeywords;
|
||||
}
|
||||
|
||||
private List<SearchWord> getSearchWords() {
|
||||
// TODO: 从数据库中取出所有的数据
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,在上面的代码实现中,newKeywords构建的成本比较高。我们需要将这10万条数据从数据库中读出,然后计算哈希值,构建newKeywords。这个过程显然是比较耗时。为了提高效率,原型模式就派上用场了。
|
||||
|
||||
我们拷贝currentKeywords数据到newKeywords中,然后从数据库中只捞出新增或者有更新的关键词,更新到newKeywords中。而相对于10万条数据来说,每次新增或者更新的关键词个数是比较少的,所以,这种策略大大提高了数据更新的效率。
|
||||
|
||||
按照这个设计思路,我给出的示例代码如下所示:
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
|
||||
private long lastUpdateTime = -1;
|
||||
|
||||
public void refresh() {
|
||||
// 原型模式就这么简单,拷贝已有对象的数据,更新少量差值
|
||||
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
|
||||
|
||||
// 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
|
||||
List<SearchWord> 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<SearchWord> getSearchWords(long lastUpdateTime) {
|
||||
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我们利用了Java中的clone()语法来复制一个对象。如果你熟悉的语言没有这个语法,那把数据从currentKeywords中一个个取出来,然后再重新计算哈希值,放入到newKeywords中也是可以接受的。毕竟,最耗时的还是从数据库中取数据的操作。相对于数据库的IO操作来说,内存操作和CPU计算的耗时都是可以忽略的。
|
||||
|
||||
不过,不知道你有没有发现,实际上,刚刚的代码实现是有问题的。要弄明白到底有什么问题,我们需要先了解另外两个概念:深拷贝(Deep Copy)和浅拷贝(Shallow Copy)。
|
||||
|
||||
## 原型模式的实现方式:深拷贝和浅拷贝
|
||||
|
||||
我们来看,在内存中,用散列表组织的搜索关键词信息是如何存储的。我画了一张示意图,大致结构如下所示。从图中我们可以发现,散列表索引中,每个结点存储的key是搜索关键词,value是SearchWord对象的内存地址。SearchWord对象本身存储在散列表之外的内存空间中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/d2/f5ba85952b27a569687e2d44352216d2.jpg" alt="">
|
||||
|
||||
浅拷贝和深拷贝的区别在于,浅拷贝只会复制图中的索引(散列表),不会复制数据(SearchWord对象)本身。相反,深拷贝不仅仅会复制索引,还会复制数据本身。浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord对象),而深拷贝得到的是一份完完全全独立的对象。具体的对比如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/82/74bceb7a0736957daaa4abeba6826182.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/1b/b978d054ab3183b9e0ae461e6abac81b.jpg" alt="">
|
||||
|
||||
在Java语言中,Object类的clone()方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身。
|
||||
|
||||
在上面的代码中,我们通过调用HashMap上的clone()浅拷贝方法来实现原型模式。当我们通过newKeywords更新SearchWord对象的时候(比如,更新“设计模式”这个搜索关键词的访问次数),newKeywords和currentKeywords因为指向相同的一组SearchWord对象,就会导致currentKeywords中指向的SearchWord,有的是老版本的,有的是新版本的,就没法满足我们之前的需求:currentKeywords中的数据在任何时刻都是同一个版本的,不存在介于老版本与新版本之间的中间状态。
|
||||
|
||||
现在,我们又该如何来解决这个问题呢?
|
||||
|
||||
我们可以将浅拷贝替换为深拷贝。newKeywords不仅仅复制currentKeywords的索引,还把SearchWord对象也复制一份出来,这样newKeywords和currentKeywords就指向不同的SearchWord对象,也就不存在更新newKeywords的数据会导致currentKeywords的数据也被更新的问题了。
|
||||
|
||||
那如何实现深拷贝呢?总结一下的话,有下面两种方法。
|
||||
|
||||
第一种方法:递归拷贝对象、对象的引用对象以及引用对象的引用对象……直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止。根据这个思路对之前的代码进行重构。重构之后的代码如下所示:
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
|
||||
private long lastUpdateTime = -1;
|
||||
|
||||
public void refresh() {
|
||||
// Deep copy
|
||||
HashMap<String, SearchWord> newKeywords = new HashMap<>();
|
||||
for (HashMap.Entry<String, SearchWord> 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<SearchWord> 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<SearchWord> getSearchWords(long lastUpdateTime) {
|
||||
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二种方法:先将对象序列化,然后再反序列化成新的对象。具体的示例代码如下所示:
|
||||
|
||||
```
|
||||
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();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
刚刚的两种实现方法,不管采用哪种,深拷贝都要比浅拷贝耗时、耗内存空间。针对我们这个应用场景,有没有更快、更省内存的实现方式呢?
|
||||
|
||||
我们可以先采用浅拷贝的方式创建newKeywords。对于需要更新的SearchWord对象,我们再使用深度拷贝的方式创建一份新的对象,替换newKeywords中的老对象。毕竟需要更新的数据是很少的。这种方式即利用了浅拷贝节省时间、空间的优点,又能保证currentKeywords中的中数据都是老版本的数据。具体的代码实现如下所示。这也是标题中讲到的,在我们这个应用场景下,最快速clone散列表的方式。
|
||||
|
||||
```
|
||||
public class Demo {
|
||||
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
|
||||
private long lastUpdateTime = -1;
|
||||
|
||||
public void refresh() {
|
||||
// Shallow copy
|
||||
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();
|
||||
|
||||
// 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
|
||||
List<SearchWord> 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<SearchWord> getSearchWords(long lastUpdateTime) {
|
||||
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
|
||||
|
||||
**1.什么是原型模式?**
|
||||
|
||||
如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式,来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式,简称原型模式。
|
||||
|
||||
**2.原型模式的两种实现方法**
|
||||
|
||||
原型模式有两种实现方法,深拷贝和浅拷贝。浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象……而深拷贝得到的是一份完完全全独立的对象。所以,深拷贝比起浅拷贝来说,更加耗时,更加耗内存空间。
|
||||
|
||||
如果要拷贝的对象是不可变对象,浅拷贝共享不可变对象是没问题的,但对于可变对象来说,浅拷贝得到的对象和原始对象会共享部分数据,就有可能出现数据被修改的风险,也就变得复杂多了。除非像我们今天实战中举的那个例子,需要从数据库中加载10万条数据并构建散列表索引,操作非常耗时,这种情况下比较推荐使用浅拷贝,否则,没有充分的理由,不要为了一点点的性能提升而使用浅拷贝。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 在今天的应用场景中,如果不仅往数据库中添加和更新关键词,还删除关键词,这种情况下,又该如何实现呢?
|
||||
1. 在[第7讲](https://time.geekbang.org/column/article/164907)中,为了让ShoppingCart的getItems()方法返回不可变对象,我们如下来实现代码。当时,我们指出这样的实现思路还是有点问题。因为当调用者通过ShoppingCart的getItems()获取到items之后,我们还是可以修改容器中每个对象(ShoppingCartItem)的数据。学完本节课之后,现在你有没有解决方法了呢?
|
||||
|
||||
```
|
||||
public class ShoppingCart {
|
||||
// ...省略其他代码...
|
||||
public List<ShoppingCartItem> getItems() {
|
||||
return Collections.unmodifiableList(this.items);
|
||||
}
|
||||
}
|
||||
|
||||
// Testing Code in main method:
|
||||
ShoppingCart cart = new ShoppingCart();
|
||||
List<ShoppingCartItem> items = cart.getItems();
|
||||
items.clear();//try to modify the list
|
||||
// Exception in thread "main" java.lang.UnsupportedOperationExceptio
|
||||
|
||||
ShoppingCart cart = new ShoppingCart();
|
||||
cart.add(new ShoppingCartItem(...));
|
||||
List<ShoppingCartItem> items = cart.getItems();
|
||||
ShoppingCartItem item = items.get(0);
|
||||
item.setPrice(19.0); // 这里修改了item的价格属性
|
||||
|
||||
```
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,如果有收获,也欢迎你把这篇文章分享给你的朋友。
|
||||
Reference in New Issue
Block a user