💉

依存性注入(Dependency Injection: DI)について理解する

2023/02/07に公開

株式会社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のインスタンスが不可欠なので、「CarEngineに依存している」といえます。

次のコードは、DIを行わずにCarクラス自身が内部でEngineのインスタンスを生成しています。

class Car {

    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

Alt text

一方で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()
}

Alt text

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, transmissionCarの内部で生成する場合、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]

Alt text

アプリケーションのDIには、フレームワークを導入する

実際のアプリケーションを構築するにあたっては、単一責任の原則にしたがって関心の分離を行うことが一般的です。結果として小さなクラスが集まり、個々のクラスは他の多くのクラスに依存するような構成となります。(Androidアプリの推奨アーキテクチャ[4]はその一例です。)またクラスAがクラスBに依存し、そのクラスBがCに依存し…と数珠繋ぎになることもしばしばです。

たとえば、アプリケーションで特定のUseCaseを呼び出し、そのUseCaseからはRepository経由でLocalCacheHttpClientを参照する場合のDIについて考えてみます。

Alt text

まず、LocalCacheHttpClientのインスタンスを生成し、それらを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のインスンタンスが必要で、そのためには⓪LocalCacheHttpClientのインスタンスが必要です。そこで、まず①LocalCacheHttpClientのバインディングをもとにインスタンスを取得し、それらのインスタンスを使ってオブジェクトグラフから②Repositoryのインスタンス→③UseCaseのインスタンスという順に取得します。

Alt text

まとめると、アプリケーションでDIを行う場合はフレームワークを導入するのが賢明であり、その最大のメリットは「フレームワークが管理するオブジェクトグラフによって、複雑な依存関係が管理しやすくなること」といえます。

DIフレームワーク(Dagger)に渡す設定

フレームワークを使ってオブジェクトを取得するためには、その取得方法やスコープ(ライフサイクル)など、依存関係の設定をフレームワークに与える必要があります。ここでは一例として、代表的なDIフレームワークであるDagger[7]の設定方法について、簡単にご紹介します。

次に示す@Injectorの設定、Moduleの定義、Componentの定義の3つを用意することで、Daggerはビルド時にFactoryに相当するクラス・メソッドを自動生成してくれる仕組みになっています。

@Injectの設定

Injectとは、指定されたバインディングに応じて、オブジェクトグラフから取得したインスタンスを注入することです。クラスのコンストラクタに@Injectアノテーションをつけることで、「クラス型」と「このコンストラクタでインスタンスを取得する」というバインディングがオブジェクトグラフに登録されます。

次のコードでは「Repositoryクラスのインスタンスは、localCachehttpClientを引数にとるコンストラクタで取得する」という情報を、オブジェクトグラフに登録しています。

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に記述したHttpClientLocalCacheのバインディングが登録されます。

@Component(modules = [DataSourceModule::class])
interface ApplicationComponent {
    @Component.Factory
    interface Factory {
        fun create(@BindsInstance application: Application): ApplicationComponent
    }
}

このほかにも、アプリケーションのDIに欠かせない、フレームワークの機能はたくさんあります。

最後に

以上、依存性注入(Dependency Injection: DI)と、そのフレームワークについての解説でした。最後まで読んでいただき、ありがとうございます!

脚注
  1. Android での依存関係インジェクション  |  Android デベロッパー  |  Android Developers ↩︎

  2. Use test doubles in Android  |  Android Developers ↩︎

  3. SOLID Definition – the SOLID Principles of Object-Oriented Design Explained ↩︎

  4. アプリ アーキテクチャ ガイド  |  Android デベロッパー  |  Android Developers ↩︎

  5. Dagger の基本  |  Android デベロッパー  |  Android Developers ↩︎

  6. Master of Dagger あんざいゆき第2版 ↩︎

  7. Dagger ↩︎

株式会社TOKIUM テックブログ

Discussion