单例模式实践 Practice in Singleton Pat

2022-02-24  本文已影响0人  小丸子啦啦啦呀

场景说明(Case)

系统中通常存在用户个性化设置数据,这类数据需要持久化存储,用户再次进入系统中将数据加载回来。
比如在我们的系统的报表页面中,用户可以自由设置每页显示多少条数据,当表格的列很多时,可以设置默认展示哪些列。

{
  list: {
    pageSize: 50, // 每页展示50条
    columns: ['name', 'count', 'comment'] // 默认展示这三列
  }
}

为此,我们建立了一个userSettingsService类, 这个类主要职责有三个:

  1. 初始化设置。在系统初始化时,从服务器读取该用户所有的settings数据,缓存在类中;
  2. 获取设置。接收一个path(如:"itemList.pageSize"), 返回该path对应的值(如:50);
  3. 修改设置。接收一个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");

问题分析

上述做法,存在两个问题:

  1. 每次遇到要使用UserSettingsService的时候,都需要new一个新的实例,这样将导致:
    1.1 资源浪费:创建每一个实例都需要占据时间和空间,而然我们根本不需要那么多个实例,就用户设置数据而言,它是整个系统全局持有的。
    1.2 数据不一致:如果在A组件将设置数据改动了,然而,由于A和B使用的不是同一个实例,将导致B组件获取到脏数据。
  2. load函数需要用户手动调用,如果用户不了解其细节,不调用Load却直接调用getConfigByPath将会导致程序出错。

重构步骤

通过问题分析可知,重构目标包括两个:

  1. 保证整个系统中只存在一个UserSettingsService的实例;
  2. new UserSettingsService的实例以及执行load函数都应该只被做一遍,他们是不可分割的,load应该在new UserSettingsService的时候一起执行,而不是由Client手动调用;
    要实现目标,单例模式当仁不让的出场选手。一下是具体的实现步骤:
  1. 首先,要防止客户端通过new来创建实例:
class UserSettingsService {
  // 将constructor设置为私有方法
  private constructor(){
  }
}
// Client
new UserSettingsService() // 报错
  1. 其次,提供一个静态方法,由这个方法来控制实例的生成和获取
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来初始化数据了。

总结

单例模式的优点很明显,它从客户端手中接管了实例的创建工作,并把这个唯一的实例掌握在自己手中,保证不会产生重复的实例。
同时,他也有自己的缺点,比如:

  1. 破坏单一职责原则。在我的例子中,UserSettingsService 既要负责实例的生产,还要负责load配置,存取配置;
  2. 实例可能会被垃圾回收机制回收掉。

引申

在多线程语言中使用单例模式

由于JS是单线程的,理论上来说不会存在有两个线程同时在创建实例的情况,所以在JS中运用单例模式是很简单的;但是在支持多线程的语言比如Java时,必须要考虑有多个线程同时在创建实例的情况。
为了应对这个问题,由个解决方案:

  1. 饿汉式:类加载的时候就把实例创建出来,可以防止创建多个实例,但是会造成资源浪费;
  2. 懒汉式:给创建语句加双重锁,这样做增加了理解难度;
  3. 内部类:在类里面再定义一个类,这个类持有一个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的专题文章吧。

上一篇 下一篇

猜你喜欢

热点阅读