体系的に依存性の注入を理解する
この記事は、マネーフォワード関西拠点 Advent Calendar 2021 の17日目の投稿です🎄
16日目は iwami yuma さんの記事でした!
はじめに
主にオブジェクト指向かつ静的型付け言語のフレームワークでアプリケーションを開発していると、「依存性の注入」という概念をよく見かけます。
フレームワークがよしなに吸収してくれてなんとなく使えてるけど、正しく使えているのか、そもそもなんでこういう考え方があるのか気になることはありませんか?そしていざ調べてみると、なんとなくわかったようでわかってない、みたいなこともあると思います。実際にわたしがそうでした!
こんなことがわかります
ここではできるだけ体系的に、依存性の注入(DI / Dependency Injection) や DI Container を紐解いています。
- 体系的に依存性の注入の理解を深める
- そもそもの課題
- DI、DI Container
- DI を知る上で必要な概念
- 依存性逆転の原則
- 制御の逆転
- フレームワークを利用した DI の実装
- Kotlin + Spring Boot での実装方法
- DI のスコープについて
- 注入の種類(コンストラクタ・セッター・フィールド)
- 他に DI で知ってるといいこと
- Service Locator
- 疎結合にしすぎるデメリット
この記事は2部構成です
書いてるとボリューミーになり、概念の説明と実装部分で大項目が2つに別れているので、この記事では 「体系的に依存性の注入の理解を深める」に焦点を当てて記載しています。
後半の「フレームワークを利用した DI の実装」「他に DI で知ってるといいこと」についてはこちらの記事をご覧ください!
そもそもなんで依存性を注入するのか
依存性の注入の話に入る前に、そもそも解決したい課題のお話をします。
解決したいことは一言でいえば「モジュール同士を密結合にせずに、疎結合を保って変更に対して柔軟にする」ことです。
オブジェクト指向の設計において、SOLID という考え方がありますよね。ここでは全ては触れませんが、SOLID の中に「依存性逆転の原則 (Dependency Inversion principle)」というのがあります。
依存性の注入は、依存性逆転の原則から繋がってくるソフトウェアパターンになります。なので、まずは依存性逆転の原則についてお話しします。
依存性逆転の原則 (Dependency Inversion Principle) ってなに
まずは Wikipedia を読んでみます。
- 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
- 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。
https://ja.wikipedia.org/wiki/依存性逆転の原則
・・・。言葉だとよくわからないので、図にします。
ここでは「保管している書籍の一覧を取得する」という超簡単なプログラムを例にします。
便宜的に Application(実行クラス) が Service を呼び出し、Service が Repository を呼び出す、というフローにします。
依存性逆転の原則に従っていない場合は例えば以下のようになります。
この時、それぞれのモジュールが依存している密結合な状態になります。いわゆる、上位モジュールが下位モジュールに依存している状態です。密結合の何が困るかというと、
- BookRepository が修正されると BookListService も修正する必要がある(かもしれない)
- BookListService の単体テストをするのに BookRepository が参照するデータも用意する必要がある。そうすると BookRepository が修正されるたびに BookListService にも影響してしまう
などです。
これを解決するには疎結合な状態にする必要があります。この手法が「依存性逆転の原則」です。引用した以下の部分です。
- 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。
これを実現すると、以下のモジュール設計になります。
(BookListService, BookRepository は抽象クラス、Impl は抽象の実装クラスと読み替えてください)
この時に以下の点に注意する必要があります。
2.抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。
つまり、BookRepository が BookRepositoryImpl ありきで作られていると(依存していると)、間接的に BookListService は BookRepositoryImpl に依存していることになってしまいます。
なので、先に抽象(インターフェース)を要件に応じてメソッドやその引数を定義しておき、その抽象を実装する方向(実装が抽象に依存している状態)にすることが重要です。
上記は単純な例ですが、抽象を実装するクラスは複数になるケースも多いです。実装クラスベースの切り抜きで抽象が作られていると、うまく上位モジュールとして定義されていない状況が生まれてしまいますので注意が必要です。
これによって、各モジュールが疎結合な状態を維持できます。
で、何が嬉しいかというと、先ほどお話しした内容を解決できます。
BookRepository が修正されると BookListService も修正する必要がある(かもしれない)
BookRepositoryImpl が修正されたとしても、インターフェースが変わらない限りは BookListService に影響しません。
また、
BookListService の単体テストをするのに BookRepository が参照するデータも用意する必要がある。そうすると BookRepository が修正されるたびに BookListService にも影響してしまう
BookListService は BookRepository インターフェースに依存しています。なので、インターフェースを満たすモックを実装してあげて、単体テストではそのモックを参照するようにしてあげると、他のモジュールを気にせずにテスタビリティが確保できます。
これが依存性逆転の原則のいいところです。
では、インターフェースを実装したクラスを実際に使用するにはどのようにすればいいのでしょうか。
実はこの手段に、依存性の注入があります。
依存性を注入する
ここまでで抽象によってモジュールを疎結合に保つメリットについてお話ししてきました。前述した通り、疎結合に保つ方法として、依存性の注入があります。
依存性の注入というと何やらイメージが難しいのですが、言い換えると「オブジェクトの代入」です。
(英語版Wikipedia では dependency injection is a technique in which an object receives other objects that it depends on
と説明されています)
一気に簡単に思えてきましたね!
上記の書籍一覧の例でコードを書いてみます。
(サンプルコードには kotlin 1.6.0 を利用しています)
密結合なコード
class Application { val allBooks = BookListService().getAllBooks() allBooks.forEach { println(it) } } class BookListService { fun getAllBooks() : List<String> { return BookRepository().getAll() } } class BookRepository { fun getAll(): List<String> { return File("booklist.txt").readLines() } }
依存性逆転の法則を適用したコード
class Application { val bookRepository = BookRepositoryImpl() val bookListService = BookListServiceImpl(bookRepository) val allBooks = bookListService.getAllBooks() allBooks.forEach { println(it) } } interface BookListService { fun getAllBooks() : List<String> } class BookListServiceImpl( private val bookRepository: BookRepository ): BookListService { override fun getAllBooks() : List<String> { return bookRepository.getAll() } } interface BookRepository { fun getAll(): List<String> } class BookRepositoryImpl: BookRepository { override fun getAll(): List<String> { return File("booklist.txt").readLines() } }
インターフェースに依存することで、疎結合になっています。
例えば BookListService の単体テストを実装するには、コンストラクタ受け取る bookRepository
にモックデータを返却する BookRepositoryMock
クラスを作ってインスタンスを代入することでてスタビリティを確保できます。
依存性の注入とは
先ほど依存性の注入は「オブジェクトの代入」だと言いました。 上記の例だと、 BookListServiceImpl
は依存するオブジェクトである BookRepository
を受け取っています。言い換えれば、オブジェクトを代入しています。
このパターンが依存性の注入 (Dependency Injection) です。
DI Containerってなに
これまでで依存性の注入について紹介してきました。しかし、実務で依存するオブジェクトを代入するのはめちゃくちゃ大変ですよね。
大量のファイルに対して代入しないといけないし、何が何に依存しているのか把握するのが大変です。
ということで、ここで登場するのが DI Container です!
DI Container は依存性の注入をサポートしてくれるソフトウェアパターンです。
DI Container は、DI 対象のクラスのインスタンスを保持し、指定の変数に生成したインスタンスを代入してくれる機構です。
そしてあともう一つ、大事な概念があります。制御の反転(Inversion of Control)です。
制御の反転 (Inversion of Control) ってなに
前述したサンプルコードでは Applicaiton
という実行クラスが DI する役割を持っていました。制御の反転(以下 IoC)は今回の例に当てはめてざっくりいうと、DI する役割をフレームワークに持たせることです。
もう少し具体的にイメージするために Wikipedia から概要を抜粋します。
https://ja.wikipedia.org/wiki/制御の反転
従来式のプログラミングでは、例えば、あるアプリケーションのmain関数が、メニューライブラリ内の関数を呼び出して、利用可能なコマンドの一覧を表示し、その中のどれか一つの機能をユーザーに選ばせる。 一方、制御の反転を使うと、このプログラムは、汎用的な振舞いやグラフィック要素を持っているソフトウェアフレームワークを使って書かれることになるだろう。そうしたフレームワークには、たとえばウィンドウシステムや、メニュー、マウス制御などが既に組み込まれている。個別に開発するコードは、フレームワークの「空白部分を埋める」ものになる。たとえば、メニュー項目の一覧を与えるとか、それぞれのメニュー項目に対応するサブルーチンを登録するといったものだ。
つまり、アプリケーションが main 関数で実行していくのではなく、アプリケーションが実装したサブルーチンを、フレームワークが実行することです。これが「制御を反転させる」意味です。
DI と IoC を組み合わせることでより強力な武器になるわけですね。
おわりに
今回は依存性の注入とその周辺の概念について整理してきました。知っている方にとっては当たり前な内容だったかもしれませんが、少しでもご理解に役立っていると嬉しいです。逆にあまり知らなかった方は、概念的な話が多く具体的なイメージが持ちづらかったかもしれません。
後編の以下の記事ではフレームワークを用いたサンプルコードを掲載し、より具体的な実装に踏み込みますので合わせてご覧ください!
そして マネーフォワード関西拠点 Advent Calendar 2021 明日は shinya inoue さんです!お楽しみに!
参考にさせていただいた記事
Inversion of Control Containers and the Dependency Injection pattern
Inversion of Control コンテナと Dependency Injection パターン
Service LocatorとDependency InjectionパターンとDI Container - nuits.jp blog