本記事のターゲット
- DI の基本を理解しているが、もう少し理解を深めたい
- アプリ開発(Android/iOS)で DI を採用したことがある。あるいは、これから採用しようとしている
キーワード
- Dependency Injection
- Service Locator
- Property Injection
- Constructor Injection
- Composition Root
はじめに
まず、英語版 Wikipedia の Dependency Injection の記事に面白い喩え話が載っているので、引用したい。
Dependency injection for five-year-olds
When you go and get things out of the refrigerator for yourself, you can cause problems. You might leave the door open, you might get something Mommy or Daddy don't want you to have. You might even be looking for something we don't even have or which has expired.
What you should be doing is stating a need, "I need something to drink with lunch," and then we will make sure you have something when you sit down to eat something.
John Munsch, 28 October 2009.
「5歳児向けの依存性の注入」
冷蔵庫から自分で物を取り出すと、問題が起こることがあります。ドアを開けっ放しにしてしまったり、ママやパパが取らないでほしいものを取ってしまったりするかもしれません。また、私たちが持っていないものや、賞味期限が切れているものを探してしまうこともあります。
あなたがすべきことは、ニーズを伝えることです。「ランチに飲むものが欲しい」と。そうすれば、食事をするときにあなたが何かを飲むことができるように、私たちがきちんと準備しておきます。
これは、stack overflow の質問"How to explain dependency injection to a 5-year-old?"に対する John Munsch 氏の回答である。
class Kid {
private val food: Food = RottenBanana()
}
上記は DI の入門編の最初に出てくる悪い例を真似たものなので、解説は省略する。
この喩え話に出てくる「冷蔵庫」はクラスライブラリ全体を指していると思われるが、
「冷蔵庫」を「Service Locator」や「DI Container」に置き換えるとどうだろうか。
class Kid {
private val food: Food = Refrigerator.get<Food>()
}
// or
class Kid {
private val food: Food = ServiceLocator.resolve<Food>()
}
// or
class Kid {
private val food: Food = Container.resolve<Food>()
}
食べ物の具象クラスへの依存が無くなったので最初のコードより改善されたように見えるが、
代わりにServiceLocator
(Container
)という別の実体との結合が発生してしまっている。
Service Locator は、グローバルにアクセス可能な、アプリケーションの実行に必要な型情報やインスタンスが登録されたレジストリであり、
利用者たる個々のクラスがそのレジストリに能動的にアクセスし、オブジェクトを取得する設計パターンを Service Locator パターンという。
Service Locator ≒ DI Container だが Service Locator パターン ≠ DI パターン
DI を知っている人が Service Locator の説明を読んだら、これは DI Container のことだと思うだろう。
その認識はおそらく正しいが、Service Locator パターンと DI パターンは同じではない。
しかし、Service Locator を扱う文献では設計パターンを指して Service Locator と呼ぶこともあるので、関連文献を読むときは注意が必要だ。
Service Locator パターンの問題点
5歳児の喩え話になぞらえるなら、Service Locator パターンは、親があらかじめ冷蔵庫に安全な食べ物だけを入れておき、好きなときに子供に取り出させるようなものだろう。
一見合理的だが、Service Locator パターンにも問題はある。
テスタビリティ
Service Locator は通常、プロセスの起動時に構築され、グローバルな領域に配置される。
あるクラスの単体テストを書きたくなったら、そのクラスが何に依存しているかをソースコードを読んで正しく把握し、
適切なモックオブジェクトを取得できるように、Service Locator を再アレンジする必要がある。
このようなテストは Constructor Injection (後述)を用いたクラスのテストよりも複雑で労力が大きい。
もしアレンジした Service Locator を元に戻すのを忘れたら、後続のテストケースの結果に悪影響を及ぼす。
5歳児が冷蔵庫を開けっ放しにしたり、玩具の食べ物を入れて兄弟たちを困らせるようなものだ。
基本的に、単体テストはグローバルな状態に触らない方が良い。そうせざるを得ないテストもあるが(例えば、データベースのテスト)、基本的には避けるべきだ。
参照透過性
専門用語を借りると、Service Locator に依存するコードには参照透過性が無い。
つまり、計算結果が入力(引数)だけでなく Service Locator の状態に左右される。
これはテスタビリティと密接に関係している。
振る舞いを理解しやすいクラスは、
- コンストラクタの結果が引数によってのみ定まり、
- インスタンスが不変で、
- インスタンスメソッドの結果が引数(
this
も含む)によってのみ定まり、 - 依存オブジェクトも上記と同様の性質を持つ
という特徴を持ち、そのようなクラスは単体テストも容易で理解しやすい。
Service Locator に依存するクラスは上記の性質のいずれかを満たせなくなり、単体テストのための複雑なお膳立てを必要とする。
広範に渡る Service Locator との結合
アプリケーションのありとあらゆるクラスが Service Locator を知り、依存することになる。
これは前述のテスタビリティに関する欠点ほど重大ではないが、サードパーティの DI Container に直接依存している場合に問題となる。これについては後述する。
この欠点には比較的簡単な軽減法がある。
我々はinterface
やprotocol
を定義して事物を抽象化し、単体テストを容易にしたり、実行環境を差し替えたりしている。
だから、Service Locator も抽象化しようという発想だ。
interface ServiceLocator {
fun <T> resolve(): T
}
class FavoriteServiceLocator constructor(
private val container: FavoriteContainer
) : ServiceLocator {
override fun <T> resolve(): T {
return container.resolve<T>()
}
}
このときのポイントは、設定(register
)と利用(resolve
)のインターフェースを分離することだ。
利用者には利用のインターフェースだけを公開すべきだ。
設定のインターフェースを全体に公開してしまうと、冷蔵庫を荒らす5歳児がどこかに現れるリスクがある。
.NET 向けのフレームワークである Prism はこの方式を採用している。
Prism は自前の DI Container を持っておらず、DryIocを利用しているが、プラグインになっており、その気になればいつでも別の DI Container に乗り換えることができる。
(Prism はかつて Unity Container を使っていたが、開発終了してしまった。ここから得られる教訓は大きい。いま使っている(使おうとしている) DI Framework が5年後も健在か、そうでないとしたら何が起こるか、想像してみたほうがいい)
ニーズを伝える = 依存の表明 = DI
そろそろ5歳児がお腹を空かせて怒り出す頃合なので、喩え話の後半に焦点を移そう。
あなたがすべきことは、ニーズを伝えることです。「ランチに飲むものが欲しい」と。そうすれば、食事をするときにあなたが何かを飲むことができるように、私たちがきちんと準備しておきます。
つまり、自身のクラスが何に依存しているかを表明し、自身ではなくそのクラスの利用者に依存の解決を依頼せよという意味であり、すなわち DI の基本思想である。
DI にはいくつか方法があるが、ここでは Property Injection と Constructor Injection にのみ触れる。
Property Injection : 無口な5歳児
Property Injection は、Java における Setter Injection (あるいは Field Injection)に相当する。
class Kid {
lateinit var food: Food
lateinit var drink: Drink
}
public class Kid {
public var food: Food!
public var drink: Drink!
}
val kid = Kid().apply {
food = SafeFood()
drink = SafeDrink()
}
Property Injection の問題点
Property Injection で書いたコードは、シンプルだが、無口で何を欲しがっているのかわかりにくい子供のようなものだ。
少なくとも、以下のような問題がある。
null 安全性
怠惰なプログラマ(あるいは親)は、コンストラクタが何も要求していないのを見て、インスタンスを生成するだけで満足してしまうかもしれない。
適切な初期化を怠っていたら、null参照を引き起こすことになる。
val kid = Kid()
println(kid.food.name)
Kotlin も Swift も null 安全な言語だが、例外的にlateinit
や!
といった記法を許可している。
しかし、これらを使うべき場面は限られており、乱用は避けるべきだ。
可視性と不変性
Property Injection は利用者から依存を注入してもらうために、Property を公開かつ可変(var
)にしなければならない。
親は5歳児に食べ物を与えることができるが、いつでも取り上げることができるというわけだ。
これはカプセル化の基本理念に反する。
これは DI Framework の力を借りていても同じで、たとえば Android の DI Framework である Hilt のドキュメントにははっきりこう書かれている。
注: Hilt によって注入されたフィールドを非公開にすることはできません。Hilt を使用して非公開フィールドを注入しようとすると、コンパイル エラーが発生します。
例外はリフレクションを使用して注入する場合だが、昨今の Android では、パフォーマンス上の理由からリフレクションを使用した DI はほとんど見かけない。
Constructor Injection : 主張する5歳児
Constructor Injection は名前の通り、コンストラクタ(イニシャライザ)で依存を表明し、利用者から必要なものを渡してもらう。
class Kid constructor(
private val food: Food,
private val drink: Drink
)
public class Kid {
private let food: Food
private let drink: Drink
public init(food: Food, drink: Drink) {
self.food = food
self.drink = drink
}
}
Constructor Injection は、Property Injection で挙げた欠点を克服している。
まず、コンストラクタによる値の注入が強制されるため、null 安全である。
依存関係がコンストラクタに明記されているため、コードを隅々まで読んで依存を洗い出す手間が省ける。
また、カプセル化を保つことができ、不変性を保つことができる。
これなら親に食べ物を取り上げられる心配はない。
また、Kodein のドキュメント(後述)にあるように「純粋」である。
つまり、不要なものに依存していない。
Property Injection と似て非なる「取得代行型」
ところで、Swift にはswift-dependenciesという比較的新しいライブラリがある。
swift-dependencies は、たとえば以下のように利用する。
public class Kid {
@Dependency(\.food) var food
@Dependency(\.drink) var drink
}
@Dependency
は Java のアノテーションのように見えるので、これは Property Injection の一種のように見えるが、じつはまったく異なる。
@Dependency
は Property Wrapper の実装であり、プロパティに付与することで、その値の set/get の振る舞いを変更できるものだ。
Property Wrapper は Kotlin の Delegated Properties (移譲プロパティ)によく似ており、Kotlin のメジャーな DI Framework である koin と Kodein はどちらも移譲プロパティによる依存の取得をサポートしている。
class Kid : KoinComponent {
val food: Food by inject()
val drink: Drink by inject()
}
移譲プロパティはプロパティの後ろにby
を付けて宣言する。
Kodein の記法も似たようなものなので省略する。
どちらも一見するとクールだ。
Property Wrapper も移譲プロパティも、インスタンスが生成されたとき(あるいは、はじめてプロパティにアクセスしたとき)に自ら依存を解決しに行くので、Property Injection の欠点であった、null 安全性と可視性と不変性の問題を克服している。
これらのプロパティは公開する必要がないので、private
にしても問題がない。
ただし、Property Wrapper はvar
で宣言しなければならないという制約は残る。
これらが Property Injection でないとしたら、なんと呼ぶべきだろうか?
冒頭の Service Locator パターンのコードと、これらのコードを見比べてほしい。
class Kid {
private val food: Food = ServiceLocator.resolve<Food>()
}
Service Locator = Dependency Retrieval
@Dependency
もby inject(...)
も、自ら依存を解決するという点で、Service Locator の一種だということがわかる。
Service Locator の実体は巧妙に隠されているが、やはり Service Locator である。
これらの記法は DI のように見えるが、DI ではない。
実際、swift-dependencies は自らを"A dependency management library"と名乗っており、DI ライブラリとは名乗っていない。
一方、koin や Kodein は Constructor Injection を全力でサポートしているので、依然として DI ライブラリである。
Kodein のドキュメントでは、この違いをはっきり説明している。
Injection & Retrieval
When dependencies are injected, the class is provided its dependencies at construction.
When dependencies are retrieved, the class is responsible for getting its own dependencies.Using dependency injection is a bit more cumbersome, but your classes are "pure": they are unaware of the dependency container. Using dependency retrieval is easier (and allows more tooling), but it does binds your classes to the Kodein-DI API.
Finally, in retrieval, everything is lazy by default, while there can be no lazy-loading using injection.
注入と取得
依存関係が注入される場合、クラスは構築時に依存関係を提供されます。
依存関係が取得される場合、クラスはそれ自身の依存関係を取得する責任を負います。依存性注入を使用するのは少し面倒ですが、クラスは "純粋" です。依存関係の取得を使用する方が簡単です(そして、より多くのツールを使用することができます)が、それはあなたのクラスをKodein-DI APIにバインドします。
最後に、注入を使用すると遅延ロードが発生しないのに対して、取得ではデフォルトですべてが遅延ロードされます。
Kodein は Service Locator パターンのことを Dependency Retrieval (依存関係の取得)と呼んでいる。
DI と対比するならこちらの呼び方のほうがわかりやすい。
ドキュメントを読むと、Kodein がby
による依存の取得をサポートする理由の一つは遅延ロードのためだということがわかる。
他にも理由はあるが、それは後述する Composition Root という概念と関係がある。
Composition Root
これまでの例には子供とその親しか登場していないが、現実のアプリケーションが2階層に留まることはまず無い。
たとえば、Web API をコールするためのClient
をRepository
が所有し、
Repository
をViewModel
が所有し、ViewModel
をView
が所有し...といった階層的な設計を採ることが多いはずだ。
このように親を辿っていくと、最終的に、アプリケーション開発者が制御可能なコードの起点に到達する。
Composition Root とは、このような「制御の起点」に依存関係の解決を集約しようという考え方、またはそのような場所のことである。
単純なコンソールアプリケーションなら、これはmain
関数のことを指す。
Android ならActivity
が該当する。
Application
でないのは、Application
はActivity
をつくる役割も権限も持たないからだ(一応、Application
も初期化という Composition Root の役割の一部を持つ)。
iOS ならAppDelegate
がこれに該当するだろう。
Composition Root の役割は、オブジェクト間の依存ツリーを辿りながら、アプリケーションを動作させるのに必要なオブジェクトを構築することである。
Composition Root だけが、オブジェクトの構築に責任を持つ。
それ以外のすべてのクラスは受け身に徹する。つまり、必要なオブジェクトを渡してもらうようにする。
Composition Root は一つであることが理想とされるが、一つであることに固執する必要はない。
Android で複数Activity
から成るアプリを作っている場合や、Fragment
に分割している場合、Composition Root も複数になるだろうし、その方が理に適っている。
もし Jetpack Compose を使用していたら、NavHost
が単一の Composition Root の候補になるだろう。
SwiftUI を使用していたら、@main
のついたApp
クラスが Composition Root の有力候補になる。
依存関係の解決が手間なら、DI Framework の力を借りてもいい。
その場合、Composition Root だけが、DI Framework に直接アクセスする権利を持つ。
koin や Kodein を使うなら、Composition Root だけが移譲プロパティby
による依存の取得の権利を持つ。
つまり、Composition Root だけが Service Locator パターンのように振る舞うことができる。
Service Locator vs Dependency Injection
このように見ていくと、Service Locator と DI は二者択一の問題ではなく、どちらに比重を置くかという問題だということがわかってくると思う。
基本的には Constructor Injection による DI を中心に考え、Composition Root だけが Dependency Retrieval を行う(Service Locator にアクセスする)のが良いと思う。
そうすることで、アプリケーションの振る舞いに関する制御を一箇所(あるいは少ない箇所)に集約でき、テスタビリティを保ち、DI Framework との結合を最小限に抑えることができる。
ライブラリを作るようにアプリを作る
再び、Kodein のドキュメントから引用したい。
If you are developing a library, then you probably should use dependency injection, to avoid forcing the users of your library to use Kodein-DI as well.
If you are developing an application, then you should consider using dependency retrieval, as it is easier to use and provides more tooling.
もしあなたがライブラリを開発しているのであれば、依存性注入を使うべきでしょう。ライブラリのユーザーにもKodein-DIを使わせることを避けるためです。
もしアプリケーションを開発しているのであれば、依存関係取得を使うことを検討すべきです。
これは、言い換えるとライブラリは Service Locator パターンになってはいけないということであり、当然の指摘である。
一般に、ライブラリはシンプルで他の何にも依存していないほうが好まれる。
一方、アプリケーションならどこでも Service Locator パターン化して良いかどうかはよく考える必要がある。
ある日、開発中のアプリケーションの中に、出来が素晴らしく汎用性の高い実装が見つかり、ライブラリ化して他のアプリケーションと共有したくなったとしよう。
そのアプリケーションは swift-dependencies を使用しており、共有したいコードは以下のようになっているとする。
public class AwesomeClass {
@Dependency(\.awesomeHelper) var helper
@Dependency(\.awesomeCalculator) var calculator
...
}
このままでは swift-dependencies に依存しているので、依存を洗い出してせっせと Constructor Injection などの別の形式に直す必要があるが、作業はそれだけは終わらないかもしれない。
依存先のクラスもまた、swift-dependencies に依存しているかもしれないのだ。
あの手この手を使ってアプリケーションと密に結合しようとする Framework は、しばしば"Intrusive"(「押し付けがましい」「侵入的な」の意)または"Invasive"(侵略的な)と表現される。
Intrusive な Framework は、自身の定義したクラスをアプリケーションのビジネスロジックに継承させたり、独自定義の注釈を大量に書かせたりして、アプリケーションを自身に依存するように仕向け、逆にアプリケーションを支配しようとする。
冒頭の喩え話に話を戻すと、DI Framework は忙しい親の代わりに子供の面倒を見てくれる家政婦のようなものだ。
良い家政婦は家主の決めたルールを厳格に守り、家族との距離感を適切に保とうとする。
悪い家政婦は自ら決めたルールで家族を支配しようとし、自分なしでは家庭が存続できないように仕向ける。
DI Framework をうまく使うコツは、Composition Root (家主)が誰であるかを意識し、Composition Root 以外は DI Framework のことを何も知らない状態を保つことだ。
単体テストも SwiftUI や Compose のプレビューも、DI Framework のことを知る必要はない。
ビジネスロジックや View を DI Framework と結合した途端、その部分は DI Framework の力なしではテストが困難になる。
そして、Framework はテストのための大げさな拡張機能を提供し、利用者に「これは便利なツールだ」と錯覚させる。
悪い設計の例。アプリケーションのあらゆる階層が Framework と結合している。
良い設計の例。Composition Root だけが DI Framework のことを知っている。
Clean Architectureからの引用
第32章 フレームワークは詳細
フレームワークなんかと結婚するな!
おっと、フレームワークを使うことは問題ない。ただし、結合しないことが大切だ。フレームワークとは一定の距離を保つこと。
...(略)...
SpringはDIフレームワークとしてよくできている。おそらくあなたも、Springを使って依存性を自動ワイヤリングしているだろう。それは別に構わない。だが、ビジネスオブジェクト全体に@autowiredアノテーションをちりばめてはいけない。ビジネスオブジェクトがSpringについて知るべきではないのだ。そうではなく、Springを使ってMainコンポーネントに依存性を注入しよう。MainコンポーネントがSpringについて知っていることは何の問題もない。Mainはアーキテクチャのなかで最下位レベルのコンポーネントだからだ。
DI Framework を選ぶポイント
- 侵入性の高い(Intrusive な)ものを避ける。そのような Framework は広範に渡るアプリケーションとの結合を生み、引きはがすのが困難になる。そのような性質を持っていても、結合度を下げる手段があれば(あるいは「結婚」する覚悟があるなら)選んでもよい。
- Framework を引きはがしたくなる理由はいろいろある。破壊的変更を繰り返す、古くなる、他に魅力的な Framework が現れて乗り換えたくなる、作者が開発に飽きてしまい乗り換えを勧める、など。
- Constructor Injection を主軸に置いているものを選ぶ。Composition Root レベルでは Service Locator としての記述が容易なものがよい。
- Android の選択候補:
- iOS の選択候補:
- どの Framework も気に入らなければ、DI の仕組みを自作するという選択肢もある。その場合でも、Constructor Injection と Composition Root の原則を守っていれば、いつでも別の方法に移行することができる。
- Property Wrapper を用いたものは DI でなく Service Locator パターンだということを忘れてはいけない(移譲プロパティも同じ)。
参考文献
- Robert C.Martin. Clean Architecture 達人に学ぶソフトウェアの構造と設計 (Japanese Edition)
- Field Dependency Injection Considered Harmful
- Why Is Field Injection Not Recommended?
- Service LocatorとDependency InjectionパターンとDI Container
- Martin Fowler. Inversion of Control Containers and the Dependency Injection pattern
- 【Swift】インスタンスの生成と利用の分離、Composition Rootという考えについて
おまけ
ChatGPT に頼んで、依存性注入を5歳児向けに説明してもらった。
ほな、ちびっ子たちにもわかるように説明してみよか。
想像してみてや。君がレゴブロックで車を作っとるんやけど、タイヤを直接車体にくっつけたら、そのタイヤだけ変えたいときに困るやろ?車体ごとばらさなアカンくなる。
そこで、タイヤを取り付ける部分を「タイヤがはめられるようにするだけ」にしておくんや。そうすれば、タイヤが古くなったり壊れたりしても、新しいのと簡単に取り替えられるし、違う種類のタイヤもはめて遊べるようになるんや。
これがあると便利な理由やねん。車とタイヤがガチガチにくっついてたら、何か一つ変えたいときに全部を作り直さなアカンくなる。でも、「はめるだけ」にしておくと、いろんなタイヤをサクッと変えられるから、色々な遊びができて楽しいやんか。
これが大人の作るプログラムでも一緒で、パーツがバラバラにできてると、直したいときにその部分だけ直せばいいし、新しい機能を付け足したいときも簡単やねん。