へっぽこプログラマがDIを解説してみる(C#)


DI(依存性の注入)とは?

Wikipediaの冒頭では以下のように説明されています。

依存性の注入(Dependency injection)とは、コンポーネント間の依存関係をプログラムのソースコードから排除するために、外部の設定ファイルなどでオブジェクトを注入できるようにするソフトウェアパターンである。英語の頭文字からDIと略される。

https://ja.wikipedia.org/wiki/%E4%BE%9D%E5%AD%98%E6%80%A7%E3%81%AE%E6%B3%A8%E5%85%A5

これを読んでもへっぽこの頭には???がつくばかり。 DI? 何それ美味しいの? 状態です。
へっぽこなので具体例を示されないと理解できません。

Webサイトを渡り歩いてみても、難しいことが書いてあるばかりで理解できませーん。
そんなわけで、苦労の末にへっぽこがつかんだDIの本質(?)についてまとめてみたいと思います。

DIを使わない場合のサンプル

まず、非DIのサンプルプログラムを掲載し、それを元に思考をすすめたいと思います。

class MyServer
{
    public void Execute()
    {
        // Loggerをnewする(Loggerに依存している)
        var logger = new Logger();

        logger.Log($"{DateTime.Now}");
    }
}

class Logger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

このサンプルではMyServerクラスの中でLoggerクラスを生成(new)しています。
言い換えると、MyServerクラスはLoggerクラスに依存していることになります。

何が問題?

「別にこれでも問題ないんじゃね?」と思ってしまいますが、何が問題なのでしょうか?
このサンプルではLoggerはログをコンソール画面に出力していますが、あるときログをファイルに出力したくなったらどうしましょう?

方法はいくつか考えられます。

  1. Loggerクラスを改変する(Logメソッドの中身を書き換える)
  2. 新たにFileLoggerクラスを作成する

方法1は直観的ですが、元に戻すにはLoggerクラスのソースを再度修正しなくてはなりません。
方法2でもいいような気もしますが、MyServerクラスでnewするオブジェクトを変更しなくてはなりません。

// 方法2のプログラム
class MyServer
{
    public void Execute()
    {
        // FileLoggerをnewする(newするクラスを変更)
        var logger = new FileLogger("log.txt");

        logger.Log($"{DateTime.Now}");
    }
}

class FileLogger
{
    private readonly string _path;

    public FileLogger(string path)
    {
        _path = path;
    }

    public void Log(string message)
    {
        File.AppendAllLines(_path, new string[] { message });
    }
}

どちらの方法も状況によって元のクラスのソースコードを改変しなくてはならず、ちょっといただけません。

じゃ、どうすりゃいいの?

MyServerクラスもLoggerクラスも改変せずに、ログの出力先や出力内容を変えるにはどうすればいいのでしょうか?

ここでDIの出番です。

MyServerが依存する機能(LoggerやFileLogger)を、MyServerに外部から与えてやるのです。

ただし、これを実現するには関連する各クラスを、あらかじめDIできるように作っておく必要があります。

DIのサンプル

まず各種Loggerクラス用のインターフェイスを作ります。

interface ILogger
{
    void Log(string message);
}

このインターフェイスを実装する形でConsoleLogger(Loggerから改名)とFileLoggerを作成します。

class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : ILogger
{
    private readonly string _path;

    public FileLogger(string path)
    {
        _path = path;
    }

    public void Log(string message)
    {
        File.AppendAllLines(_path, new string[] { message });
    }
}

次にMyServerクラスを作成します。
コンストラクタの引数にILoggerインターフェイスの引数を持たせるのがミソです。
もらったLoggerオブジェクトはメンバ変数に保存しておき、使用する際(Executeメソッド内)で利用します。

class MyServer
{
    private ILogger _logger;

    // コンストラクタでILoggerを実装するオブジェクトをもらう
    public MyServer(ILogger logger)
    {
        // メンバ変数に退避
        _logger = logger;
    }

    public void Execute()
    {
        // コンストラクタでもらったLoggerオブジェクトのLogメソッドを呼び出す
        _logger.Log($"{DateTime.Now}");
    }
}

最後にMyServerを呼び出すプログラムを作ります。

class Program
{
    static void Main(string[] args)
    {
        // 使用したいLoggerをnewする
        ILogger logger = new ConsoleLogger();

        // もしFileLoggerを使用したい場合は次のようにする
        // ILogger logger = new FileLogger("log.txt");

        MyServer server = new MyServer(logger);
        server.Execute();
    }
}

ここで使いたいLoggerをnewしてMyServerのコンストラクタに渡します。
上の例ではConsoleLoggerクラスをnewしていますが、ファイルにログを出力したい場合はnewするオブジェクトをFileLoggerにします。

使用するLoggerを変更する場合はProgramクラスを修正する必要はありますが、MyServerクラスや各種Loggerクラスを修正する必要はありません。

また、新たにログをデータベースに出力したくなった場合でも、ILoggerインターフェイスを実装したDatabaseLoggerクラスを作りMyServerクラスのコンストラクタに渡すだけで済みます。

クラス図を描いて比べてみる

まず非DIの場合は下図のようになります。MyServerとConsoleLogger(またはFileLogger)が密接に結びついているため、Loggerの実装を変更するのは簡単ではありません。

次にDIの場合は次のようになります。

MyServerはILoggerインターフェイスにだけ依存しているため、ILoggerの実装(ConsoleLoggerやFileLoggerやそれ以外のLogger)を変更しやすいです。ILoggerの実装は外部からMyServerに渡してやります。

DIの種類

DIは依存するオブジェクトの渡し方によって分類することができます。上の例ではコンストラクタに依存するオブジェクトを渡しているので、コンストラクタインジェクションと呼ばれています。

  1. コンストラクタ インジェクション
    コンストラクタに依存するオブジェクトを渡す方法(上の例)

  2. セッター インジェクション
    setterに依存するオブジェクトを渡す方法

  3. インターフェイス インジェクション
    メソッドの引数に依存するオブジェクトを渡す方法

オブジェクトを受け渡す場所が変わるだけです。特別なことはありません。

DIのメリット

  • 機能の切り替えが容易になる
  • それゆえに単体テストがやりやすくなる

DIのデメリット

  • コードが増える、複雑になる
  • DIに対応したクラスをnewするときには、そのクラスが使用するオブジェクトもnewしなくてはならなくなる
  • 抽象化された分だけ、プログラムの見通しが悪くなる

つまるところDIって?

DIとはある機能(オブジェクト)を使いたいクラスがあったとき、そのオブジェクトをクラス内部でnewするのではなく、クラスの外から渡してもらい、それを使うデザインパターンのことです。

外から渡してもらう際に、オブジェクトの具象クラス(ConsoleLoggerやFileLoggerのこと)ではなく、抽象化されたインターフェイス(ILoggerのこと)を渡してもらうことで、機能のカスタマイズ(MyServerのカスタマイズのこと)が容易になります。

DIを用いることで、本体のソースコードを変更することなく、使用する機能を切り替えることができるようになります。

また、DIを「依存性の注入」と訳すのが間違いで、「オブジェクトの注入」と訳すのが正しいという見解もあるようです。私にはどちらが正しいのかはわかりませんが、個人的には「オブジェクトの注入」と言われた方がすんなりと理解できる気がします。

DIの使いどころ

なんでもかんでもDIすればいいってわけじゃないと思います。そんなことしたら、プログラムが煩雑になって仕方がありません。

やはり、切り替える可能性の高い機能に絞ってDIを使う、というのがいいのではないでしょうか。

あとは単体テストをしやすくするためにDIを使うというのも大きな動機になるみたいです。単体テストに関しては個人的には造詣が深くないので(汗)、別途考察してみたいと思います。

まとめ

はじめてDIって聞いたとき「なんじゃそりゃ?」って思ってびびってたのですが、理解してみれば「似たようなことは昔からやってたなー」っていう感じのものでした。

上の例のようにログの出力先を変えるとか、あるいは取得したデータの種類によって解析方法を変えるとか、インターフェイスを使ってよくやりますよね? …えっ、やらない?

間違い、勘違い等あるかもしれませんがご容赦ください。なにぶん、へっぽこなもので。
この資料が何かの参考になれば幸いです。