😇

DI(依存性注入)ってお寿司で言うとこんな感じ?

2024/12/22に公開

はじめに

お寿司、クソ美味しいですよね?
ところで、お寿司屋さんでの注文の仕方にいくつかのパターンがあるように、オブジェクト指向プログラミングの世界にも「DI(依存性注入)」という、オブジェクトの生成と利用に関する、ちょっとしたテクニックがあります。

「DI」とか「依存性注入」という言葉だけ聞くと難しそうですが、実はお寿司屋さんでの注文に例えると、とてもイメージしやすいんです!(そんなわけ...あるのか!?)

DIに関する解説記事はたくさんあります。
しかし、どうもピンとこなかったり、誤解して覚えてしまったりする人が多いようです...

そこで本記事では、お寿司を題材にした解説で、以下のようなDIのモヤモヤをスッキリ解消することを目指します!

  • そもそもDIって何?
  • なぜDIを使うと良いの?
  • アンチパターンはあるの?
  • DIコンテナって?

DIって何? お寿司で言うと?

DI(Dependency Injection)とは、日本語で「依存性の注入」と訳されます。

…と言われても、まだピンと来ないかもしれませんね。

お寿司屋さんで例えるなら、DIとはズバリ 「お寿司を握ってもらうときに、ネタを自分で用意して渡すこと」 です。

「えっ、どういうこと?」と思いましたか? 大丈夫、順番に説明していきます!

「依存」ってどういう状態? お寿司で言うと?

まずは、「依存」という状態について、お寿司屋さんを舞台に考えてみましょう。

あなたは今、お寿司屋さんで「寿司職人」というクラスを書いています(実際には書きませんが、イメージしてくださいね)。寿司職人は、「マグロ(Maguro)」や「サーモン(Salmon)」などのネタがないと、お寿司を握れません。

class Maguro {
  getNetaName() {
    return "マグロ";
  }
}

class SushiChef {
  private topping: Maguro;

  constructor() {
    this.topping = new Maguro(); // 寿司職人が自分でマグロを用意している
  }

  makeSushi(): void {
    console.log(`${this.topping.getNetaName()}のお寿司を握りました!`);
  }
}

const chef = new SushiChef();
chef.makeSushi(); // 出力: マグロのお寿司を握りました!

このコードでは、SushiChefクラスのコンストラクタ(constructor)の中で、new Maguro()として、「マグロ」オブジェクトを生成しています。

この状態は、SushiChefクラスはMaguroクラスに依存している」 ということになります。

SushiChefクラスは、Maguroクラスがないと、makeSushi()メソッドで「マグロのお寿司を握りました!」という仕事ができません。つまり、Maguroクラスに頼っている(依存している)わけですね。

それの何が問題なの? お寿司で言うと?

上記のコードのままでも、とりあえずマグロのお寿司は握れます。では、何が問題なのでしょうか?

想像してみてください。もし、あなたが無類のサーモン好きで、サーモンのお寿司ばかり食べたいとします。しかし、この寿司職人は、Maguro(マグロ)しか用意してくれません。これでは困りますね。

つまり、今のSushiChef(寿司職人)は、Maguro(マグロ)専門の寿司職人になってしまっていて、融通が利かない状態です。

もし「サーモン(Salmon)」を握ってほしい場合は、SushiChefクラスのコードを書き換える必要が出てきます。

class Salmon {
  getNetaName() {
    return "サーモン";
  }
}

class SushiChef {
  // ... (中略) ...

  constructor() {
    // this.topping = new Maguro(); // これをコメントアウトして...
    this.topping = new Salmon(); // こっちに書き換えないといけない!
  }

  // ... (後略) ...
}

これでは、ネタを変えるたびにSushiChefクラスを書き換えなければならず、大変です。

さらに、SushiChefクラスをテストしたい場合も問題が出てきます。SushiChefクラスをテストするには、必ずMaguroクラスも一緒に動かす必要があります。Maguroクラスに問題があったら、SushiChefクラスのテストが失敗してしまうかもしれません。

これらの問題は、SushiChefクラスがMaguroクラスに 「密結合」 していることが原因です。密結合とは、クラス同士が強く結びついていて、お互いの変更の影響を受けやすい状態のことです。

そこでDIの出番! 依存するネタは外から渡そう

ここでDIの登場です! DIでは、SushiChefクラスの中でMaguroオブジェクトを生成するのではなく、外部からネタを渡してもらうようにします

さて、ここで「外部から渡す」ものには、大きく分けて2つのパターンがあります。

1. 振る舞いを定義した「ネタ処理インターフェース」を渡す

interface Topping {
  getNetaName(): string; // ネタの名前を取得する
  // 他にもネタに関する処理があれば、ここに追加していく
}

class Maguro implements Topping {
  getNetaName(): string {
    return "マグロ";
  }
}

class Salmon implements Topping {
  getNetaName(): string {
    return "サーモン";
  }
}

class Uni implements Topping {
  getNetaName(): string {
    return "ウニ";
  }
}

class SushiChef {
  private topping: Topping;

  // コンストラクタでネタ処理(Topping型)を受け取る
  constructor(topping: Topping) {
    this.topping = topping;
  }

  makeSushi(): void {
    console.log(`${this.topping.getNetaName()}のお寿司を握りました!`);
  }
}

// 使う側で、握ってほしいネタを渡す
const maguro: Maguro = new Maguro();
const chefWithMaguro: SushiChef = new SushiChef(maguro); // マグロを渡す
chefWithMaguro.makeSushi(); // 出力: マグロのお寿司を握りました!

const salmon: Salmon = new Salmon();
const chefWithSalmon: SushiChef = new SushiChef(salmon); // サーモンを渡す
chefWithSalmon.makeSushi(); // 出力: サーモンのお寿司を握りました!

const uni: Uni = new Uni();
const chefWithUni: SushiChef = new SushiChef(uni); // ウニを渡す
chefWithUni.makeSushi(); // 出力: ウニのお寿司を握りました!

この例では、Toppingという「ネタ処理インターフェース」を定義し、SushiChefはそれを使ってお寿司を握るようにしました。ToppingインターフェースはgetNetaName()という「ネタの名前を取得する」という振る舞い(メソッド)を定義しています。MaguroSalmonToppingを実装することで、具体的なネタとして扱えるようになります。

2. 具体的なネタ(具象クラス)を直接渡す

class Maguro {
  getNetaName() {
    return "マグロ";
  }
}

class Salmon {
  getNetaName() {
    return "サーモン";
  }
}

class SushiChef {
  private topping: Maguro | Salmon; // MaguroかSalmonを受け取る

  constructor(topping: Maguro | Salmon) {
    this.topping = topping;
  }

  makeSushi(): void {
    console.log(`${this.topping.getNetaName()}のお寿司を握りました!`);
  }
}

// 使う側で、握ってほしいネタを渡す
const maguro = new Maguro();
const chefWithMaguro = new SushiChef(maguro); // マグロを渡す
chefWithMaguro.makeSushi();

const salmon = new Salmon();
const chefWithSalmon = new SushiChef(salmon); // サーモンを渡す
chefWithSalmon.makeSushi();

この例では、SushiChefMaguroまたはSalmonという「具体的なネタ」を直接受け取るようにしました。SushiChefを握るSushiChefは、渡された具体的なネタを使ってお寿司を握ります。

どちらの方法が良い?

どちらもDIのパターンであり、状況によって使い分けます。

抽象的なネタ処理(インターフェース)を使うメリット

  • SushiChefクラスは、具体的なネタの種類に依存しなくなります。
    Toppingを実装していれば、どんなネタでも扱えるようになります。
  • 新しいネタを追加するのが簡単です。
    Toppingを実装した新しいクラスを作るだけで、SushiChefクラスを変更する必要はありません。
  • テストで、モックオブジェクト(偽のネタ)を簡単に渡すことができます。

具体的なネタ(具象クラス)を使うメリット

  • インターフェースを定義する手間が省られます。
  • シンプルなケースでは、コードが簡潔になります。

一般的には、抽象的なネタ処理(インターフェース)を使う方が、柔軟性が高く、変更に強いコードになります。しかし、扱うネタの種類が限定的で、将来的に増える可能性が低い場合は、具体的なネタを直接渡す方がシンプルな場合もあります。

DIの様々な注入方法 お寿司で言うと?

DIには、依存オブジェクトを注入するための様々な方法があります。それぞれお寿司で例えながら、代表的なものを紹介します。

1. コンストラクタインジェクション

コンストラクタの引数を通じて依存オブジェクトを渡す方法です(今までの例は全てコンストラクタインジェクション)

メリット

  • オブジェクトが生成される際に、必要な依存オブジェクトが確実に渡されるため、オブジェクトは常に完全な状態で使用できます。
  • コンストラクタの引数を見れば、そのクラスが何に依存しているのかが一目瞭然です。
  • 依存関係が明確になり、コードの可読性と保守性が向上します。
  • テストが容易になります。
    テスト用のモックオブジェクトをコンストラクタに渡すだけで、簡単にテストできます。

デメリット

  • 依存オブジェクトが多い場合、コンストラクタの引数が長くなり、煩雑になる可能性があります。

お寿司で言うと?

寿司職人がお店を開く時に、今日のネタを仕入れてもらうイメージです。「今日はマグロとサーモンで!」のように、最初にネタを渡します。

2. セッターインジェクション

セッターメソッドを通じて依存オブジェクトを設定する方法。

class SushiChef {
  private topping: Topping;

  setTopping(topping: Topping): void {
    this.topping = topping;
  }

  // ...
}

const chef = new SushiChef();
chef.setTopping(new Salmon()); // 後からサーモンを渡す

メリット

  • 依存オブジェクトをオプション扱いにできます。
    つまり、必ずしも依存オブジェクトを設定しなくても、オブジェクトを生成できます。
  • オブジェクトの生成後に、依存オブジェクトを変更することができます。

デメリット

  • オブジェクトが生成された時点では、依存オブジェクトが設定されていない可能性があるため、オブジェクトが不完全な状態になるリスクがあります。
  • 依存オブジェクトが設定されているかどうかを、使う側が確認する必要があり、コードが煩雑になる可能性があります。

お寿司で言うと?

寿司職人に「後から良いサーモンが入ったら、それを使って!」と伝えておくイメージです。

3. インターフェースインジェクション

依存オブジェクトを設定するための専用のインターフェースを定義し、そのインターフェースを実装することで依存オブジェクトを注入する方法です。

interface ToppingInjector {
  injectTopping(topping: Topping): void;
}

// ...

class SushiChef implements ToppingInjector {
  private topping: Topping;

  // インターフェースで定義されたメソッドでToppingを注入する
  injectTopping(topping: Topping): void {
    this.topping = topping;
  }

  makeSushi(): void {
    console.log(`${this.topping.getNetaName()}のお寿司を握りました!`);
  }
}

// 使用例
const chef = new SushiChef();
chef.injectTopping(new Uni());
chef.makeSushi();

メリット

  • 依存オブジェクトを注入するための規約を強制できます。
  • 依存オブジェクトの注入方法を、具象クラスから分離できます。

デメリット

  • コードが複雑になります。
    依存オブジェクトを注入するためだけに、専用のインターフェースとメソッドが必要になります。

お寿司で言うと?

「ネタを渡す専用の窓口」を寿司職人に用意してもらうイメージですが、ちょっと特殊な注文方法ですね。

4. プロパティインジェクション

依存オブジェクトをpublicなプロパティに直接設定する方法です(非推奨)

class SushiChef {
    public topping: Topping;
    // ...
}

const chef = new SushiChef();
chef.topping = new Topping();

メリット

  • 非常にシンプルで、実装が簡単です。

デメリット

  • カプセル化の原則に反します。 依存オブジェクトが外部から直接アクセス・変更可能になってしまい、オブジェクトの内部状態の整合性が保てなくなる危険性があります。
  • 依存オブジェクトが設定されていない状態でメソッドが呼ばれると、エラーが発生する可能性があります。

お寿司で言うと?

寿司職人の目の前にあるネタケースに、直接ネタを置くイメージです。ただし、他の人からもネタが見えてしまうため、セキュリティ的(衛生的?)に問題があります。

5. メソッドインジェクション

特定のメソッドの引数として依存オブジェクトを渡す方法です。

// ...

class SpecialSauce {
  getSauceName(): string {
    return "特製ソース";
  }
}

class SushiChef {
  // 特製ソースをかけて握る(メソッドインジェクション)
  makeSushiWithSpecialSauce(topping: Topping, sauce: SpecialSauce): void {
    console.log(`${topping.getNetaName()}のお寿司に${sauce.getSauceName()}をかけて握りました!`);
  }
}

// 使用例
const chef = new SushiChef();

const salmon = new Salmon();
const specialSauce = new SpecialSauce();
chef.makeSushiWithSpecialSauce(salmon, specialSauce); // 出力: サーモンのお寿司に特製ソースをかけて握りました!

メリット

  • 特定のメソッド内でのみ依存オブジェクトが必要な場合に、簡潔に記述できます。
  • メソッドのシグネチャを見れば、依存関係がわかります。

デメリット

  • メソッドが呼び出されるたびに依存オブジェクトを渡す必要があり、冗長になる可能性があります。
  • クラス全体として依存しているオブジェクトには適していません。

お寿司で言うと?

「このネタを使って、この握り方で!」と、握るたびにネタと方法を指定するイメージ。

どの注入方法が最適か?

多くの場合、コンストラクタインジェクションが最適です。
オブジェクトの生成時に必要な依存オブジェクトがすべて揃うため、オブジェクトを常に正しい状態で使用できます。また、依存関係が明確になり、コードの可読性や保守性、そしてテスト容易性も向上します。

状況に応じて適切な注入方法を選択することが重要です。

DIで何が嬉しいの? お寿司で言うと?

DIを使うことで、先ほどの問題が解決され、以下のようなメリットが得られます。

  • ネタを簡単に切り替えられる: SushiChefクラスのコードを変更することなく、マグロ、サーモン、ウニ、その他のどんなネタでも、簡単に握ってもらえるようになりました。
    SushiChefクラスを使う側で、渡すオブジェクトを変えるだけでOKです。
  • テストが簡単になる: SushiChefクラスをテストする際に、本物のMaguroオブジェクトの代わりに、テスト用のモックオブジェクトを渡すことができます。
    これにより、SushiChefクラスだけを独立してテストすることができます。
  • コードが読みやすくなる: SushiChefクラスが何に依存しているかが、コンストラクタを見れば一目でわかるようになりました。
  • 変更に強くなる: 将来的に新しいネタが増えても、SushiChefクラスの変更を最小限に抑えることができます。

つまり、DIによって、クラス間の結合が緩やかになり(疎結合)、柔軟で変更に強い、テストしやすいコードを実現できるのです!

お寿司で例えると、色々なネタを柔軟に使いこなせる、お客さんの要望に臨機応変に対応できる、そんな優秀な寿司職人を作ることができるということです。

DI、ここには気をつけろ!(アンチパターン)

DIは強力なツールですが、誤った使い方をすると逆効果になることもあります。ここでは、代表的なDIのアンチパターンを、お寿司の例えとともに紹介します。

1. 過剰な抽象化(なんでもかんでもインターフェース)

寿司ネタだけでなく、シャリ、ワサビ、醤油、全てを抽象化してインターフェース経由で渡さないと気が済まない状態。「米インターフェース」「醤油インターフェース」など、過剰に抽象化されています。

  • 問題点: コードが複雑になり、理解しにくくなる。必要以上の抽象化は、開発効率を低下させ、保守性を損ないます。
  • 教訓: 抽象化は、必要なところ、効果的なところに限定しましょう。

2. 依存関係の隠蔽(サービスロケータ)

寿司職人が、ネタをどこからか勝手に取ってきて握ってしまう状態。お客さんからは、何を使っているのかわからない。しかも、そのネタの調達方法が、お客さんから見てブラックボックスになっています。

  • 問題点: 一見便利に見えるが、依存関係がわかりにくくなり、テストやデバッグが困難になり、グローバルな状態への依存を生み出し、予期せぬ副作用を引き起こす危険性があります。
  • 教訓: サービスロケータは、依存関係を隠蔽し、グローバルな状態への依存を生み出すため、原則として避けるべきです。
    依存関係は、コンストラクタインジェクションなどを使って、明示的に 注入しましょう。

サービスロケータ実装例

// 簡易的なサービスロケータの例(アンチパターン)
class ServiceLocator {
  private static instance: ServiceLocator;
  private dependencies: { [key: string]: any } = {};

  private constructor() {}

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

  register(key: string, dependency: any): void {
    this.dependencies[key] = dependency;
  }

  get(key: string): any {
    if (!this.dependencies.hasOwnProperty(key)) {
      throw new Error(`Dependency not found: ${key}`);
    }
    return this.dependencies[key];
  }
}

class SushiChef {
  private topping: Topping;

  constructor() {
    this.topping = ServiceLocator.getInstance().get('Topping'); // どこからネタを取得しているかわからない!
  }

  makeSushi(): void {
    console.log(`${this.topping.getNetaName()}のお寿司を握りました!`);
  }
}

// サービスロケータへの依存オブジェクトの登録
const serviceLocator = ServiceLocator.getInstance();
serviceLocator.register('Topping', new Maguro());

const chef = new SushiChef(); // ここでは何も渡していないのに、勝手にMaguroが使われる
chef.makeSushi(); // 出力: マグロのお寿司を握りました!

3. 過剰なDI(何でもかんでも注入)

寿司職人に、ネタだけでなく、箸、お皿、お茶、さらには「握り方」まで、全てを外部から注入してもらう状態。「握り方」まで外部から注入しようとすると、柔軟性よりも複雑さが勝ってしまいます。

  • 問題点: コードが冗長になり、理解しにくくなる。依存関係が複雑になりすぎて、管理が困難になります。
  • 教訓: 注入するものは、本当に外部から変更する必要があるもの、テストで差し替える必要があるものに限定しましょう。

4. 循環依存

寿司職人Aが「最高の寿司を握るには、寿司職人Bが握った寿司が必要だ」と言い、寿司職人Bは「最高の寿司を握るには、寿司職人Aが握った寿司が必要だ」と言っている状態。これでは、いつまで経っても寿司が完成しません。

  • 問題点: プログラムが正常に動作しなくなります。
  • 教訓: クラス間の依存関係が循環しないように設計しましょう。

もっと楽に! DIコンテナの活用

DIを実践していると、依存オブジェクトの生成や注入を管理するのが大変になってくることがあります。特に、大規模なアプリケーションでは、依存関係が複雑になり、手動で管理するのは困難です。

そこで登場するのがDIコンテナです。

DIコンテナは、依存オブジェクトの生成と注入を自動化してくれます!

お寿司屋さんで例えるなら、優秀な仕入れ担当のようなものです。
寿司職人は、DIコンテナに「マグロがほしい」「サーモンがほしい」と伝えるだけで、DIコンテナが適切なネタを調達してきてくれます。

DIコンテナのメリット

  • 依存オブジェクトの生成と注入を自動化できる: 依存関係を定義ファイルやアノテーションで記述しておけば、DIコンテナが自動的にオブジェクトを生成し、注入してくれます。
  • オブジェクトのライフサイクルを管理できる: オブジェクトをシングルトン(常に同じインスタンスを返す)にしたり、リクエストごとに新しいインスタンスを生成したり、といった制御ができます。
  • 設定が容易: 多くのDIコンテナは、設定ファイルやアノテーションを使って、簡単に依存関係を定義できます。

代表的なDIコンテナ

DIコンテナの注意点

DIコンテナは強力なツールですが、銀の弾丸ではありません。

  • 学習コスト: DIコンテナの使い方を学ぶ必要があります。
  • 設定ミス: 設定を間違えると、アプリケーションが正常に動作しなくなる可能性があります。
  • 過剰な依存: DIコンテナに頼りすぎると、コードがDIコンテナと密結合になってしまう可能性があります。

DIコンテナは、依存関係が複雑な大規模アプリケーションで特に効果を発揮しますが、小規模なアプリケーションでは、手動でDIを行う方がシンプルな場合もあります。

まとめ:DIは怖くない(むしろ美味しい!?)

DI(依存性注入)は、オブジェクト指向プログラミングにおいて非常に重要な考え方です。最初は難しく感じるかもしれませんが、「依存するオブジェクトを外から渡す」 というシンプルな原則を、お寿司の例えで理解すれば、決して怖くありません。

DIはインターフェースを使った抽象化だけでなく、具体的なクラスを直接注入することも含みます。状況に応じて適切な方法を選び、柔軟に使いこなすことが重要です。

そして、DIのアンチパターンに陥らないように注意することも大切です。

DIコンテナは、依存オブジェクトの生成と注入を自動化してくれる便利なツールです。大規模アプリケーションでは、DIコンテナの活用を検討してみましょう。

最後に、もっとDIについて詳しく知りたい方は、以下の本がオススメです!
https://book.mynavi.jp/ec/products/detail/id=143373

株式会社ソニックムーブ

Discussion