创建型:单例模式

2021-12-26 Golang Go 进阶

# 一、为什么要使用单例

# 1.1 单例的定义

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

# 1.2 单例的用处

从业务概念上,有些数据在系统中 只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。

除此之外,我们还可以使用单例 解决资源访问冲突 的问题。

# 1.3 单例的实现

概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:

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

具体来说,单例有下面几种经典的实现方式。

饿汉式

饿汉式的实现方式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。

package singleton

// Singleton 饿汉式单例
type Singleton struct{}

var singleton *Singleton

func init() {
	singleton = &Singleton{}
}

// GetInstance 获取实例
func GetInstance() *Singleton {
	return singleton
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

懒汉式(双重检测)

懒汉式相对于饿汉式的优势是支持延迟加载,同时双重检测可以保证只要 instance 被创建,之后再调用 getInstance() 函数都不会进入到加锁逻辑中。所以,这是一种既支持延迟加载、又支持高并发的单例实现方式。

package singleton

import "sync"

// LazySingleton 懒汉式单例
type LazySingleton struct{}

var (
	lazySingleton *LazySingleton
	once          sync.Once
)

// GetLazyInstance 获取实例
func GetLazyInstance() *LazySingleton {
	if lazySingleton == nil {
		once.Do(func() {
			lazySingleton = &LazySingleton{}
		})
	}
	return lazySingleton
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 二、单例存在哪些问题

# 2.1 对 OOP 特性支持不友好

OOP 的四大特性是封装、抽象、继承、多态。单例的使用方式违背了基于接口而非实现的设计原则,即违背了广义上理解的 OOP 的抽象特性。

此外,单例对继承、多态特性的支持也不友好。虽然单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

# 2.2 会隐藏类之间依赖关系

由于单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以使用。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

# 2.3 对代码的扩展性不友好

我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。

以数据库连接池为例,在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。

但之后我们如果想要将慢 SQL 和其他 SQL 分离执行,就需要两个连接池了,而单例显然无法满足这样的需求变更。也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。

# 2.4 对代码可测试性不友好

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

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

# 2.5 不支持有参的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。

# 三、单例模式的唯一性

单例类中对象的唯一性的作用范围是 进程唯一 的。

  • 进程唯一指的是:进程内唯一,进程间不唯一;
  • 线程唯一指的是:线程内唯一,线程间不唯一;
    • 实际上,进程唯一就意味着线程内、线程间都唯一,这也是进程唯一和线程唯一的区别之处。
  • 集群唯一指的是:进程内唯一、进程间也唯一。

# 3.1 线程唯一的单例

我们通过一个 Map 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。

注意

Golang 中不推荐使用,Golang 主要使用协程,而协程 ID 默认并没有暴露出来。

# 3.2 集群环境下的单例

我们需要把这个单例对象 序列化并存储到外部共享存储区(比如文件)

进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。

为了保证任何时刻在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,需要显式地将对象从内存中删除,并且释放对对象的加锁。

# 四、多例模式的实现

“单例”指的是一个类只能创建一个对象。对应地,“多例”指的就是一个类可以创建多个对象,但是个数是有限制的。

多例的实现也比较简单,通过一个 Map 来存储对象类型和对象之间的对应关系,即同一类型的只能创建一个对象,不同类型的可以创建多个对象。这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象

# 五、参考资料

Last Updated: 2023-01-28 4:31:25