设计模式之美总结(创建型篇)

前三篇见:

1. 单例模式(Singleton Design Pattern)

1.1 为什么要使用单例?

单例设计模式(Singleton Design Pattern) 理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式

1.1.1 处理资源访问冲突

如下例,自定义实现了一个往文件中打印日志的 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 文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况

在这里插入图片描述

那如何来解决这个问题呢?最先想到的就是通过加锁的方式:给 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() 函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题

在这里插入图片描述

还有,给 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 {
    private Logger logger = new Logger();
    public void create(OrderVo order) {
        // ...省略业务逻辑代码...
        Logger.getInstance().log("Created a order: " + order.toString());
    }
}

1.1.2 表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类,比如,配置信息类。在系统中,只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份

再比如,唯一递增 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();

1.2 如何实现一个单例?

要实现一个单例,需要关注的点无外乎下面几个:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例
  • 考虑对象创建时的线程安全问题
  • 考虑是否支持延迟加载
  • 考虑 getInstance() 性能是否高(是否加锁)

1.1.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),可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性

1.1.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,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了

1.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 操作和初始化操作设计为原子操作,就自然能禁止重排序)

1.1.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。insance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载

1.1.5 枚举

这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如
下所示:

public enum IdGenerator {
    INSTANCE;
    private AtomicLong id = new AtomicLong(0);
    public long getId() {
        return id.incrementAndGet();
    }
}

1.3 单例存在哪些问题?

尽管单例是一个很常用的设计模式,在实际的开发中,也确实经常用到它,但是,有些人认为单例是一种反模式(anti-pattern),并不推荐使用

大部分情况下,在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID 生成器类。单例模式书写简洁、使用方便,在代码中,不需要创建对象,直接通过类似 IdGenerator.getInstance().getId() 这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题

1.3.1 单例对 OOP 特性的支持不友好

OOP 的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好,如下例:

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();
    }
}

除此之外,单例对继承、多态特性的支持也不友好。这里之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性

1.3.2 单例会隐藏类之间的依赖关系

代码的可读性非常重要。在阅读代码的时候,希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类

1.3.3 单例对代码的扩展性不友好

单例类只能有一个对象实例。如果未来某一天,需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。这时可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,把数据库连接池类设计成了单例类。但之后发现,系统中有些 SQL 语句运行得非常慢。这些 SQL 语句在执行的时候,长时间占用数据库连接资源,导致其他 SQL 请求无法响应。为了解决这个问题,希望将慢 SQL 与其他 SQL 隔离开来执行。为了实现这样的目的,可以在系统中创建两个数据库连接池,慢 SQL 独享一个数据库连接池,其他 SQL 独享另外一个数据库连接池,这样就能避免慢 SQL 影响到其他 SQL 的执行

如果将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类

1.3.4 单例对代码的可测试性不友好

单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如 DB,在写单元测试的时候,希望能通过 mock 的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现 mock 替换

除此之外,如果单例类持有成员变量(比如 IdGenerator 中的 id 成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题

1.3.5 单例不支持有参数的构造函数

单例不支持有参数的构造函数,比如创建一个连接池的单例对象,没法通过参数来指定连接池的大小。针对这个问题,可以来看下都有哪些解决方案:

1、创建完实例之后,再调用 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();

2、将参数放到 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);

3、将参数放到另外一个全局变量中

具体的代码实现如下。Config 是一个存储了 paramA 和 paramB 值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的

public class Config {
    public static final int PARAM_A = 123;
    public static fianl 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;
    }
}

1.4 有何替代解决方案?

如果不用单例,怎么才能保证这个类的对象全局唯一呢?为了保证全局唯一,除了使用单例,还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如,上面讲到的 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++ 中由程序员自己负责,道理是一样的

1.5 如何理解单例模式中的唯一性?

首先看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。”

定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的

我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是平时所说的“可执行文件”(比如 Windows 下的 exe 文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,完全可以简单地理解为就是代码本身

当使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象

进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个 fork() 语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如 user 临时变量、User 对象)。所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的

1.6 如何实现线程唯一的单例?

“进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一

线程唯一单例的代码实现很简单,如下所示。在代码中,通过一个 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();
	}
}

1.7 如何实现集群环境下的单例?

“进程唯一”指的是进程内唯一、进程间不唯一。“线程唯一”指的是线程内唯一、线程间不唯一。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象

如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来就有点难度了。具体来说,需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。实现如下:

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();

1.7 如何实现一个多例模式?

“单例”指的是,一个类只能创建一个对象。对应地,“多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建 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);
	}
}

实际上,对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。这里的“类型”如何理解呢?

如下例,在代码中,loggerName 就是刚刚说的“类型”,同一个 loggerName 获取到的对象实例是相同的,不同的 loggerName 获取到的对象实例是不同的

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");

这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象,实际上,它还有点类似享元模式。除此之外,实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象

2. 工厂模式(Factory Design Pattern)

一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在 GoF 的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。实际上,前面一种分类方法更加常见,所以,这里沿用第一种分类方法

在这三种细分的工厂模式中,简单工厂、工厂方法原理比较简单,在实际的项目中也比较常用。而抽象工厂的原理稍微复杂点,在实际的项目中相对也不常用。因此,重点是前两种工厂模式,搞清楚应用场景

  • 什么时候该用工厂模式?
  • 相对于直接 new 来创建对象,用工厂模式来创建究竟有什么好处呢?

2.1 简单工厂(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 class RuleConfigSource {
	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(ruleConfig);
		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 分支判断逻辑,也并不是没有任何缺点的,它虽然提高了代码的扩展性,更加符合开闭原则,但也增加了类的个数,牺牲了代码的可读性

2.2 工厂方法(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 support");
		}
		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 support");
		}
		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 操作,功能非常单薄(只有一行代码),也没必要设计成独立的类,所以,在这个应用场景下,简单工厂模式简单好用,比工厂方法模式更加合适

2.3 什么时候该用工厂方法模式,而非简单工厂模式呢?

前面提到,之所以将某个代码块剥离出来,独立为函数或者类,原因是这个代码块的逻辑过于复杂,剥离之后能让代码更加清晰,更加可读、可维护。但是,如果代码块本身并不复杂,就几行代码而已,完全没必要将它拆分成单独的函数或者类

基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的 new 一下就可以,而是要组合其他类对象,做各种初始化操作的时候,推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂

除此之外,在某些场景下,如果对象不可复用,那工厂类每次都要返回不同的对象。如果使用简单工厂模式来实现,就只能选择第一种包含 if 分支逻辑的实现方式。如果还想避免烦人的 if-else 分支逻辑,这个时候,就推荐使用工厂方法模式

  • 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明
  • 代码复用:创建代码抽离到独立的工厂类之后可以复用
  • 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象
  • 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁

2.4 抽象工厂(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代码

2.5 如何设计实现一个 Dependency Injection 框架?

2.5.1 工厂模式和 DI 容器有何区别?

实际上,DI 容器底层最基本的设计思路就是基于工厂模式的。DI 容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”

DI 容器相对于工厂模式来说,它处理的是更大的对象创建工程。上面讲的工厂模式中,一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建,而 DI 容器负责的是整个应用中所有类对象的创建

除此之外,DI 容器负责的事情要比单纯的工厂模式要多。比如,它还包括配置的解析、对象生命周期的管理

2.5.2 DI 容器的核心功能有哪些?

总结一下,一个简单的 DI 容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理

1、配置解析

在上面讲的工厂模式中,工厂类要创建哪个类对象是事先确定好的,并且是写死在工厂类代码中的。作为一个通用的框架来说,框架代码跟应用代码应该是高度解耦的,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>

2、对象创建

在 DI 容器中,如果给每个类都对应创建一个工厂类,那项目中类的个数会成倍增加,这会增加代码的维护成本。要解决这个问题并不难,只需要将所有类对象的创建都放到一个工厂类中完成就可以了,比如 BeansFactory。运用“反射”这种机制,在程序运行的过程中,动态地加载类、创建对象,不需要事先在代码中写死要创建哪些对象。所以,不管是创建一个对象还是十个对象,BeansFactory 工厂类代码都是一样的

3、对象的生命周期管理

简单工厂模式有两种实现方式,一种是每次都返回新创建的对象,另一种是每次都返回同一个事先创建好的对象,也就是所谓的单例对象。在 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 属性指定的方法来做一些清理工作,比如释放数据库连接池、关闭文件

2.5.3 最小原型设计

实际上,用 Java 语言来实现一个简单的 DI 容器,核心逻辑只需要包括这样两个部分:配置文件解析、根据配置文件通过“反射”语法来创建对象

这里只实现一个 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">
		<constructor-arg type="String" value="127.0.0.1">
		<constructor-arg type="int" value=1234>
	</bean>
</beans>

最小原型的使用方式跟 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.5.4 提供执行入口

面向对象设计的最后一步是:组装类并提供执行入口。在这里,执行入口就是一组暴露给外部使用的接口和类

通过刚刚的最小原型使用示例代码可以看出,执行入口主要包含两部分: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: " + configLocatio
			}
			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 来创建对象

2.5.5 配置文件解析

配置文件解析主要包含 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
	}
}

2.5.6 核心工厂类设计

最后来看 BeansFactory 是如何设计和实现的。这也是我们这个 DI 容器最核心的一个类了。它负责根据从配置文件解析得到的 BeanDefinition 来创建对象

如果对象的 scope 属性是 singleton,那对象创建之后会缓存在 singletonObjects 这样一个 map 中,下次再请求此对象的时候,直接从 map 中取出返回,不需要重新创建。如果对象的 scope 属性是 prototype,那每次请求对象,BeansFactory 都会创建一个新的对象返回

实际上,BeansFactory 创建对象用到的主要技术点就是 Java 中的反射语法:一种动态加载类和创建对象的机制。JVM 在启动的时候会根据代码自动地加载类、创建对象。至于都要加载哪些类、创建哪些对象,这些都是在代码中写死的,或者说提前写好的。但是,如果某个对象的创建并不是写死在代码中,而是放到配置文件中,我们需要在程序运行期间,动态地根据配置文件来加载类、创建对象,那这部分工作就没法让 JVM 自动完成了,需要利用 Java 提供的反射语法自己去编写代码

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)) {
			return singletonObjects.get(beanDefinition.getId());
		}
		Object bean = null;
		try {
			Class beanClass = Class.forName(beanDefinition.getClassName());
			List<BeanDefinition.ConstructorArg> args = beanDefinition.getConstructorA
			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: ");
						}
						argClasses[i] = Class.forName(refBeanDefinition.getClassName());
						argObjects[i] = createBean(refBeanDefinition);
					}
				}
				bean = beanClass.getConstructor(argClasses).newInstance(argObjects);
			}
		} catch (ClassNotFoundException | IllegalAccessException) {
			| InstantiationException | NoSuchMethodException | InvocationTarget
			throw new BeanCreationFailureException("", e);
		}
		if (bean != null && beanDefinition.isSingleton()) {
			singletonObjects.putIfAbsent(beanDefinition.getId(), bean);
			return singletonObjects.get(beanDefinition.getId());
		}
		return bean;
	}
}

3. 建造者/构建者/生成器模式(Builder Design Pattern)

3.1 为什么需要建造者模式?

在平时的开发中,创建一个对象最常用的方式是,使用 new 关键字调用类的构造函数来完成。但什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?如下例:

假设有这样一道设计面试题:需要定义一个资源池配置类 ResourcePoolConfig。这里的资源池,可以简单理解为线程池、连接池、对象池等。在这个资源池配置类中,有以下几个成员变量,也就是可配置项。现在,请编写代码实现这个 ResourcePoolConfig 类

在这里插入图片描述

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, Int 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

解决这个问题的办法就是用 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() 方法设置可选配置项,就能实现我们的设计需求。如果把问题的难度再加大点,比如,还需要解决下面这三个问题,那现在的设计思路就不能满足了

  1. 刚刚讲到,name 是必填的,所以把它放到构造函数中,强制创建对象的时候就设置。如果必填的配置项有很多,把这些必填配置项都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果把必填项也通过 set() 方法设置,那校验这些必填项是否已经填写的逻辑就无处安放了
  2. 除此之外,假设配置项之间有一定的依赖关系,比如,如果用户设置了 maxTotal、maxIdle、minIdle 其中一个,就必须显式地设置另外两个;或者配置项之间有一定的约束条件,比如,maxIdle 和 minIdle 要小于等于 maxTotal。如果继续使用现在的设计思路,那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了
  3. 如果希望 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 类中重新再定义一遍

3.2 与工厂模式有何区别?

建造者模式是让建造者类来负责对象的创建工作。工厂模式是由工厂类来负责对象创建的工作。那它们之间有什么区别呢?

实际上,工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象

有一个经典的例子很好地解释了两者的区别

顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨

实际上,也不要太学院派,非得把工厂模式、建造者模式分得那么清楚,我们需要知道的是,每个模式为什么这么设计,能解决什么问题。只有了解了这些最本质的东西,我们才能不生搬硬套,才能灵活应用,甚至可以混用各种模式创造出新的模式,来解决特定场景的问题

4. 原型模式(Prototype Design Pattern)

对于熟悉 JavaScript 语言的前端程序员来说,原型模式是一种比较常用的开发模式。这是因为,有别于 Java、C++ 等基于类的面向对象编程语言,JavaScript 是一种基于原型的面向对象编程语言。即便 JavaScript 现在也引入了类的概念,但它也只是基于原型的语法糖而已。不过,如果熟悉的是 Java、C++ 等这些编程语言,那在实际的开发中,就很少用到原型模式了

4.1 原型模式的原理与应用

如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式(Prototype Design Pattern),简称原型模式

何为“对象的创建成本比较大”?

实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失

但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从 RPC、网络、数据库、文件系统等非常慢速的 IO 中读取,这种情况下,就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作

假设数据库中存储了大约 10 万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统 A 在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,给关键词建立一个散列表索引

如果熟悉的是 Java 语言,可以直接使用语言中提供的 HashMap 容器来实现。其中,HashMap 的 key 为搜索关键词,value 为关键词详细信息(比如搜索次数)。只需要将数据从数据库中读取出来,放入 HashMap 就可以了

不过,还有另外一个系统 B,专门用来分析搜索日志,定期(比如间隔 10 分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,对 v2 版本的数据进行更新,得到 v3 版本的数据。这里假设只有更新和新添关键词,没有删除关键词的行为

在这里插入图片描述

为了保证系统 A 中数据的实时性(不一定非常实时,但数据也不能太旧),系统 A 需要定期根据数据库中的数据,更新内存中的索引和数据。该如何实现这个需求呢?

实际上,也不难。只需要在系统 A 中,记录当前数据的版本 Va 对应的更新时间 Ta,从数据库中捞出更新时间大于 Ta 的所有搜索关键词,也就是找出 Va 版本与最新版本数据的“差集”,然后针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,就将它插入到散列表中

public class Demo {
	private ConcurrentHashMap<String, SearchWord> currentKeywords = new Concurren
	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)

4.2 原型模式的实现方式:深拷贝和浅拷贝

先来看,在内存中,用散列表组织的搜索关键词信息是如何存储的。大致结构如下图所示,从图中可以发现,散列表索引中,每个结点存储的 key 是搜索关键词,value 是 SearchWord 对象的内存地址。SearchWord 对象本身存储在散列表之外的内存空间中

在这里插入图片描述

浅拷贝和深拷贝的区别在于,浅拷贝只会复制图中的索引(散列表),不会复制数据(SearchWord 对象)本身。相反,深拷贝不仅仅会复制索引,还会复制数据本身。浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord 对象),而深拷贝得到的是一份完完全全独立的对象。具体的对比如下图所示:

在这里插入图片描述

在这里插入图片描述

在 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.getLas
			    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 更新 SearchWord 对象之后再替换为原先 currentKeywords 中的老对象。毕竟需要更新的数据是很少的。这种方式即利用了浅拷贝节省时间、空间的优点,又能保证 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;
	}
}
posted @ 2022-12-21 14:41  凡223  阅读(11)  评论(0编辑  收藏  举报