あなたも使ってるDIから理解するDIパターン


こう言うと「私はDIコンテナーなんて使っていない!アンチDIだ!」とおっしゃる人もいるかもしれません。

まぁ落ち着いてください。今回は、DIコンテナーは登場せず、Dependency Injection(以後DI)パターンのお話です。

DIパターンとは、つぎのようなものだと私は考えています。

「依存性を外部から注入することで、ふるまいを変更する設計パターン」

詳細はコードを見つつ解説します。コードはC#で記載していますが、だれでも読めるレベルです。たぶん言語が違えど似たようなコードは誰もが書いたことがあるはずです。

ということで、さっそく見ていきましょう!

あなたも使っているDIパターン

お題

「何らかのリソースから文字列を読み取り、コンソールに出力する」

もうネタはバレたかもしれません。

ひとつめのDI

さて、まずはローカルストレージ上のテキストファイルを読み込んでコンソールに出力してみましょう。以下のコードをご覧ください。

class Program
{
    static void Main(string[] args)
    {
        // ローカルの「README.txt」ファイルを読み取り専用で開く
        using var stream = new FileStream("README.txt", FileMode.Open);
        WriteConsole(stream);
    }

    static void WriteConsole(Stream stream)
    {
        // ストリームから文字列を読みだすため、StreamReaderを生成する
        using var reader = new StreamReader(stream);
        Console.WriteLine(reader.ReadToEnd());
    }
}

シンプルなコードですが、明らかにDIパターンが適用された設計になっています。詳しく見ていきましょう。

ここでは代表的なクラスとして、Program、FileStream、Stream、StreamReaderの4つのクラスが登場します。それらの関係は、つぎのようになっています。

FileStreamはファイルへの入出力を提供するStreamの実装クラスです。

Streamは何らかのリソースへの入出力を提供する「ストリーム」を表す抽象クラスです。Streamは必ずしもテキストリソースだけを扱う訳ではなく、画像などのバイナリソースも扱うため、バイト列をもちいます。

StreamReaderクラスは、Streamからバイト列を取得し、デコードして利用者に文字列を提供します。StreamReaderクラスはバイト列をどのリソースからどのように取得するのか、一切関与しません。そのため抽象的なStreamのみに依存し、実装クラスであるFileStreamには依存しません。

Programクラスはこれらを組み合わせて、ローカルファイルを読み取ってコンソールへ出力しています。

StreamReaderに、抽象的な依存性(Stream)を注入しており、紛れもなくDIパターンが採用されています。

ふたつめのDI

さて、ある時ローカルファイルではなく、Web上のリソースをコンソール出力したくなったとします。

そこで、あなたはつぎのようにコードを書き換えました。

class Program
{
    static async Task Main(string[] args)
    {
        //using var stream = new FileStream("README.txt", FileMode.Open);

        // HttpClientを利用してURL「https://www.google.com/」上のリソースを開く
        using var httpClient = new HttpClient();
        await using var stream = await httpClient.GetStreamAsync("https://www.google.com/");
        WriteConsole(stream);
    }

    static void WriteConsole(Stream stream)
    {
        // ストリームから文字列を読みだすため、StreamReaderを生成する
        using var reader = new StreamReader(stream);
        Console.WriteLine(reader.ReadToEnd());
    }
}

FileStreamの生成をコメントアウトし、HttpClientのGetStreamAsyncメソッドを利用して、指定アドレスからバイト列を読み取るためのStreamを非同期に取得します。

クラス間の関係はつぎのようになっています。

ここでもHttpClientから取得された抽象的なStreamを、StreamReaderに注入しており、DIパターンが踏襲されていることが見て取れます。

あなたも使っているDI

こんなパターンのDIであれば、あなたも一度は利用したことがあるのではないでしょうか?

実際、こういったパターンの設計は標準ライブラリにもよく見られます。オブジェクト指向言語をつかっている方であれば、どこかでDIをつかっているはずです。

別に構えるほど特別なものではないことに、共感いただけるのではないでしょうか。

ところで、本エントリーではDIコンテナーは登場しません。こんな言葉はありませんが「手組みDIパターン」です。DIコンテナーはDIパターンを利用するための道具であって、DIパターンを構成する必須要素ではありません。

あらためてDIパターンとは何か?

DIパターンの特徴

DIパターンとは、抽象的な依存性を、外部から注入することで、ふるまいを変える設計パターンです。

DIパターンの目的

DIパターンで、ふるまいを変える「目的」はつぎのものを得るためです。

  • 再利用性
  • 拡張性
  • 保守性(レイヤー間の疎結合など)
  • テスト容易性

など、ほかにもあります。英語のWikiが良くまとまっているので見てみるのも良いでしょう。

これらの目的を実現するための代表的なひとつの「手段」がDependency Injection Patternです。

DIパターンと同じ目的を実現する他の手段

もちろん手段はひとつではありません。

DIパターンの対抗となる代表的なパターンはService Locatorパターンです。これはDIが誕生した当初から議論されていることです。FactoryなどもService Locatorと大きな違いはありません。

これらを比較したときのメリット・デメリットは簡単には語り切れませんが、ここでは代表的なケースについて簡単に記載します。

DIのデメリット

Service Locatorと比較したとき、「難しい」ことだと私は思っています。習熟するとその難しさから遠ざかってしまいがちですが、そこから目をそらすべきではないでしょう。

Service Locatorパターンは依存先のオブジェクトを利用する箇所で、依存先のオブジェクトを構築(もしくは取得)します。

対してDIパターンでは、依存先オブジェクトを利用する個所と、依存先オブジェクトを構築する個所が分離しています。

これがDIパターンを難しくしている本質です。

Service Locatorパターンでは普通にオブジェクトをnewして利用する代わりに、Service Locatorから取得して利用するだけで、そこに大きなパラダイムの変化はありません。これはDIと比較して「簡単な」解決策です。

DIのメリット

逆にService Locatorでは解決できないケースもあります。そして今回のケースはこれに該当します。

DIやService Locatorの目的は「抽象的な依存性を切り替えることにより、ふるまいを変えること」です。

しかしService Locatorの場合、ふるまいを変えられる「幅」に、DIよりも制限があります。

「ファイルとWeb上のリソースを読み取るためのリーダークラス」はService Locatorパターンでも作れるかもしれません。ファイルのアドレスもURLも文字列ですしね。

しかし「開発対象のシステム専用のBLOBストレージに格納された、バイナリオブジェクトを読み取れるよう拡張できるStreamReaderクラス」を作ることはService Locatorパターン単独で解決することは難しいでしょう。DIよりトリッキーなコードか、Service Locator(依存性)をInjectionするか、いずれか必要になりそうです。もちろんこの例のStreamReaderであれば、専用のStreamさえ作れば簡単に実現できます。

Service Locatorの難しさ

DIは難しいと書きましたが、逆にService Locatorの方が難しくなるケースもあります。

とくにユニットテストでは顕著です。

Service LocatorでMockを解決しようとした場合、依存オブジェクトの利用箇所から分離された箇所で、Mockに差し替える必要があります。これは先に書いたDIの難しさとまったく同じものです。とはいえ、DIよりはそれらの個所は近いです。

またテストケースをマルチスレッドで実行したいといった場合、Service Locatorをマルチスレッド対応する必要があります。Thread-Specific Storageパターンを利用して解決できるでしょうけど、「難しい」話しです。

まとめ

  • DIパターンとは、依存性を外部から注入することで、ふるまいを変えるパターンです
  • DIパターンの目的は、以下を得ることです
    • 再利用性
    • 拡張性
    • 保守性(レイヤー間の疎結合など)
    • テスト容易性
    • などなど
  • 多くはService Locatorパターンなどで代替が可能ですが、代替できないケースもあります
  • 依存性の利用箇所だけ見ると、Service Locatorパターンの方が簡単です
  • テストを考慮するとDIの方が簡単なこともよくあります
  • DIコンテナーはDIパターンをサポートするツールで、DIパターンそのものではありません

結局は使い分けなんですが、個人的にはService Locatorじゃないといけない場合を除き、DIパターンを利用することが多いです。「慣れれば」そんなに難しいものではないですし、過去のXML Hellみたいなことは現代のDIにはありませんしね。

ということで以上です。よいDIライフを!