embryo

エンジニアの備忘録

DIの目的とメリットについて

今日はDependency Injection(以下DI)の目的を整理しました。 できるだけ目的やメリットをベースにまとめていきます。

tl:dr

  1. DIはオブジェクトを外から渡すこと → モジュールは依存するオブジェクトの依存解決(生成)方法を意識しない
  2. 抽象に依存することにより疎結合に → モジュールは依存するオブジェクトの実装の方法を意識しない
  3. 2に静的解析が組み合わさると大変便利

依存するオブジェクトの参照を解決する方法

DIの目的は特定のモジュールの振る舞いと依存解決の方法を分けることです。 今回は例としてapiサーバーにアクセスするためのクライアントクラス(以下APIClient)を実装します。 APIClientは内部でhttp通信を行うためのクラスを参照する必要があるものとします。

1. 内部で生成する

export class APIClient {
  httpClient: HTTPClient;
  constructor() {
    this.httpClient = new HTTPClient(baseURL: "https://example.com");
  }
  request(config) {
    this.httpClient.request(config)
  }
}

内部で必要なプロパティを生成する方法です。APIClientの振る舞いを変更する方法は内部の実装を変える必要がある他、 HTTPClientのConstructorに変更があった場合に、APIClientの実装を変更する必要があります。

2. (Static)Factoryパターンを使って取得する

export class APIClient {
  httpClient: HTTPClient;
  constructor() {
    this.httpClient = HTTPClientFactory.create();
  }
}

export class HTTPClientFactory {
  static httpClient: HTTPClient
  
  static setInstance(httpClient: HTTPClient) {
    this.httpClient = httpClient;
  }
  
  static getInstance() {
    if (!!this.httpClient) {
      return this.httpClient;
    }
    return this.httpClient;
  }
}

Factoryパターンを用いることで、APIClientの実装とHTTPClientの生成を切り離すことに成功しました。 HTTPClientの生成に変更があった場合もAPIClientに変更を加える必要はありません。 実装の切り替えはFactoryクラスのgetter, setter経由で行います。

ただしこのコードにはいくつかの問題があります。

  • グローバルにインスタンスを保持しているため、どこからでも書き換えられる
  • モジュール内部で依存解決を行なっているため、実装を見ないと依存関係がわからない
  • Factoryクラスへの依存が発生する

3. DI(Consturcutor Injection)を使う

export class APIClient {
  constructor(private client: HTTPClient) {}
}

上記はConstructor経由でオブジェクトを渡すConstructor Injectionと呼ばれます。 APIClientは渡されるオブジェクトを使用するだけなので、依存解決の方法や依存オブジェクトの生成方法を意識する必要がありません。 また、依存関係はConstructorに全て宣言されているため、依存解決を検証しやすいというメリットがあります。

4. DI(Lazy Injection)を使う

export class APIClient {
  setClient(client: HTTPClient) {
    this.httpClient = client
  }
}

依存オブジェクトが得られるタイミングがモジュールの生成タイミングと異なるケースや、後から実装を差し替えたいケースがあります。 このような場合にはsetter経由で依存オブジェクトを渡します。

この場合生成時にパラメータが初期化されているか分からないので、あくまでConstructor Injectionを実装した上で必要な場合に実装するのが良いです。

DIと抽象化プログラミング

APIClientの依存する対象はHTTPClientとは限りません、典型的な例で言えばテストの場合、実際にHTTP通信を行って データを返却する必要はありません。そのため、HTTPClientの振る舞いはそのままに実装を変えたいケースがあります。 以下のコードではHTTPClientの振る舞いと実装を分割しています。

interface APIBackend {
  request(config)
}

class HTTPClient: APIBackend {
  request(config) {
    ...
  }
}

class MockClient: APIBackEnd {
  request(config) {
    ...
  }
}

export class APIClient {
  client: APIBackend;
  constructor(private client: APIBackend) {
  }
}

上記のようにAPIClientが依存する機能をAPIBackendとして定義することで、APIClientが依存するオブジェクトを容易に変更できます。 APIClientは自身が必要な機能だけを意識して実装するため、実際に渡される依存オブジェクトとは疎結合になります。 つまり、APIClientのテストを書く場合、APIClientで書いているロジックのみをテストすれば良いことになります。

DIと静的解析

静的解析は必ずしも必要なわけではありませんが、実行前に依存解決の検証ができるので、安全にプログラミングができます。

依存対象に変更があった場合、影響するモジュールは容易に判別可能ですし、いわゆるnull許容のような型の解析をサポートしている言語では 依存するオブジェクトが渡されているタイミングも示すことが可能であるため、開発時にコードから様々な情報を得ることができます。

以下の例ではclientインスタンスはモジュール生成タイミングで必ず渡されているので安全に使用できますが、 authTokenは生成タイミングでは存在しないため、内部でも気をつけて取り扱う必要があることがわかります。

export class APIClient {
  authToken: string?

  constructor(private client: APIBackend) {
  }

  setToken(authToken: string) {
    this.authToken = authToken;
  }
}
よく混同されがちなDI Containerの話

DIを用いることでそれぞれのモジュールは疎結合になりますが、実際にはそれぞれの依存を解決するレイヤーが必要になります。 DIはデザインパターンであって、依存グラフを解決する仕組みではありません。そこで、それらを解決する仕組みの一つがDI コンテナです。 詳しくは検索すれば良い記事が沢山転がっていますのでそちらをどうぞ。

まとめ

DIの目的について順番に解説しました。実際の現場では抽象化や静的解析と組み合わせて使用されることがほとんどですが、 あくまで各モジュールを疎結合に保つためのデザインパターンなので、言語によらず意識的に実践することで結果的に全体の設計が綺麗になっていくかと思います。 対象となるスコープも小さいので導入も簡単ですね。