追記
2022/11/12 追記
この記事読んで、DI 便利だなって思ったらこちらも併せて読んでみてください。クリーンアーキテクチャーの開設の中で依存性逆転の説明が出てきます。難しいかもしれませんが、一度理解すればつぶしが効く考え方なので腰を据えて読んでみてください。
本文
ここでは、最近のそこそこの規模のアプリだと大体使われてる(と私は思ってる)Dependency Injection(DI)について、何故使ってるのか?というのを私の理解で書いていきたいと思います。
今回の対象言語は C# ですが、DI 使ってる言語であれば大体同じ事情なのかなと思います。
単体テストしたいよね
アプリケーションを作るとうまく動いているかテストをすると思います。
たとえ、そのアプリがハローワールドだとしても動かして目視で確認してると思います。
もうちょっとアプリの規模が大きくなってくるとクラス単位やクラスのメソッド単位でのテストもしたくなってきます。俗にいう単体テストというやつですね。いちいちアプリを起動して、そのクラスを使う画面まで移動してテキストを入力したりしてボタンを押さないと動かせないのはテスト効率が悪いので、テストコードを書いて実行して緑のバーが出たらテスト成功!そういう環境を作っておくと何かと便利になります。
コードを変更したときにテストを実行してグリーンだったら少なくともテストコードを書いてる範囲内についてはバグってることは、ほぼないという感じなので安心感が違います。
単体テストをするというのは、世の中全体としてみても「単体テストなんて不要だ!しないほうがいい!」という人はかなり少数派だと思うので、単体テストは出来るならする方が良いというのは多くの人にとって合意してもらえることなのかなと思います。
単体とは…?
テスト書いてみましょう。例えば以下のようなクラスがあるとします。単純ですね。足し算するだけです。
class Calc
{
public int Add(int x, int y) => x + y;
}
色々な単体テストフレームワークがありますが、大体 Assert というクラスを使ってメソッドの結果が思った値と同じか確認します。大体以下のようになります。
var calc = new Calc();
Assert.Equal(10, calc.Add(8, 2)); // 8 + 2 は 10 になるはず
でも 1 クラスで完結するようなものはなかなかありません。大体クラス内で別のクラスを使ってます。
例えば以下のような感じで DataGenerator クラスで生成されたデータに対して Aggregator クラスで集計(とりあえず合計)するような感じです。
public class DataGenerator
{
public int[] Generate() => new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
}
public class Aggregator
{
public int Sum()
{
var dataGenerator = new DataGenerator();
return dataGenerator.Generate().Sum();
}
}
Aggregator を単体テストしようとすると DataGenerator が必要ですよね。
今回は DataGenerator は固定値を返すので問題になりませんが例えばインターネット上のデータをもとにデータを生成したり、日付に応じて返すデータが違う場合はどうでしょう?例えばこんな感じです。
public class DataGenerator
{
public int[] Generate() => new[] { DateTime.Now.Year, DateTime.Now.Day, DateTime.Now.Second };
}
現実には以下のように配列に年月日などの情報を入れて返すようなことはないと思いますが DB の情報によって返す値が変わったり、Web API の結果によって戻り値が変わったり、特定の状態(しかも、その状態になるには、そこそこの手順を踏まないといけない)じゃないとテストしたい値を返さないなど、その時の状況によって結果が異なるようなものが実際には色々あると思います。
そうなると Aggregator のテストはどうすればいいでしょうか?Aggregator は DataGenerator が返す値を集計するのが仕事です。集計処理をテストしたいのに、依存先クラスの実装にひっぱられてテストができないという問題が起きます。
解決方法!!
手段は色々あります。例えばテストしたいロジックだけをメソッドに切り出してテストを行う。
public class Aggregator
{
public int Sum()
{
var dataGenerator = new DataGenerator();
return SumInternal(dataGenerator.Generate());
}
// こっちをテストする
public int SumInternal(int[] data) => data.Sum();
}
内部ロジックを public にする点が Aggregator の利用者にとって優しくないですね…。あまりイケてません。
ここら辺から DI の話になってきます。Aggregator は データを Generate 出来る人がいればいいのですが、これが DataGenerator クラスに固定化されているためにテストがしづらいという問題が起きています。じゃぁクラス固定じゃなくて Generate 出来る人であればいいということを表す interface を使うように変更してみましょう。
public interface IDataGenerator
{
int[] Generate();
}
public class DataGenerator : IDataGenerator
{
public int[] Generate() => new[] { DateTime.Now.Year, DateTime.Now.Day, DateTime.Now.Second };
}
Aggregator は IDataGenerator であれば OK なようにしたいのですが、メソッド内で DataGenerator クラスを new してると実装を切り離すことが出来ないですよね…。
public class Aggregator
{
public int Sum()
{
// インターフェースを導入しても実装を new してたら意味がない…
IDataGenerator dataGenerator = new DataGenerator();
return dataGenerator.Generate().Sum();
}
}
じゃぁ new するのは諦めて外部から受け取るようにしましょう。Aggregator を使う人に責任を押し付けます。
public class Aggregator
{
// DataGenerator の実装を new するのは外部にお任せ
public Aggregator(IDataGenerator dataGenerator)
{
DataGenerator = dataGenerator;
}
private IDataGenerator DataGenerator { get; }
public int Sum() => DataGenerator.Generate().Sum();
}
これでやっと Aggregator がテストできるようになりました。こんな感じですね。
public class UnitTest1
{
// テスト用の DataGenerator
private class MockDataGenerator : IDataGenerator
{
public int[] Generate() => new[] { 1, 2, 3 };
}
[Fact]
public void Test1()
{
// テスト時はテスト用の DataGenerator を使う
var aggregator = new Aggregator(new MockDataGenerator());
// テスト用の DataGenerator は固定値を返すのでテスト出来る
Assert.Equal(6, aggregator.Sum());
}
}
こんな感じで内部で依存先を new するのではなく、外から依存先の実装を設定してもらうという考え方が Dependency (依存性) Injection (注入) になります。Dependency Injection とインターフェースを組み合わせて実装を入れ替える仕組みを入れることで、クラス単体でテストが可能になります。やったね。
例え、1 回呼ぶのに 100 万円かかるような Web API があったとしても、今回のように呼び出す処理を行うクラスに対して interface を定義しておいて、実装を差し替えるようにすれば OK ですね。
interface IReallyExpensiveService
{
string Call();
}
class ReallyExpensiveService: IReallyExpensiveService
{
public string Call()
{
// 1 回呼ぶのに 100 万円の外部サービスを呼んでるとする
}
}
class MockReallyExpensiveService : IReallyExpensiveService
{
public string Call()
{
// テストのときはテスト用の結果を返す実装でいいよね
return "テスト用の結果";
}
}
すべて解決?
こんな感じで、テスト時に差し替え可能に出来たほうが嬉しいものに対して interface を定義して実装を外部から設定するようにするといい感じですが、クラスの利用者側視点から見ると割と最悪です。例えば Aggregator を使う場合は Aggregator の他に DataGenerator を new しないといけないです。
以下のような感じですね。
var aggregator = new Aggregator(new DataGenerator());
もうちょっと複雑になってくると、1 つのオブジェクトを組み立てるのに、こんな風にコードを書かないといけなくなるかもしれません。
var x = new ProductOrderService(
new ProductRepository(
new PostgreSQLConnectionProvider(
new SettingsProvider(),
)
),
new OrderManager(
new CustomerManager(
new CustomerDataAccessor(new SettingsProvider()),
)
),
);
ちょっとこれをやるのは現実的ではないですね…。
DI コンテナ
ということで、オブジェクトの依存関係を外部から設定するようにすると、オブジェクトを組み立てるのが大変になるので、そこを省力化しようというライブラリが登場してきます。
それが DI コンテナです。ほとんどの DI コンテナは、このインターフェースには、この実装クラスを使ってくれというルールを設定しておくと、いい感じにルールに従ってオブジェクトを組み立ててくれます。
イメージとしては以下のような感じです。
var c = new Container(); // Container クラスが DI コンテナだとする
// アプリの起動時あたりで、以下のようにアプリで使うインターフェースと実装クラスを登録していく
c.RegisterType<ISettingsProvider, SettingsProvider>();
c.RegisterType<IDbConnectionProvider, IPostgreSQLConnectionProvider>();
c.RegisterType<IProductRepository, ProductRepository>();
c.RegisterType<ICustomerDataAccessor, CustomerDataAccessor>();
c.RegisterType<ICustomerManager, CustomerManager>();
c.RegisterType<IOrderManager, OrderManager>();
c.RegisterType<IProductOrderService, ProductOrderService>();
// インスタンスが欲しいときは、コンテナから取得
var productOrderService = c.Resolve<IProductOrderService>();
こうすることで、DI コンテナがコンストラクターの引数とかから依存先を解決していって綺麗に組み立てたオブジェクトを返してくれます。便利。
実際にはフレームワークに DI コンテナが組み込まれていることが多くてコンテナのインスタンスを自分で作ったり、コンテナから自分でインスタンスを明示的に取得することは少ないです。利用者がするのは、インターフェースと実装クラスの対応を登録するだけというのが多いです。
まとめ
DI を何故するのか?ということでさくっとまとめましょう。
- 単体テストを容易にするためにインターフェースに依存するようにクラスを作る
- 依存先の実装はコンストラクターなどで外部から渡してもらうようにする
- ↑のように作るとオブジェクトの組み立てが大変になるので、それを省力化するための DI コンテナがある
という感じなので、個人的な感想としては DI しなくても単体テストさえ可能で現実的な労力で実装可能な方法があれば、別に DI コンテナとか DI 使わなくてもいいかなぁとは思ってます。
ただ、黒魔術的なことをしない限りは C# などの静的な型付け言語だとめんどくさいので、今のところおとなしく DI を使うのが現実的です。
おまけとして、DI コンテナを使うことでオブジェクトの生成時に付加価値を追加するようなことも出来るので、フレームワークを提供する側から見ても便利だったりします。まぁ、それは今回の主題とは関係ないのでまた今度。