コンテンツへスキップ

【C#】Dependency Injection(依存性の注入)とは

  • C#

今回の記事はDependency Injection(依存性の注入)について。

設計についての学習を始めると「依存性の逆転」「Dependency Injection(依存性の注入)」「DIコンテナ」など言葉を耳にする機会が増えてくると思います。適切にDIを扱えるようになると設計の柔軟性が飛躍的に向上するため、是非とも習得したい知識です。

今回は「そもそもDependency Injection(依存性の注入)とは何なのか」という話から始め、サービスロケータやDIコンテナについてまで解説していきたいと思います。

またこの記事ではSOLID原則(特に依存性の逆転)等の知識を前提として進めるため、設計何もわからん!という方は以下の記事から読むことをお勧めします。記事の後半で依存性の逆転について触れているため、その辺りを押さえてからDIについて学ぶと良いでしょう。

DIとは何か?

依存性の注入(いぞんせいのちゅうにゅう、: Dependency injection)とは、あるオブジェクトや関数が、依存する他のオブジェクトや関数を受け取るデザインパターンである。英語の頭文字からDIと略される。DIは制御の反転の一種で、オブジェクトの作成と利用について関心の分離を行い、疎結合なプログラムを実現することを目的としている。

依存性の注入とは何かを明確に説明するのは難しいですが、ざっくりした意味合いとしては「オブジェクトが依存するオブジェクトをインターフェースとして受け取る実装パターン」のことを指します。これは言葉で書いてもわかりづらいので、実際にコードを見ながら進めていきましょう。

例として以下のようなクラスを考えます。

public class MessageWriter
{
    public void Write(string message)
    {
        // 受け取ったメッセージをConsoleに書き出す
        Console.WriteLine("Message: " + message);
    }
}

そして、このクラスを他の適当なクラスが利用するとしましょう。

// 適当なクラス
public class FooService
{
    // 内部でMessageWriterをnew()して保持する
    private readonly MessageWriter _messageWriter = new();
    
    public void Execute()
    {
        // Foo!と書き込む
        _messageWriter.Write("Foo!");
    }
}

一見問題はなく、実際普通に動作するコードではあります。しかし、このような実装方法は設計上あまり良くありません。

このFooServiceは内部でMessageWriterクラスを直接new()して利用しています。言い換えると「FooServiceがMessageWriterに依存している」状態です。このようにクラス同士が密結合したコードは柔軟性に劣るため様々な問題を引き起こします。

例えば、もし書き込み部分をMessageWriter以外の実装で置き換えたい場合、直接FooServiceを書き換える必要があります。今回の場合FooServiceの責務は「Foo!と書き込む」ことだけであり、それ以外の理由でFooServiceが変更されることは単一責任の観点からも良くありません。

またMessageWriter自体が依存関係を含んでいる場合、これをFooService内で構築する必要が生じます。それ以外の場所でもMessageWriterを利用するのであれば、その都度MessageWriterを構成するコードを書く羽目になります。

さらにテスタビリティの低さも問題です。このコードではFooServiceにモックのWriterを挟むことができないため、単体テストが困難になってしまいます。

とまあ、このような問題があるため直接他のクラスに依存するような実装は避けるべきです。ではどうするかというと、インターフェースを定義してそれを渡すことで依存関係を逆転させます。

// メッセージの書き込み用にインターフェースを定義
public interface IMessageWriter
{
   void Write(string message);
}

// IMessageWriterを実装した具象型を作成
public class MessageWriter : IMessageWriter
{
    public void Write(string message)
    {
        Console.WriteLine("Message: " + message);
    }
}

こんな感じでインターフェースを定義しましょう。

そして、これをコンストラクタでFooServiceに外部から渡してあげます。これがいわゆる依存性の注入(DI)と呼ばれる実装パターンです。

public class FooService
{
    private readonly IMessageWriter _messageWriter;

    // コンストラクタでIMessageWriterを受け取る
    public FooService(IMessageWriter messageWriter)
    {
        this._messageWriter = messageWriter;
    }
    
    public void Execute()
    {
        // Foo!と書き込む
        _messageWriter.Write("Foo!");
    }
}

あとはFooServiceを生成する際に任意のIMessageWriterを実装したクラスを渡せばOKです。このように依存するオブジェクトを外部から注入することで、FooServiceを書き換えることなくMessageWriterの実装を差し替えることが可能になります。

以上の例のようにDIを用いることで結合度を下げ、より柔軟性の高い設計を実現することができます。

誰がインスタンスを渡すのか

ただし現実には上の例のように「インターフェース定義してコンストラクタで渡せばDIができて一件落着」とはいきません。実際にはどこかでコンストラクタに具象型を渡さなければならないからです。

// 結局どこかで具象型をnew()して渡す必要がある
var messageWriter = new MessageWriter();
var service = new FooService(messageWriter);

これくらい単純な例であれば問題にならないかもしれませんが、複雑な依存関係になると手動で解決するのはかなり厳しくなります。

// 必要なサービスを手動で作成する
var serviceA = new ServiceA();
var serviceB = new ServiceB();
...

// コンストラクタに一つずつ渡す (めんどくさい!)
var mainService = new(serviceA, serviceB, ...);

このように都度コンストラクタで全てのサービスを渡すのは非現実的であるため、何らかの方法でこれを自動化する必要が生じてきます。そのため、これを解決する手段として「サービスロケーター」と「DIコンテナ」が存在します。

サービスロケーターを作成する

サービスロケーターとは依存先のオブジェクトの解決を行う責務を担うクラスのことです。決まった実装があるわけではありませんが、多くのものは専用のstatic classを定義して型をキーにした解決を行います。

簡易的なServiceLocatorを作成するならこんな感じでしょうか。

using System;
using System.Collections.Generic;

public static class ServiceLocator
{
    private static readonly Dictionary<Type, object> _container = new();

    /// <summary>
    /// 型引数をキーにインスタンスを取得する
    /// </summary>
    public static T Resolve<T>()
    {
        return (T)_container[typeof(T)];
    }

    /// <summary>
    /// 使用するインスタンスを登録する
    /// </summary>
    public static void Register<T>(T instance)
    {
        _container[typeof(T)] = instance;
    }

    // 登録解除やコンテナのクリア等の処理
    ...
}

これにあらかじめ使用するインスタンスを登録しておき、必要な時にResolveで取り出します。

// あらかじめどこかでインスタンスを登録しておく
ServiceLocator.Register<IMessageWriter>(new MessageWriter());

// Resolveでインスタンスを取得する
var writer = ServiceLocator.Resolve<IMessageWriter>();

サービスロケーターの問題点

このように一見便利なサービスロケーターですが、実際に利用しようとするといくつかの問題が生じることがあります。いくつか考えられる問題点を挙げてみます。

DIパターンが適用できず、依存関係が見えにくい

サービスロケーターを用いると依存性の注入が明確ではなくなり、実際にどのサービスが利用されているのかがコードから追いづらくなってしまいます。

例えば先ほどのFooServiceをサービスロケーターを用いて実装してみます。

public class FooService
{
    private readonly IMessageWriter _messageWriter;

    public FooService()
    {
        // ServiceLocatorからIMessageWriterを取得する
        this._messageWriter = ServiceLocator.Resolve<IMessageWriter>();
    }
    
    public void Execute()
    {
        // Foo!と書き込む
        _messageWriter.Write("Foo!");
    }
}

コンストラクタからIMessageWriterを渡す必要がなくなり簡単に依存関係の解決が行えるようになりました。手動でDIを行うよりもはるかに手軽で、これで問題は解決したように思えます。

ここで、このFooServiceを利用する側に立って考えてみます。このFooServiceは引数なしのコンストラクタを持つため普通にnew()して利用できます。

// そのままnew()して使えそうだが...?
var service = new FooService();
service.Execute();

しかし事前にServiceLocatorにMessageWriterが登録されていないため、このコードを実行すると例外が発生します。

// KeyNotFoundExceptionが発生する
var service = new FooService();
service.Execute();

このコードの問題はコンパイルが普通に通ってしまうということです。これがコンストラクタで明示的にDIを行う方式であれば、new()の時点でコンパイルエラーが発生して気づくことができます。

// コンストラクタで渡すのであれば、そもそもこの行がコンパイルエラーになる
// var service = new FooService();

// MessageWriterを渡せば問題ない
var service = new FooService(new MessageWriter());

ServiceLocatorクラスへの依存

本来DIパターンを用いるのであれば、依存関係の解決には対象のオブジェクトさえあれば十分でした。しかし、サービスロケーターを介して解決を行うことでそれ自体への余計な依存が生じます。

コンストラクタで依存先を渡す形式をとっておけば手動でDIを行うこともできますが、先ほどのFooServiceのように内部でサービスロケーターに依存したサービスがある場合、そのサービスを利用する側もサービスロケーターを使用しなければならなくなってしまいます。

単体テストがしづらい

基本的にサービスロケーターはstatic classのような形で実装され、グローバルなスコープで依存関係の解決を行います。そのため複数のテストを走らせる際に制御が煩雑になる可能性があります。

先ほどのServiceLocatorの実装ではstaticなdictionaryにインスタンスが保持されるため、Clear()のようなメソッドを用意して毎回それを呼び出す必要があります。それだけなら大した問題ではありませんが、テストがマルチスレッドである場合などには意図せぬ動作を引き起こすことも考えられます。

このようにサービスロケーターにはいくつか厄介な点があるため、使用を避けることが推奨される傾向にあります。実際「Service Locator is An Anti-Pattern」のような記事が書かれることがあるくらいです。

実行速度や手軽さなどの利点もあるため一概にアンチパターンと言い切れないとも思いますが、基本的にはDIコンテナの方を使用するべきでしょう。

DIコンテナを利用する

先ほど述べたようにサービスロケーターは便利ですが、いくつかの問題点が残されています。DIコンテナは依存関係を外部で一元管理することでこれを解決します。

DIコンテナを使ってみる

DIコンテナの実装は複雑であるため、使用する際には外部ライブラリを利用します。ここでは.NET標準のDIコンテナであるMicrosoft.Extensions.DependencyInjectionを例に説明していきます。こちらについてはドキュメントに詳しい説明が載っているので、そちらも参照してみると良いでしょう。

それでは、先ほどのFooServiceをDIコンテナを用いて実装していきましょう。

public class FooService
{
    private readonly IMessageWriter _messageWriter;

    // コンストラクタでIMessageWriterを受け取る
    public FooService(IMessageWriter messageWriter)
    {
        this._messageWriter = messageWriter;
    }
    
    public void Execute()
    {
        // Foo!と書き込む
        _messageWriter.Write("Foo!");
    }
}

FooService側は手動でDIを行ったときと同じように、コンストラクタからIMessageWriterを受け取る形で実装を行います。

Microsoft.Extensions.DependencyInjectionの場合、まずはServiceCollectionを作成してコンテナを構築します。と言ってもやることは簡単で、使用するクラスの型を登録していくだけです。

// 新たなServiceCollectionを作成する
var services = new ServiceCollection();

// Singletonスコープで型を登録
services.AddSingleton<IMessageWriter, MessageWriter>();
services.AddSingleton<FooService>();

追加する際にはスコープというものを指定しますが、これについては後述します。

必要な型を登録し終えたら、このServiceCollectionからServiceProviderを作成します。このServiceProviderを介して必要なサービスを取得することができます。

// ServiceProviderを作成
var provider = services.BuildServiceProvider();

// GetService()で必要なサービスを取得する
var fooService = provider.GetService<FooService>();

// GetRequiredService()を用いると、型が登録されていなかった場合にnullではなく例外をスローする
// var fooService = provider.GetRequiredService<FooService>();

これで依存関係が解決されたサービスが自動で作成されるため、取り出したFooServiceはそのまま利用することができます。

// DIコンテナによって依存関係の解決が行われている
fooService.Execute();

インスタンスのスコープ

DIコンテナのもう一つの役割として、オブジェクトのライフサイクルを管理するというものがあります。オブジェクトのライフサイクルはそれぞれ異なるため、対応したスコープで管理する必要があります。一般的にDIコンテナには以下の3つのスコープが用意されており、都度使い分ける必要があります。

・Singleton

SingletonスコープではDIコンテナ内に単一のインスタンスを保持し、それを注入します。そのため同じ型の依存関係を要求する全ての場所で同じインスタンスが共有されます。
その性質上、アプリケーション全体で共有されるようなインスタンスを管理する場合に利用します。

Transient

Transientスコープでは、DIコンテナは依存の解決を要求するたびに新しいインスタンスを生成します。同じクラスの異なるインスタンスが必要な場合にはこちらを利用します。

Scoped

Scopedスコープは特定のスコープ毎に新しいインスタンスを生成します。Microsoft.Extensions.DependencyInjectionの場合にはIServiceProvider.CreateScope()を呼び出すことでスコープを作成できます。
これはアプリケーションがリクエストごとにインスタンスを生成し、そのリクエスト内でのみそのインスタンスを共有するような状況において利用されます。

インジェクションの種類

DIコンテナでは、依存先を注入する方法としていくつかの種類があります。これらは使用するDIコンテナのライブラリによってサポートされているもの/そうでないものがあるため注意してください。

・コンストラクタインジェクション

最も一般的なインジェクションの方法で、先ほどのFooService同様コンストラクタから依存先のオブジェクトを注入します。Microsoft.Extensions.DependencyInjectionはこのコンストラクタインジェクション以外をサポートしていないため、他のインジェクション方法を使用したい場合は別のライブラリを使用しましょう。

・メソッドインジェクション

こちらはコンストラクタではなくメソッド経由で依存先を注入します。存在するメソッドに何でもかんでも注入されては困るので、C#の場合は[Inject]のような属性で明示的に指定する場合が多いです。

・プロパティ/フィールドインジェクション

こちらはさらにアグレッシブなインジェクト方法で、プロパティやフィールドに直接依存先を注入します。こちらも勝手に注入されては大変なことになるので、多くの場合は属性で対象のプロパティ/フィールドを明示します。

ライブラリによっては様々なインジェクション方法がサポートされますが、可能な限りコンストラインジェクションを利用することが推奨されます。これはDIコンテナなしでも同じDIパターンとして収まるため、単体テストなどの際にDIコンテナを経由せずとも手動でインスタンス化が可能になるからです。
特にプロパティ/フィールドインジェクションなどは外部から手動で注入できなくなるため、DIコンテナ以外からの生成が難しくなります。

とはいえ記述量の少なさを考えるとプロパティ/フィールドインジェクションの利点もあるため、完全にDIコンテナに依存することを良しとするのであれば許容できる場合もあります。どちらを優先するかは状況や規約によって決定するべきでしょう。

サービスロケーターパターンを避ける

DIコンテナはサービスロケーターのような使用方法も可能になっています。例えばMicrosoft.Extensions.DependencyInjectionの場合には以下のような使い方をすることもできます。

using Microsoft.Extensions.DependencyInjection;

public class BarService
{
    private readonly IFooService _fooService;

    // コンストラクタでIServiceProviderを注入
    public BarService(IServiceProvider serviceProvider)
    {
        // ServiceLocatorのような使い方ができてしまう
        _fooService = serviceProvider.GetService<IFooService>();
    }
}

が、これはサービスロケーターパターンと呼ばれるアンチパターンであり、DIコンテナを使うのであれば可能な限り避けるべきです。Microsoftのドキュメントでも非推奨であることが書かれています。

非推奨である理由は、サービスロケーターの問題点で述べたものと全く同じことが起こってしまうからです。直接DIが使用できる場合には、このような書き方は避けるようにしましょう。

using Microsoft.Extensions.DependencyInjection;

public class BarService
{
    private readonly IFooService _fooService;

    // コンストラクタで直接IFooServiceを注入する
    public BarService(IFooService fooService)
    {
        _fooService = fooService;
    }
}

.NET/Unity向けのDIコンテナ

今回はMicrosoft.Extensions.DependencyInjectionを例に説明を進めてきましたが、C#で書かれたDIコンテナは他にもたくさん存在します。ここではいくつか有名なDIコンテナを紹介していきたいと思います。

また、.NET向けのIoCコンテナ(DIコンテナなどの依存性逆転を実現するフレームワークの総称)のパフォーマンス比較を行なっているリポジトリもあります。.NET向けの多くのIoCコンテナを網羅しているため、使用するライブラリを選ぶ際にはこちらも参考にするといいかもしれません。

Microsoft.Extensions.DependencyInjection

先ほどから使ってきた.NET標準のDIコンテナです。dotnet/runtimeリポジトリに組み込まれており、ASP.NET Core等のフレームワークでも使用されています。

// 新たなServiceCollectionを作成する
var services = new ServiceCollection();

// Singletonスコープで型を登録
services.AddSingleton<IMessageWriter, MessageWriter>();
services.AddSingleton<FooService>();

// ServiceProviderを作成
var provider = services.BuildServiceProvider();

// GetService()でFooServiceを取り出す
var fooService = provider.GetService<FooService>();

非常にシンプルな構成であり、DIコンテナとしての最低限の機能のみを持っています。これでも十分と言えば十分ですが、メソッドインジェクション等のより高機能なDIコンテナが欲しければ別のライブラリを使用することになるでしょう。

Simple Injector

Simple Injectorは名前の通りシンプルな構成のDIコンテナです。必要十分な機能を備え、パフォーマンス面も良好です。

// コンテナを作成
container = new Container();

// コンテナに型を登録
container.RegisterType<IMessageWriter, MessageWriter>(Lifestyle.Singleton);
container.RegisterType<FooService>(Lifestyle.Singleton);

// 登録を検証する
container.Verify();

var fooService = container.GetInstance<FooService>();

詳細はこちらから確認できます。

Unity Container

ゲームエンジンのUnityと紛らわしいですが、こちらは.NET向けのDIコンテナになります。速度面では他のライブラリにやや劣りますが機能面は充実しています。

// コンテナを作成
IUnityContainer container = new UnityContainer();

// コンテナに型を登録
container.RegisterType<IMessageWriter, MessageWriter>(TypeLifetime.Singleton);
container.RegisterType<FooService>(TypeLifetime.Singleton);

// FooServiceを取り出す
var fooService = container.Resolve<FooService>();

DIコンテナとしてフルセットな機能が揃っており、Microsoft.Extensions.DependencyInjection用の拡張も用意されています。機能の詳細はマニュアルから確認できます。

Zenject (Extenject)

こちらは.NET向けではなく、Unity(ゲームエンジンの方)向けのDIコンテナです。Unity用に調整された豊富な機能が特徴となっています。

ZenjectではInstallerというものを通して型の登録を行います。

using UnityEngine;
using Zenject;

public interface IFooService { }
public class FooService : IFooService { }

// 対象のMonoBehaviour
public class ExampleBehaviour : MonoBehaviour
{
    // フィールドインジェクション
    [Inject] private IFooService _fooService
}

// Installerを作成
public class ExampleInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<IFooService>() // [Inject]がついているIFooService型のフィールドに
            .To<Example>() // FooServiceクラスのインスタンスを注入する
            .AsTransient(); // スコープをTransientに指定
    }
}

使用する際はSceneContextをシーン上に配置し、作成したInstallerを追加することで動作するようになります。

Zenjectのリポジトリは諸事情により分裂しており、今ではExtenjectというForkされたリポジトリで保守されています。Extenjectの方が積極的に更新されているためそちらの方を利用すると良いでしょう。(Unity向けのDIコンテナであれば、個人的には次のVContainerをお勧めします。)

VContainer

こちらもZenjectと同じくUnity向けのDIコンテナとなっています。Zenjectと比較するとVContainerはパフォーマンスに優れており、必要十分な機能を厳選したシンプルなDIコンテナとなっています。

VContainerではLifetimeScopeというコンポーネントを作成することで型の登録を行います。

using VContainer;
using VContainer.Unity;

public class ExampleLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        // LifetimeScopeのConfigure内で型の登録を行う
        builder.Register<IFooService, FooService>(Lifetime.Singleton);
    }
}

使用する際はこれをアタッチしたGameObjectをシーン内に配置します。

VContainerはZenjectのようにシーン内のオブジェクトに自動でInjectするのではなく、LifetimeScope内で明示的にInjectするスタイルをとっています。

[余談] Dependency Injectionの訳

Dependency Injectionという言葉は「依存性の注入」という日本語訳が浸透していますが、実はこれはあまり適切な訳ではありません。実際この名称がDI周りの混乱を招いているように感じます。

そもそもこの場合のDependencyの意味としては「依存関係」「依存対象のもの」を指すようなニュアンスであり、「依存性」という日本語を当てはめるのはやや不自然です。

実際にMicrosoftのドキュメントでは「依存関係の挿入」と訳されており、他の記事や書籍等では「依存オブジェクトの注入」と訳されることもあります。(この辺りの名称不一致が余計混乱する要因になってたりもしますが…)

まとめ

今回はDIに関する記事でしたが、いかがだったでしょうか。DI周りはやや抽象的でわかりづらく習得するのは大変ですが、一度覚えてしまえば非常に強力な武器になります。特に複雑な依存関係を持つ設計を扱う際には、DIコンテナは欠かせないものとなるでしょう。

DIを使いこなし、より良い設計ができるようになっていきましょう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です