デザインのアンチパターン

アンチパターンは一見良いアプローチのように見えますが、メリットよりも弊害の方が大きいことが示されています。これらは一般的に避けるべきです。

GitLabのコードベース全体を通して、これらのアンチパターンの歴史的な使い方があるかもしれません。これらのレガシーパターンを使っているコードに触れるときは、リファクタリングするかどうかを判断するときに慎重にしてください。

note
新しい機能については、アンチパターンは必ずしも禁止されているわけではありませんが、別のアプローチを見つけることを強く推奨します。

共有グローバルオブジェクト(アンチパターン)

共有グローバルオブジェクトとは、どこからでもアクセスできるインスタンスであり、明確なオーナーを持ちません。

このパターンをVuexストアに適用した例を示します:

const createStore = () => new Vuex.Store({
  actions,
  state,
  mutations
});

// Notice that we are forcing all references to this module to use the same single instance of the store.
// We are also creating the store at import-time and there is nothing which can automatically dispose of it.
//
// As an alternative, we should export the `createStore` and let the client manage the
// lifecycle and instance of the store.
export default createStore();

共有グローバルオブジェクトが引き起こす問題とは?

共有グローバル・オブジェクトは、どこからでもアクセスできるので便利です。しかし、その利便性は必ずしもその重いコストを上回るとは限りません:

  • 所有者がいないこと。これらのオブジェクトには明確なオーナーがいないため、非決定的で永続的なライフサイクルを想定しています。これは特にテストにとって問題となります。
  • アクセス制御ができません。共有グローバルオブジェクトが何らかの状態を管理する場合、 このオブジェクトへのアクセス制御がないため、 非常にバギーで困難なカップリングが発生する可能性があります。
  • 循環参照の可能性。共有グローバル・オブジェクトのサブモジュールは、共有グローバル・オブジェクト自身を参照するモジュールを参照することができるため、共有グローバル・オブジェクトは循環参照の状況を作り出す可能性もあります(例としてMRを参照)。

以下は、このパターンが問題とされた歴史的な例です:

Shared Global Objectパターンが実際に適切なのはどのような場合でしょうか?

共有グローバルオブジェクトは、何かをグローバルにアクセス可能にするという問題を解決します。このパターンは適切かもしれません:

  • レスポンシビリティが本当にグローバルで、アプリケーション全体で参照されるべき場合(例えば、アプリケーション全体のイベントバス)。

このようなシナリオであっても、Shared Global Object パターンを避けることを検討してください。

リファレンス

詳細については、C2 wikiのGlobal Variables Are Badを参照してください。

シングルトン(アンチパターン)

古典的なSingletonパターンは、あるもののインスタンスが1つしか存在しないことを保証するアプローチです。

このパターンの例を示します:

class MyThing {
  constructor() {
    // ...
  }

  // ...
}

MyThing.instance = null;

export const getThingInstance = () => {
  if (MyThing.instance) {
    return MyThing.instance;
  }

  const instance = new MyThing();
  MyThing.instance = instance;
  return instance;
};

シングルトンが引き起こす問題とは?

ある物事のインスタンスは1つしか存在しないというのは大前提です。多くの場合、シングルトンは誤用され、それ自身とそれを参照するモジュールの間で非常に緊密な結合を引き起こします。

以下は、このパターンが問題とされた歴史的な例です:

シングルトンがしばしば生み出す悪弊をいくつか紹介します:

  1. 非決定性テスト。シングルトンは非決定論的なテストを助長します。なぜなら、単一のインスタンスが個々のテストにまたがって共有されるため、あるテストの状態が別のテストに影響を及ぼすことがよくあるからです。
  2. 高い結合性。シングルトンクラスのクライアントは、あるオブジェクトの特定のインスタンスを共有することになります。つまり、このパターンでは、所有者が明確でない、アクセス制御ができない、といったShared Global Object の問題をすべて引き継ぐことになります。つまり、このパターンは、所有権が明確でない、アクセス制御ができないなど、共有グローバルオブジェクトの問題点をすべて受け継いでいるということです。これらは、バグが多く、解くのが難しい高カップリングの状況を引き起こします。
  3. 感染性。シングルトンは、特に状態を管理するときに感染します。Web IDE で使われているRepoEditorコンポーネントを考えてみましょう。このコンポーネントは、Monaco で作業するための状態を管理するシングルトンエディタとのインターフェイスを持っています。Editorクラスがシングルトンであるため、コンポーネントRepoEditor もシングルトンであることを余儀なくされます。このコンポーネントのインスタンスが複数あると、Editorのインスタンスを本当に所有する人がいないため、内部でイシューが発生します。

これは、Javaのようにすべてをクラスでラップしなければならない言語の制限のためです。JavaScriptではオブジェクトや関数リテラルのようなものがあり、ユーティリティ関数をエクスポートするモジュールで多くの問題を解決できます。

シングルトンパターンが実際に適切なのはどのような場合でしょうか?

シングルトンは、ある物事のインスタンスが1つしか存在しないことを強制するという問題を解決します。以下のような稀なケースでは、シングルトンが適切である可能性があります:

  • インスタンスを1つだけ持たなければならないリソースを管理する必要がある場合(つまり、ハードウェアの制限)。
  • 本当に横断的な関心事(例えばロギング)があり、シングルトンが最もシンプルなAPIを提供します。

このようなシナリオであっても、Singletonパターンは避けることを検討してください。

Singletonパターンに代わるものは?

ユーティリティ関数

状態を管理する必要がない場合、クラスのインスタンス化をいじらずに、モジュールからユーティリティ関数をエクスポートすることができます。

// bad - Singleton
export class ThingUtils {
  static create() {
    if(this.instance) {
      return this.instance;
    }

    this.instance = new ThingUtils();
    return this.instance;
  }

  bar() { /* ... */ }

  fuzzify(id) { /* ... */ }
}

// good - Utility functions
export const bar = () => { /* ... */ };

export const fuzzify = (id) => { /* ... */ };

依存性の注入

Dependency Injectionは、モジュールの依存関係を宣言してモジュールの外部から注入することで、結合を解除するアプローチです (例えば、コンストラクタのパラメータ、真正な Dependency Injection フレームワーク、Vueprovide/injectなど)。

// bad - Vue component coupled to Singleton
export default {
  created() {
    this.mediator = MyFooMediator.getInstance();
  },
};

// good - Vue component declares dependency
export default {
  inject: ['mediator']
};
// bad - We're not sure where the singleton is in it's lifecycle so we init it here.
export class Foo {
  constructor() {
    Bar.getInstance().init();
  }

  stuff() {
    return Bar.getInstance().doStuff();
  }
}

// good - Lets receive this dependency as a constructor argument.
// It's also not our responsibility to manage the lifecycle.
export class Foo {
  constructor(bar) {
    this.bar = bar;
  }

  stuff() {
    return this.bar.doStuff();
  }
}

この例では、mediator のライフサイクルと実装の詳細は、すべてコンポーネント (おそらくページのエントリポイント) の外部で管理されます。