すぎしーのXRと3DCG

主にXR, Unity, 3DCG系の記事を投稿していきます。

Dependency Injection解説 と C#における実装例

概要

Dependency Injection解説 と C#における実装例を紹介しようと思います。
C#を使っていますが考え方は他のオブジェクト指向プログラミング言語でも応用できます。

次回の記事はUnityEditor向けDependency Injection講座の予定です!

DIの本質

DIの本質は以下のように言えます。

抽象オブジェクトに依存させ、その依存オブジェクトを注入によって柔軟に切り替え可能にする

上記のことを意識しながら以下の解説を読んでください。

DIに関連の深いキーワード

  • 依存注入
  • 疎結合
    • 実体ではなく抽象と結合していること
  • 抽象への依存

Dependency Injection の例

Dependency Injectionは直訳すると「依存注入」となります。
どうやって依存注入を実現するのか簡単な例を紹介します。

DIを使用しないパターン

以下のMessengerクラスはRecvメソッドを経由してメッセージを受け取る構成です。

using System;

public class Messenger
{
    public void Recv(string message)
    {
        Console.WriteLine(message);
    }
}

このMessengerクラスはConsole.WriteLIneを直接コーディング(ハードコーディング)しており、そのメソッドが属する名前空間Systemが必要となります。

これはSystem.Console.WriteLine蜜結合している状態と言えます。

DIを使用するパターン

上記MessengerのDI対応をしましょう。
本質で述べたように抽象オブジェクトに依存させます

DIを実装する場合はよくinterfaceが使用されます。 edrfvb 今回は「stringを受け取って何かをする」interface ILogger を用意します。

public interface ILogger
{
    void Log(string message);
}

そして、MessengerにはSystem.Console.WriteLineではなくILoggerに依存させましょう。

public class Messenger
{
    private ILogger _logger;

    // コンストラクタでILoggerの実体を受け取る
    public Messenger(ILogger logger)
    {
        _logger = logger;
    }

    public void Recv(string message)
    {
        // ILogger.Logをコール
        _logger.Log(message);
    }
}

Messengerクラスのコードは多くなりましたが、以下の特徴を持つようになりました。

  • Recvの動きはILogger.Logに依存
  • System.Console.WriteLineへの蜜結合が解消

DI対応したMessengerは依存するILoggerをコンストラクタ経由で注入されるようになりました。
これが「依存注入(Dependency Injection」と呼ばれる理由です。

実体ではなく抽象オブジェクトに依存させるため、Loggerとは疎結合している状態と言えます。

Loggerを実装して注入

Console.WriteLineを実施するLogger

これはいわゆる「DIを使用しないパターン」と同じ挙動となるLogger

using System;

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

ちなみに以下のように使用します。

var injectedLogger = new ConsoleLogger();
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

コンソールに"Hello, Messenger!"と出力されます。

ファイルにメッセージを追記していくLogger

指定されたパスのファイルにmessageを追記しているLogger

public class FileLogger : ILogger
{
    private string _filePath;

    public FileLogger(string filePath)
    {
        _filePath = filePath;
    }

    public void Log(string message)
    {
        File.AppendAllText(_filePath, message);
    }
}
var injectedLogger = new FileLogger(@"C:\test.txt");
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

C:\test.txt"Hello, Messenger!"と追記されていきます。

メッセージを無視するLogger

あえて何もしないLogger

public class DummyLogger : ILogger
{
    public void Log(string message) {}
}
var injectedLogger = new DummyLogger();
var messenger = new Messenger(injectedLogger);
messenger.Recv("Hello, Messenger!");

DummyLogger.Logは何もしないため、messenger.Recvをコールしても何も起きません。
何に使うかというと、Messenger.Recvで何もさせたくないとき、もしくはさせることがないときに使います。

Messengerのコンストラクタにnullを渡すとRecv時にNullReferenceExceptionが発生してしまいます。
何もさせることがないけどnullを渡すとまずいときにDummyLoggerのような何もしないLoggerを渡してやれば問題は起きません。

依存注入でnullが渡された場合の対策

今回作成したMessengerですが、nullを渡されたときに問題が起きないようにしておいた方が安全です。
_loggerがnullの時は処理を無視する方法ももちろん可能ですが。。。

public void Recv(string message)
{
    _logger?.Log(message);
}

今回はせっかくDummyLoggerを作ったので、個人的にはコンストラクタを以下のように修正するのがオススメです! loggerにnullが渡された場合はDummyLoggerを使用させます。

public Messenger(ILogger logger)
{
    _logger = (logger != null) ? logger : new DummyLogger();
}

C#にはコンパイラレベルでNull非許容型などがありませんので、現状はルール化、もしくは上記のような対応が必要になります。
KotlinなどのようなNull Safetyの概念が入れてば不要になると思います。

DIの利点

DIを導入することの利点をいくつか紹介します

SOLID原則における「open/closed principle」を満たせる

平たく言うと柔軟にクラスの挙動をクラス自体を修正することなく変更できるようになります。

  • Messengerの挙動の追加はLoggerを作成して注入することで対応(拡張に対してOpen)
  • Messenger自体の修正は不要(修正に対してClose)

テスタビリティ(Testability)を満たせる

UnitTestなどのテストコード作成への対応が可能になります。
(逆にDIを知らない場合はUnitTestやテスタビリティのあるクラス設計が難しい気がします)。

例えば以下のようにMessenger.Recvにnullが渡されたときは動作が無視されるとしましょう。

public class Messenger
{
    public void Recv(string message)
    {
        if (message == null) {
            return;
        }

        Console.WriteLine("" + message);
    }
}

if文による条件分岐のテストはコードを実行してわざわざ人力でコンソールを見ないといけません。
今時そんな事やってられないので、UnitTestに対応させてテストを自動化させます。

そんなときもDIに対応させておくことで、UnitTestにも柔軟に対応できます!

public class Messenger
{
    private ILogger _logger;

    // コンストラクタでILoggerの実体を受け取る
    public Messenger(ILogger logger)
    {
        _logger = logger;
    }

    public void Recv(string message)
    {
        if (message == null) {
            return;
        }

        _logger.Log(message);
    }
}

DIの種類

DIの実装方法はいくつかあります。
都合の良い方法で実装してあげてください。

Constructor Dependency Injection

コンストラクタでDIを行う方法です。
上記で説明したDIはこちらになります。

インスタンス生成後は依存の切り替えはなしということを明示します。

※コードは省略

Method/Property Dependency Injection

メソッドやプロパティ経由でDIする方法です。

public class Messenger
{
    private ILogger Logger { get; set; }

    public void Recv(string message)
    {
        Logger.Log(message);
    }
}

インスタンス生成後でも依存の切り替え可能ということを明示します。

雑感

今回はDIの解説を入れてみました。
本当はUnityにおけるDIを解説したかったんですが、DIの話が長くなったので分割することにしました。

そもそもDIに関する記事って普通に多いのでいらないんじゃね?とも思ったんですが、自分なりの解説記事も出してみたくなったので作ってみました。

皆さんのエンジニアリングの参考になれば幸いです。

本題は次回の「UnityEditor向けDependency Injection講座」です!
お楽しみに~