iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

TypeScriptシングルトンパターン設計について

こんにちは。iimonでエンジニアをしているhayashiと申します。

普段は主に拡張機能を開発しております。

今回はTypeScriptでのシングルトーンパターンについて解説したいと思います。

シングルトン(singleton)とは、オブジェクト指向プログラミングにおけるクラスのデザインパターンの一つで、実行時にそのクラスのインスタンスがアプリケーション全体で一つだけであることを保証するために使用されます

これにより以下の利点があります。

  1. グローバルな状態管理: アプリケーション全体で共有される状態を一元管理するためにシングルトンパターンを使用します。例えば、ユーザーの認証情報や設定など。
  2. リソースの節約: 同じインスタンスを再利用することで、メモリやリソースの無駄を減らすことができます。
  3. 一貫性の確保: シングルトンパターンを使用することで、同じデータや状態が常に一貫して提供されることを保証できます。

例えば設定情報やログ出力クラスなど、複数のインスタンスを持つ必要がない場合に適しています。

TypeScript でのシングルトンパターンの実装

次にTypeScript を使ってシングルトンパターンを実装してみましょう。

基本的なシングルトンの実装

  1. 静的プロパティ: インスタンスを保持するために static なプロパティを使用。
  2. private constructorとして 外部から new キーワードで直接インスタンスを作成することを防止できます。
  3. 静的メソッド: インスタンスへのアクセスを提供するための getInstance メソッド。
class Singleton {
    // 唯一のインスタンスを保持する静的プロパティ
    private static instance: Singleton;
  
    // コンストラクタを private にして外部からのインスタンス化を防止.
    // eslint-disable-next-line no-useless-constructor, no-empty-function
    private constructor() {}
  
    // インスタンスを取得するための静的メソッド
    static getInstance() {
        if (!this.instance) {
            this.instance = new Singleton();
        }
        return this.instance;
    }
  }
  
  const instance1 = Singleton.getInstance();
  const instance2 = Singleton.getInstance();
  
  console.log(instance1 === instance2); // true

基本的なシングルトンの実装は上記のようになります。

例えば以下のように認証したuserのtokenをシングルトンクラスで管理したらSPAサイトなどの場合は無駄にインスタンスを作成せず、かつローカルストレージにも保存せずに使い回すことが可能です。

以下のクラスはインスタス変数として自分のインスタンスを持っておき、インスタンスがなければ作成してそれを使うってことで一貫性を保持します。

AccessToken.getInstance().setToken(token)の様な形でtokenをクラスに保持したらAccessToken.getInstance().getToken()という形でいつでもsetしたtokenを色んなファイルで呼び出し使い回すことが可能です。

export default class AccessToken {
  private static instance: AccessToken;

  private accessToken: string = '';
  
   // eslint-disable-next-line no-useless-constructor, no-empty-function
  private constructor() {}

  static getInstance(): AccessToken {
      if (!this.instance) {
          this.instance = new AccessToken();
      }
      return this.instance;
  }

  setToken(token: string): void {
      this.accessToken = token;
  }

  getToken(): string {
      return this.accessToken;
  }
}
const token = getToken()
AccessToken.getInstance().setToken(token)
const token = AccessToken.getInstance().getToken();

スレッドセーフなシングルトン

マルチスレッド環境では各クライアントリクエストを別々のスレッドで処理することで複数のリクエストを効率的に処理します。

なので同時に複数のスレッドが getInstance メソッドを呼び出すと複数のインスタンスが作成される可能性があります。

TypeScript自体はシングルスレッドで動作しますが、Node.jsでtypescriptを書いた場合などスレッドセーフなシングルトンとしなくては同時に同じインスタンスが作られてしまい、一貫性の保持ができなくなってしまうようです。

スレッドとは細かい説明は省きますが、端的にいうとプログラム内で独立して実行される最小の処理単位です。

async-mutexというライブラリを使った場合の例は以下のような形になります。

npm install async-mutex
import { Mutex } from 'async-mutex';

export default class ThreadSafeSingleton {
    private static instance: ThreadSafeSingleton;
    private static mutex = new Mutex();

    private constructor() {}

    static async getInstance(): Promise<ThreadSafeSingleton> {
        if (!this.instance) {
            await this.mutex.runExclusive(async () => {
                if (!this.instance) {
                    this.instance = new ThreadSafeSingleton();
                }
            });
        }
        return this.instance;
    }
}

runExclusiveasync-mutexライブラリのMutexクラスに含まれるメソッドです。このメソッドは他のスレッドや非同期タスクが同じMutexを使用している場合、その関数が実行されるまで待機し、実行が完了すると次の待機中の関数が実行されます。 このような複数のスレッドやプロセスが同時に共有リソースにアクセスすることを防ぐための制御方法を排他制御と言います。


シングルトン活用例

以下に具体的な例をいくつか紹介します。

1. 設定管理

アプリケーション全体で共有される設定情報を管理するクラスとしてシングルトンを使用します。

export default class Config {
    private static instance: Config;
    private settings: { [key: string]: string } = {};
  
    private constructor() {}
  
    static getInstance(): Config {
        if (!Config.instance) {
            this.instance = new Config();
        }
        return this.instance;
    }
  
    set(key: string, value: string) {
        this.settings[key] = value;
    }
  
    get(key: string) {
        return this.settings[key];
    }
  }
  
const config = Config.getInstance();
config.set("apiUrl", "https://api.example.com");
console.log(config.get("apiUrl")); // https://api.example.com

2. ログ管理

アプリケーションのログ出力を管理するクラスでシングルトンを利用する例です。

export default class Logger {
    private static instance: Logger;
  
    private constructor() {}
  
    static getInstance(): Logger {
        if (!this.instance) {
            this.instance = new Logger();
        }
        return this.instance;
    }
  
    log(message: string) {
        console.log(`Log: ${message}`);
    }
  }
logger.log("This is a log message");

3. キャッシュ管理

高頻度でアクセスされるデータをキャッシュするためのクラスにシングルトンを使用します。

アプリケーションでデータのキャッシュを管理する場合、シングルトンを使用してキャッシュの状態を一元管理できます。

class CacheInfo {
  private static instance: CacheInfo;
  private cache: { [key: string]: any }; // オブジェクトでキャッシュ管理

  private constructor() {
    this.cache = {}; // 空のオブジェクトで初期化
  }

  static getInstance(): CacheInfo {
    if (!this.instance) {
      this.instance = new CacheInfo();
    }
    return this.instance;
  }

  // 値をキャッシュに設定
  set(key: string, value: any): void {
    this.cache[key] = value;
  }

  // 値をキャッシュから取得
  get(key: string): any | undefined {
    return this.cache.hasOwnProperty(key) ? this.cache[key] : undefined;
  }

  // キャッシュをクリア
  clear(): void {
    this.cache = {};
  }
}
const cache = CacheInfo.getInstance();
cache.set("user_1", { name: "Alice", age: 25 });
console.log(cache.get("user_1")); // { name: "Alice", age: 25 }
cache.clear();
console.log(cache.get("user_1")); // undefined

例えばクリックイベントの管理にも便利です。

同じhtmlのボタンを使い回していて、出す状況によって発火させるイベントを変えたい場合もあると思います。

(例えば右上の鉛筆ボタンは基本情報を入力するイベントを仕込んでるが、その画面で新たに出したモーダルの鉛筆ボタンでは画像のみを入力したい場合など)

jqueryは推奨していないですが、jqueryで書かれている場合、その点は簡単でした。

onイベントで何個イベントを追加されていても

// クリックイベントを設定
$('#myButton').on('click', () => {
    alert('Button clicked!');
});

以下のようにoffメソッドを使用すると、指定したelemに関連付けられたすべての指定したイベントを削除することができます。

// クリックイベントを削除
$('#myButton').off('click');

普段は拡張機能を作成している手前、拡張機能を発火させるサイトにモーダルやボタンを差し込んで、そこにイベントをつけることが多いので、offはその点が便利でした。

これをvanillaに直した場合は

// クリックイベントを設定
const handleClick = () => {
    alert('Button clicked!');
}
const button = document.getElementById('myButton');
button.addEventListener('click', handleClick);

以下のようにelemを指定して、どのメソッドをremoveするのか渡さないといけません。

// クリックイベントを削除
button.removeEventListener('click', handleClick);

なのでイベントをキャッシュしておけば、いつでもイベントの付け外しが出来ます。

type EventCacheType = {
  [index: string]: {
      func: () => void;
      elem: HTMLElement;
  };
};

class EventManager {
    private static instance: EventManager;
    private eventCache:EventCacheType = {}
    private constructor() {
        this.eventCache = {};
    }

    static getInstance(): EventManager {
        if (!this.instance) {
            this.instance = new EventManager();
        }
        return this.instance;
    }

    addClickEvent(elem: HTMLElement | null, name: string, func: (e?: any) => void) {
      if(!elem) return
        this.eventCache[name] = {func, elem}
        elem.addEventListener('click', func)
    }

    removeClickEvent(elem: HTMLElement | null, name: string): void {
        if (!elem || !this.eventCache[name]) return
          const func =  this.eventCache[name].func
          elem.removeEventListener('click', func)
          delete this.eventCache[name]
    }
}
const eventManager = EventManager.getInstance();
const button = document.getElementById('myButton');
const handleClick =() => {
    alert('Button clicked!');
}
eventManager.addClickEvent(button, 'testFunc', handleClick);

上記のようにeventをキャッシュするクラスを作って以下みたいな形でelemとnameを渡してあげれば、そのelemからイベントを消して 新たに付け直したりすることも可能で便利かなって思います。

eventManager.removeClickEvent(button, 'testFunc');

jqueryのoffのように一回つけられているイベントを全部外したい場合はそのelemについているイベントをループで回してremoveする形に書き換えてもいいかなって思います。

シングルトンの過度な使用

シングルトンパターンは単一のセットした値を返してくれるので効率的ですが、 過度に使用するとモジュール間の依存が増えてしまう側面も持っている為、注意も必要です。

[UserInfoSingleton.ts]

class CountSingleton {
  private static instance: CountSingleton;
  private count: number;

  private constructor() {
    this.count = 50;
  }

  static getInstance(): CountSingleton {
    if (!this.instance) {
      this.instance = new CountSingleton();
    }
    return this.instance;
  }

  get(): number {
    return this.count;
  }

  set(value: number): void {
    this.count = value;
  }
}

[AClass.ts]

class A {
  method1() {
    CountSingleton.getInstance().set(100);
  }
}

[BClass.ts]

class B {
  method2() {
    CountSingleton.getInstance().get();
  }
}

[CClass.ts]

class C {
  method3() {
    CountSingleton.getInstance().set(20);
  }
}
new A().method1()
new B().method2()
new C().method3()
new B().method2()

当然ですが上記みたいな形でnew C().method3()とした後にnew B().method2()とすると

返す値が変わります。基本的にはシングルトンを活用する場合に こうゆう挙動を望んでいると思うのですが、 ファイル数が多かったりすると値のsetが予期せぬところに影響を及ぼしてしまう可能性もあります。 そこら辺を踏まえた上でハンドリングしていくのがマストですね!

まとめ

最後まで読んで下さりありがとうございます。

シングルトンパターンの考え方は他の言語においても役に立ちそうです。

もちろん気をつける点はありますが、シングルトンパターンを使いこなせればシステム全体で共有する値の保存に対して、不要なインスタンスの生成を減らして簡潔な設計に出来そうです。

実際に私が使用したシングルトンの例だとchromeExtensionsのwebRequestAPIを使ってネットワークより取得した情報をブラウザやchromeStorageに保存したくない場合にシングルトンクラスで一貫性を持った値として保持することに使えて便利でした。

それもwebRequestAPIをもっと解説したら、併せてブログにしようかなって思います。

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします!

iimon採用サイトWantedly / Green

明日は沖縄が産んだ悲願の記事を書いてくれるひがさんです。

楽しみです!!!!!!!!!!!!!!!!!!!!!!!!

参考記事 https://developer.chrome.com/docs/extensions/reference/api/webRequest?hl=ja https://zenn.dev/nekoniki/articles/b05c0f1297f301d3bd63 https://refactoring.guru/ja/design-patterns/singleton/typescript/example https://jp-seemore.com/web/13219/#toc17 https://qiita.com/tonkotsuboy_com/items/6d86d68200326757195d https://qiita.com/developer-kikikaikai/items/ad94a56084147f425ede https://swri.jp/glossary/%E3%82%B9%E3%83%AC%E3%83%83%E3%83%89 https://chmod774.com/singleton-pattern/#toc3 https://github.com/DirtyHairy/async-mutex/blob/master/src/Mutex.ts