📥

DI(依存性注入)の基礎を理解する【Part2】〜手動new地獄からの脱却!DIコンテナ入門〜

に公開

はじめに

前回の記事でDIの基本概念については理解できたかと思います。

前回の記事では…

  • DIの基本は「使うものは自分で作らず、外からもらう」
  • 依存の制御を外に出すことで、設計が柔軟でテストしやすくなる
  • interfaceと組み合わせることで、本当に強力な疎結合設計が可能になる

みたいな話をしましたね。

さて、DIを使った事で疎結合な設計が可能になった訳ですが、では、DIが複雑な構造になってきた場合どんな事になってしまうのでしょうか?

また、ソースコードを追いながらより具体的に知りたい場合は、下記のリポジトリをご覧ください
https://github.com/manntera/DependencyInjectionSamples

複雑なDIになってしまった時

まず、下記のクラス構成を考えてみましょう

  • AppController:アプリの入口
  • NotificationService:ユーザーへの通知
  • UserService:ユーザー取得
  • Mailer:通知を送信
  • TemplateEngine:通知文面を生成
  • Logger:ログ記録
  • ConfigService:環境設定の読み込み
const configService = new ConfigService();
const logger = new Logger(configService);
const templateEngine = new TemplateEngine(configService);
const mailer = new Mailer(configService, logger);
const userService = new UserService(logger, configService);
const notificationService = new NotificationService(
  userService,
  mailer,
  templateEngine,
  logger,
  configService
);
const appController = new AppController(notificationService, logger);

上記コードは一見シンプルに見えるかもしれませんが、
実はメンテ地獄の一丁目が見えてきています。

  • 同じ依存を複数箇所に渡す必要がある
    • ConfigServiceやLoggerを多くのクラスに渡している
  • ネストされた依存の管理が面倒
    • Loggerを作るのに、CnfigServiceが必要
    • 色んなインスタンスを作るのにConfigServiceが必要
    • 更にネストが増えると地獄
  • 依存が増えるたびに コンストラクタに渡すコードが膨れ上がる
    • 既にNotificationServiceのコンストラクタがすごい事になってる
  • 切り替えやテストのたびに、依存関係を見直す必要が出てくる

と、メンテナンス作業が複雑な事になってきます。

要は、DIでは依存の責任を外に逃がす事が可能にはなったが、
依然として複雑な依存管理を行わないといけない状況となっているのです。

依存管理の自動化「DIコンテナ」

そんな複雑な依存管理ですが、なんと自動でやってくれる方法が存在します。
それがDIコンテナです。
DIコンテナのライブラリであるInversifyJSを用いて、先ほどの複雑な依存関係を持ったコードを整理してみましょう。

const container = new Container();
container.bind<ConfigService>(ConfigService).toSelf();
container.bind<Logger>(Logger).toSelf();
container.bind<TemplateEngine>(TemplateEngine).toSelf();
container.bind<Mailer>(Mailer).toSelf();
container.bind<UserService>(UserService).toSelf();
container.bind<NotificationService>(NotificationService).toSelf();
container.bind<AppController>(AppController).toSelf();

const app = container.get(AppController);

このコードは何をしているのでしょうか?
まず、一行ずつ見ていきましょう。

const container = new Container();

ここで、**DIコンテナ(InversifyJSの中心となるオブジェクト)**を作成しています。
このコンテナに、各クラスがどんな依存を持っているのかを教えてあげることで、後から自動的にインスタンスを生成してもらえるようになります。

container.bind<ConfigService>(ConfigService).toSelf();

この行では、ConfigService というクラスをコンテナに登録しています。
toSelf() というのは、「この識別子(=ConfigService)に対応するのはこのクラス自身です」という意味です。

同様に、他のクラスも次々と登録していきます:

container.bind<Logger>(Logger).toSelf();
container.bind<TemplateEngine>(TemplateEngine).toSelf();
container.bind<Mailer>(Mailer).toSelf();
container.bind<UserService>(UserService).toSelf();
container.bind<NotificationService>(NotificationService).toSelf();
container.bind<AppController>(AppController).toSelf();

この時点で、「どのクラスを使うか」だけを宣言していて、実際にインスタンスを作っているわけではありません。

const app = container.get(AppController);

この行で初めて、AppController のインスタンスを取得します。

ただし、ここがDIコンテナのすごいところ。
InversifyJS は AppController のコンストラクタを見て、「あ、このクラスは NotificationService と Logger が必要なんだな」と判断します。
そして NotificationService にもまた依存があることを見て、さらに掘り下げて、必要なインスタンスをすべて再帰的に自動生成してくれるんです。

つまり、get(AppController) という1行で、
最初にあった複雑な new の連鎖すべてを肩代わりしてくれている、というわけです。


要するに…
このコードは、

  • 各クラスがどんな依存を持っているかを コンテナに登録
  • 実際に使いたいクラス(AppController)を 1回だけ取得
  • あとはInversifyJSが、必要な依存をすべて自動で注入してくれる

という流れになっており、

「DIコンテナが依存関係の面倒をすべて肩代わりしてくれる」
という便利さを体感できる実例になっています。

InversifyJSの最低限の使い方

DIコンテナの中でも InversifyJS は、TypeScript 向けに設計されていて、型安全かつデコレーターで直感的に書けるのが特徴です。

ここでは、InversifyJSを使うために最低限覚えるべき構文を紹介します。

@injectable() : クラスをDI可能にするマーク

import { injectable } from "inversify";

@injectable()
class Logger {
  log(msg: string) {
    console.log(msg);
  }
}

InversifyJSに「このクラスは依存注入の対象ですよ」と知らせるマークです。

  • これをつけないと、Inversifyはそのクラスをインスタンス化できません
  • TypeScriptの型情報だけでは足りず、実行時にも情報を持たせるために必要です
  • 裏では Reflect Metadata を使って「依存情報」を記録しています

@inject() : 依存先の識別子を指定する

import { inject } from "inversify";

@injectable()
class UserService {
  constructor(
    @inject("ILogger") private logger: ILogger
  ) {}
}

この @inject() は、「この引数にはコンテナに登録された何を注入するのか?」を明示します。

  • ILogger というinterfaceは JavaScriptには存在しない概念のため、実行時には識別子("ILogger" のような文字列や Symbol)を使います
  • この識別子と実装クラスを bind() 時に関連づける必要があります

TypeScriptの型情報だけでは Inversify は何を注入すればいいか判断できないため、@inject() でヒントを与える、というわけです。


container.bind() : コンテナに依存関係を登録する

const container = new Container();

container.bind<ILogger>("ILogger").to(ConsoleLogger);
  • "ILogger" という識別子で、ConsoleLogger クラスを登録しています
  • これで @inject("ILogger") と書かれた場所に、ConsoleLogger が注入されるようになります
  • .to() は「この識別子に対して、どのクラスを使うか」を指定します

実装を差し替えるときは .to() の引数を変えるだけ。コードを修正せず切り替えられます!

container.get() : インスタンスを取得(=注入を完了させる)

const userService = container.get<UserService>(UserService);
  • UserService のインスタンスを取得しますが、その内部で必要な依存(例: ILogger)はすべて自動で注入されます
  • このとき、Inversifyは UserService のコンストラクタを調べて、@inject() で指定された識別子を元に、登録済みの依存を渡します

interfaceと識別子(stringまたはSymbol)

interface ILogger {
  log(msg: string): void;
}

const TYPES = {
  ILogger: Symbol("ILogger"),
};
container.bind<ILogger>(TYPES.ILogger).to(ConsoleLogger);

class UserService {
  constructor(@inject(TYPES.ILogger) private logger: ILogger) {}
}
  • interface自体は実行時に存在しないため、代わりに識別子を使います
  • 実プロジェクトでは "ILogger" のような文字列ではなく Symbol("ILogger") を使うのが安全
    • なぜなら、文字列は重複しやすくバグの元になるから
  • 識別子は TYPES オブジェクトにまとめるのがベストプラクティス

🧪まとめコード

// types.ts
export const TYPES = {
  ILogger: Symbol("ILogger"),
};

// logger.ts
@injectable()
export class ConsoleLogger implements ILogger {
  log(msg: string) {
    console.log(msg);
  }
}

// userService.ts
@injectable()
export class UserService {
  constructor(@inject(TYPES.ILogger) private logger: ILogger) {}

  doSomething() {
    this.logger.log("Hello from service");
  }
}

// main.ts
const container = new Container();
container.bind<ILogger>(TYPES.ILogger).to(ConsoleLogger);
container.bind<UserService>(UserService).toSelf();

const service = container.get(UserService);
service.doSomething();

✅覚えるべき最低限の使い方

要素 意味と重要性
@injectable() DIコンテナに使えるクラスだとマークする
@inject() 「どの依存を注入するか」を明示する
container.bind() 依存の識別子と実装クラスを紐づける
container.get() DI済みインスタンスを取得する
識別子(Symbol) interfaceの代わりに使う、実行時に一意な依存のキーとなる

DIコンテナのメリット

依存関係の管理が楽になる

  • DIコンテナが依存の解決を自動で行ってくれるので、
    • 深い依存ツリーでも new 地獄にならない
    • 新しいクラスや依存を追加しても、既存コードはそのままでOK

実装の差し替えが一箇所で完結

container.bind<ILogger>("ILogger").to(ConsoleLogger);
// 本番環境では
// container.bind<ILogger>("ILogger").to(FileLogger);

このように、どの実装を使うかの切り替えは、1ヶ所だけ。
if文で分岐したり、呼び出し元を探しまわる必要はありません。

依存関係が明示的になる

各クラスは constructor で必要な依存だけを宣言します。
どのクラスが何に依存しているかが一目で分かり、設計の見通しがよくなります。

まとめ

観点 DIコンテナなし DIコンテナあり
依存の管理 手動で new 自動で注入される
実装切り替え コード内で if 分岐 コンテナ設定で一元管理
可読性 依存関係が見えにくい コンストラクタで明示的

おわりに

こんな具合で、DIContainerを導入すると…

  • 依存の解決を自動化し、new地獄から脱出できる
  • 実装の切り替えが容易になり、柔軟な設計が可能に
  • クラスごとの責務がより明確になり、保守しやすくなる

などなど、注入周りの恩恵を得る事が出来る感じです。

Discussion