单例模式实践 Practice in Singleton Pat
场景说明(Case)
系统中通常存在用户个性化设置数据,这类数据需要持久化存储,用户再次进入系统中将数据加载回来。
比如在我们的系统的报表页面中,用户可以自由设置每页显示多少条数据,当表格的列很多时,可以设置默认展示哪些列。
{
list: {
pageSize: 50, // 每页展示50条
columns: ['name', 'count', 'comment'] // 默认展示这三列
}
}
为此,我们建立了一个userSettingsService类, 这个类主要职责有三个:
- 初始化设置。在系统初始化时,从服务器读取该用户所有的settings数据,缓存在类中;
- 获取设置。接收一个path(如:"itemList.pageSize"), 返回该path对应的值(如:50);
- 修改设置。接收一个path和这个path下的最新值,更新内存中的settings.path, 并调用API存到数据库中。
import { get, set } from "lodash"
class UserSettingsService {
private userSettings = {};
public async load(){
await fetch('xxx').then(settings => this.userSettings = settings)
}
public getConfigByPath(path: string) {
return get(this.userConfig, path);
}
public saveConfigByPath(path: string, value: any) {
set(this.userConfig, path, value);
// send http request to save lates config data
}
}
// Client
const settingsService = new UserSettingsService();
await settingsService.load();
settingsService.getConfigByPath("itemList");
问题分析
上述做法,存在两个问题:
- 每次遇到要使用UserSettingsService的时候,都需要new一个新的实例,这样将导致:
1.1 资源浪费:创建每一个实例都需要占据时间和空间,而然我们根本不需要那么多个实例,就用户设置数据而言,它是整个系统全局持有的。
1.2 数据不一致:如果在A组件将设置数据改动了,然而,由于A和B使用的不是同一个实例,将导致B组件获取到脏数据。 - load函数需要用户手动调用,如果用户不了解其细节,不调用Load却直接调用getConfigByPath将会导致程序出错。
重构步骤
通过问题分析可知,重构目标包括两个:
- 保证整个系统中只存在一个UserSettingsService的实例;
- new UserSettingsService的实例以及执行load函数都应该只被做一遍,他们是不可分割的,load应该在new UserSettingsService的时候一起执行,而不是由Client手动调用;
要实现目标,单例模式当仁不让的出场选手。一下是具体的实现步骤:
- 首先,要防止客户端通过new来创建实例:
class UserSettingsService {
// 将constructor设置为私有方法
private constructor(){
}
}
// Client
new UserSettingsService() // 报错
- 其次,提供一个静态方法,由这个方法来控制实例的生成和获取
class UserSettingsService {
private static instance: UserSettingsService | null;
private constructor(){
}
public static getInstance(){
if(this.instance === null){
this.instance = new UserSettingsService()
}
return this.instance;
}
}
// Client
console.log(
UserSettingsService.getInstance() === UserSettingsService.getInstance()
) // true
至此,已经运用单例模式解决了第一个问题了,但是,第二个问题尚未解决。
由于load函数需要通过HTTP请求从服务端获取数据,是一个异步的行为,所以我们难以避免引入异步代码。
一开始我尝试在contructor中调用await load,但是发现系统会报错,
经过一番搜索,我发现,如果想要在构造函数里执行异步程序除了用比较tricky的方式(比如立即执行函数)之外,是无法做到的。在语言层面,这种行为也是可以理解的,因为contructor的返回应该就是一个对象,而不是一个Promise,哪怕能够用tricky的方式实现,这也不是一个好的实践。
既然constructor里不能放,那么只能试图将它放在getInstance方法中,这样将导致getInstance方法成为一个异步方法,返回一个Promise,因此客户端使用getInstance的方式也要变化:
class UserSettingsService {
private userSettings = {};
private static instance: UserSettingsService | null = null;
private constructor() {}
public static async getInstance() {
if (!this.instance) {
const config: any = await this.load();
this.instance = new UserConfigService1();
this.instance.userConfig = config;
}
return this.instance;
}
private static async load() {
await fetch('xxxx')
}
public getConfigByPath(path: string) {
return get(this.userConfig, path);
}
public saveConfigByPath(path: string, value: any) {
set(this.userConfig, path, value);
// send http request to save lates config data
}
}
// Client
const service1 = await UserSettingsService .getInstance();
const service2 = await UserSettingsService .getInstance();
console.log(service1 === service2);
service1.saveConfigByPath("list.pageSize", 50);
console.log(service2.getConfigByPath("list.pageSize")); // 50
从以上测试代码可以看出,不管调用多少遍getInstance,获取到的始终同一个实例,并且我们也不在需要手动调用load来初始化数据了。
总结
单例模式的优点很明显,它从客户端手中接管了实例的创建工作,并把这个唯一的实例掌握在自己手中,保证不会产生重复的实例。
同时,他也有自己的缺点,比如:
- 破坏单一职责原则。在我的例子中,UserSettingsService 既要负责实例的生产,还要负责load配置,存取配置;
- 实例可能会被垃圾回收机制回收掉。
引申
在多线程语言中使用单例模式
由于JS是单线程的,理论上来说不会存在有两个线程同时在创建实例的情况,所以在JS中运用单例模式是很简单的;但是在支持多线程的语言比如Java时,必须要考虑有多个线程同时在创建实例的情况。
为了应对这个问题,由个解决方案:
- 饿汉式:类加载的时候就把实例创建出来,可以防止创建多个实例,但是会造成资源浪费;
- 懒汉式:给创建语句加双重锁,这样做增加了理解难度;
- 内部类:在类里面再定义一个类,这个类持有一个static final 修饰的实例,可以实现需要时在加载(js里不支持在类里面定义类)。
Singleton Pattern in Angular
上述Case是我在做React项目时遇到的,后来接触到了Angular项目,了解到在Angular中实现系统级的单例只需要一个配置一个注解
@Injectable({
provideIn: 'root'
})
class UserSettingsService{
}
// client
class Component{
// DI 自动注入实例
constructor(private userSettingsService: UserSettingsService ){}
}
不禁感慨Angular DI(依赖注入)真是强大,尝试阅读源码来研究Angular是如何实现的,但是内容太多,那就期待后面关于Angular DI的专题文章吧。