依存性注入(Dependency Injection: DI)について理解する
株式会社TOKIUMでAndroidエンジニアをしている渡邊(@error96num)です。ここ数年は"injection"というとワクチン注射が思い浮かびますが、本記事ではアプリ開発において欠かせないinjection、依存性注入(Dependency Injection: DI)という概念について解説します。
対象読者
以下のような方を想定しています。
- 依存性注入(Dependency Injection: DI)に馴染みがなく、ざっくり理解したい
- DIフレームワークを使ったアプリ開発をしているが、基礎にたちかえってDIの目的やメリットについて今一度理解したい
依存性注入 (Dependency Injection: DI)
Android公式のドキュメント[1]にも登場するCar
, Engine
クラスを使った例で、Kotlinでの実装を交えてDIについて解説します。
多くの場合、クラスは他のクラスへの参照を必要とします。この章で説明するケースケースは、Car
のインスタンスを動かす(start
関数を呼ぶ)ためにEngine
のインスタンスが不可欠なので、「Car
はEngine
に依存している」といえます。
次のコードは、DIを行わずにCar
クラス自身が内部でEngine
のインスタンスを生成しています。
class Car {
private val engine = Engine()
fun start() {
engine.start()
}
}
fun main(args: Array) {
val car = Car()
car.start()
}
一方でDIを行った場合は、次のコードのようにCar
クラスの外部からEngine
のインスタンスが提供されます。Car
クラスからはEngine
のインスタンスを生成する責務が取り除かれており、コンストラクタの引数として受け取るだけでCar
クラスを動かせます。(コンストラクタ インジェクション)
class Car(private val engine: Engine) {
fun start() {
engine.start()
}
}
fun main(args: Array) {
val engine = Engine()
val car = Car(engine)
car.start()
}
DIを行うメリット
上記のようなDIの仕組みを利用するメリットについて説明します。
👍クラスを再利用できる
先ほどのEngine
に依存するCar
の例を、もう少し複雑にしてみます。Car
が依存する部品クラスを増やし、さらに各部品クラスがコンストラクタで引数をとる場合を考えてみます。次のコードのCar
は最高出力132PS/7000rpmのEngine
と、スポーツタイプのTires
と、6速MTタイプのTransmission
を持っています。
class Car {
private val engine = Engine(ps = 132, rpm = 7000)
private val tires = Tires(type = "sport")
private val transmission = Transmission(speed = 6, type = "mt")
...
}
DIを行わずにengine
, tires
, transmission
をCar
の内部で生成する場合、Car
のインスタンスはすべて、同じスペックの部品しか持てないことになります。つまりTransmission
をMTタイプからATタイプに変えたCar
のインスタンスを作りたい場合、Car
クラスごと新しく定義しなければなりません。これは面倒です。
ここでDIを使って、engine
, tires
, transmission
をコンストラクタの引数として渡すように書き換えれば、この問題は解消できます。次のコードでは、DIによりひとつのCar
クラスから異なるスペックのインスタンスを生成しています。
class Car(
private val engine: Engine,
private val tires: Tires,
private val transmission: Transmission
) {
...
}
val car1 = Car(
Engine(ps = 132, rpm = 7000),
Tires(type = "sport"),
Transmission(speed = 6, type = "mt")
)
val car2 = Car(
Engine(ps = 105, rpm = 4000),
Tires(type = "comfort"),
Transmission(speed = 6, type = "at")
)
このようにDIによって、一度定義したCar
クラスを再利用しながら、依存しているオブジェクトの差し替えが柔軟にできるようになります。
👍テストをしやすくなる
DIによりCar
が依存するオブジェクトの差し替えが容易になったことによって、「6速MTタイプのTransmission
」であったり「スポーツタイプのTires
」など、特定のプロパティを持つCar
についてのテストが書きやすくなります。
また、フェイクやモック、ダミーのようなテストダブル[2]を差し込んだテストも容易になるため、正常系・異常系におけるCar
クラスの振る舞いを確認しやすくなります。
class CarTest {
@Test
fun 'Car with right parts'() {
val car = Car(FakeEngine(), FakeTires(), FakeTransmission())
}
@Test
fun 'Car with failing Engine'() {
val car = Car(FakeFailingEngine(), FakeTires(), FakeTransmission())
}
}
👍コードを修正しやすくなる
DIを行った結果、Car
クラスが依存しているengine
, tires
, transmission
が外部から提供されるようになりました。Car
クラスからは、これらのオブジェクトを生成する責務が取り除かれます。本来Car
にしか担当できない責務だけが残り、よりシンプルなクラス設計ができると考えられます。
このように責務の切り分けが明確になることで、修正時のコンフリクトが少なく、メンテナンスがしやすいコードに保ちやすくなります。(単一責任の原則[3])
アプリケーションのDIには、フレームワークを導入する
実際のアプリケーションを構築するにあたっては、単一責任の原則にしたがって関心の分離を行うことが一般的です。結果として小さなクラスが集まり、個々のクラスは他の多くのクラスに依存するような構成となります。(Androidアプリの推奨アーキテクチャ[4]はその一例です。)またクラスAがクラスBに依存し、そのクラスBがCに依存し…と数珠繋ぎになることもしばしばです。
たとえば、アプリケーションで特定のUseCase
を呼び出し、そのUseCase
からはRepository
経由でLocalCache
とHttpClient
を参照する場合のDIについて考えてみます。
まず、LocalCache
とHttpClient
のインスタンスを生成し、それらをRepository
に提供します。
val localCache = LocalCache()
val httpClient = HttpClient()
val repository = Repository(localCache, httpClient)
次に、Repository
のインスタンスをUseCase
に提供します。
val useCase = UseCase(repository)
ここで、アプリケーションの画面1, 2, 3... のように複数の箇所からUseCase
を利用したい場合は、上記のコードを何度も書く必要が出てきてしまいます。
そのような繰り返しを避けるためには、ファクトリークラスを作り、以下のようにUseCase
のインスタンスを生成します。
val useCase = UseCaseFactory.create()
ファクトリークラスのコードは次のようになります。
object UseCaseFactory {
fun create() : UseCase {
val localCache = LocalCache()
val httpClient = HttpClient()
val repository = Repository(localCache, httpClient)
return UseCase(repository)
}
}
DIのフレームワークは、インスタンスを取得する方法やスコープ(ライフサイクル)を設定すれば、上記のUseCaseFactory
のようなインスタンス取得のコードを自動で生成してくれます。
また、フレームワークによって「クラスの型」と「そのインスタンスの取得方法」の組み合わせが管理されます。この組み合わせのことを「バインディング」といい、アプリケーションを構成するさまざまなオブジェクトのバインディングを寄せ集めたものを「オブジェクトグラフ」といいます。オブジェクトグラフに登録されているクラスの型を指定すれば、対応するインスタンスの取得方法を使って、フレームワークから実際のインスタンスを返してもらうことができます。[5][6]
たとえば、前述のUseCase
のインスタンスを取得するためにはRepository
のインスンタンスが必要で、そのためには⓪LocalCache
とHttpClient
のインスタンスが必要です。そこで、まず①LocalCache
とHttpClient
のバインディングをもとにインスタンスを取得し、それらのインスタンスを使ってオブジェクトグラフから②Repository
のインスタンス→③UseCase
のインスタンスという順に取得します。
まとめると、アプリケーションでDIを行う場合はフレームワークを導入するのが賢明であり、その最大のメリットは「フレームワークが管理するオブジェクトグラフによって、複雑な依存関係が管理しやすくなること」といえます。
DIフレームワーク(Dagger)に渡す設定
フレームワークを使ってオブジェクトを取得するためには、その取得方法やスコープ(ライフサイクル)など、依存関係の設定をフレームワークに与える必要があります。ここでは一例として、代表的なDIフレームワークであるDagger[7]の設定方法について、簡単にご紹介します。
次に示す@Injectorの設定、Moduleの定義、Componentの定義の3つを用意することで、Daggerはビルド時にFactoryに相当するクラス・メソッドを自動生成してくれる仕組みになっています。
@Injectの設定
Injectとは、指定されたバインディングに応じて、オブジェクトグラフから取得したインスタンスを注入することです。クラスのコンストラクタに@Inject
アノテーションをつけることで、「クラス型」と「このコンストラクタでインスタンスを取得する」というバインディングがオブジェクトグラフに登録されます。
次のコードでは「Repository
クラスのインスタンスは、localCache
とhttpClient
を引数にとるコンストラクタで取得する」という情報を、オブジェクトグラフに登録しています。
class Repository @Inject constructor(
private val localCache: LocalCache,
private val httpClient: HttpClient
) {
...
}
Moduleの定義
DaggerのModuleは複数のバインディングの定義をグループ化し、再利用しやすくするための仕組みです。このようなバインディングのグループを定義するModuleクラスには、@Module
のアノテーションをつけます。そして、@Provides
でアノテーションされたメソッドを定義し、注入したいオブジェクトを返すように記述します。後述するComponentにModuleを指定することで、オブジェクトの注入が必要な場所では、このメソッドの返り値が提供されるようになります。
@Module
class DataSourceModule {
@Provides
fun provideHttpClient(): HttpClient = HttpClient()
@Provides
fun provideLocalCache(): LocalCache = LocalCache()
}
Componentの定義
DaggerのComponentはオブジェクトグラフを持っており、要求されたクラスのインスタンスを取得し、指定された変数に注入する役割を担います。この役割を担うクラスは@Component
のアノテーションをつけたabstract classかinterfaceを定義することで、ビルド時に実装クラスが生成される仕組みになっています。
Componentにバインディングを登録する方法はいくつかあります。まず、Component.Factory
のComponentを返すメソッドに@BindsInstance
アノテーションをつけることで、メソッドの引数に渡したインスタンスが直接Componentに登録されます。またComponentにModuleを指定すると、そのModuleに定義されているバインディングがすべて、オブジェクトグラフに登録されます。
次のコードは、Application
クラスを直接Componentに登録し、さらに前述の例で定義したDataSourceModule
を指定した場合です。このようにして定義したApplicationComponent
のオブジェクトグラフには、Application
の他、DataSourceModule
に記述したHttpClient
とLocalCache
のバインディングが登録されます。
@Component(modules = [DataSourceModule::class])
interface ApplicationComponent {
@Component.Factory
interface Factory {
fun create(@BindsInstance application: Application): ApplicationComponent
}
}
このほかにも、アプリケーションのDIに欠かせない、フレームワークの機能はたくさんあります。
最後に
以上、依存性注入(Dependency Injection: DI)と、そのフレームワークについての解説でした。最後まで読んでいただき、ありがとうございます!
Discussion