🎉

Swiftで実装する基本的な依存性注入

に公開

こんにちは。株式会社PREVENTでiOSエンジニアをしている佐藤です。
Swiftでのアプリ開発で、依存性注入の実装方法について検討することがあり、どんな依存性注入ができるのかを調べたので、
その時の知見を記事にします!

依存性注入(DI)とは

依存性注入(長いので以降は略してDIと呼ぶ)は、プログラミングをしていると、言語問わずよく聞くと思います。
そもそもDIがよくわからんという場合は、まずは以下記事を見た後で、本記事を見る方がわかりやすいと思います。

https://qiita.com/okazuki/items/a0f2fb0a63ca88340ff6

以降では、本題であるSwiftでの基本的なDIを行うための実装を紹介します。
以下のような簡単なViewModelを例に、DIの実装を説明していきます。
例のViewModelの内容は単純で、onAppearを呼び出すと、executeで取得した値でstateを更新するというものです。

イニシャライザによるDI

最も基本的なのは、イニシャライザでDIする方法です。
インスタンス生成時にイニシャライザの引数に依存するRepositoryを指定することで、DIを行います。
具体的な実装は以下のとおりです。

プロダクションコードの実装

Repositoryの実装

SampleRepositoryというprotocolを定義し、アプリを動作させたときの実際の動作の実装は、SampleRepositoryに準拠したSampleRepositoryImplに行います。
こうすることで、SampleRepositoryImplSampleRepository型として振る舞えるようになります。

protocol SampleRepository {
    func execute() -> String
}

struct SampleRepositoryImpl: SampleRepository {
    func execute() {
        return "Hello World!"
    }
}

ViewModelの実装

上述したとおり、SampleRepository型のプロパティはinitでインスタンス生成時に注入されるようになっています。
このように、外部から注入できるようになっていることで、プロダクションコード用とテスト用のSampleRepositoryを切り替えることができるので、SampleViewModelの単体テストも行えます。

final class SampleViewModel {
    private let sampleRepository: SampleRepository
    var state: String = ""

    init(sampleRepository: SampleRepository) {
        self.sampleRepository = sampleRepository
    }

    func onAppear() {
        state = sampleRepository.execute()
    }
}

ViewModelをインスタンス化する実装

プロダクションコードでViewModelをインスタンス化するときに、先ほど定義したSampleRepositoryImplを注入します。
ここまでで、プロダクションコードの実装は完了です。

let viewModel = SampleViewModel(sampleRepository: SampleRepositoryImpl())

テストコードの実装

ViewModelの実装が行えたので、実装したViewModelをどのようにテストするかを考えてみます。

SampleViewModelinitSampleRepository型を注入できるようになっているので、SampleRepositoryImplを実装した要領でテスト用のRepositoryを注入すればテストできるようになりそうです。

テスト用のRepository実装

expectedOutputexecuteで返すことで、executeの戻り値を制御できるようにします。
SampleRepositoryMockSampleRepositoryに準拠させることで、SampleRepository型として振る舞えるようにして、SampleViewModelにDIできるようにしておきます。

struct SampleRepositoryMock: SampleRepository {
    let expectedOutput: String
    func execute() -> String {
        return expectedOutput
    }
}

ViewModelのテストコード実装

SampleRepositoryMockのイニシャライザでexecuteの戻り値を指定することで、
testViewModel.stateの値を適切に検証できるようになります。(本来のexecuteの処理で通信を行っていたとしても)

@Test func example() {
    let sampleRepositoryMock = SampleRepositoryMock(expectedOutput: "Hello, Test World!")
    let testViewModel = SampleViewModel(sampleRepository: sampleRepositoryMock)
    testViewModel.onAppear()
    #expect(testViewModel.state == "Hello, Test World!")
}

ここまでが、イニシャライザによるDIの実装方法でした。

特徴

この方法は、基本的なだけあって、比較的簡単に実装することができます。
DIの仕組みがシンプルなので、DIにまつわる不具合も起きづらいですし、起きたとしても調査は容易であることが多いです。
また、基本の実装方法ゆえにサポートツールも豊富で、テスト用のRepositoryを実装を都度実装するのは手間と思われたかもしれませんが、Mockoloというモック自動生成ツールによりテスト用のRepositoryも自動生成できたりします。

この実装方法の弱みとして、イニシャライザでDIすることになるので、DIを行う型(今回の例だとSampleViewModel)のインスタンス生成時のコードが巨大化しがちという部分です。
今回の例では、SampleRepositoryをDIだけでしたが、実際の開発では大体複数の依存が存在します。
アプリが成長すると5個以上の依存が存在することもざらで、そうなったときにインスタンス生成のコードは結構大きくなります。

let viewModel = SampleViewModel(
    sampleRepository1: SampleRepositoryImpl1(),
    sampleRepository2: SampleRepositoryImpl2(),
    sampleRepository3: SampleRepositoryImpl3(),
    sampleRepository4: SampleRepositoryImpl4(),
    sampleRepository5: SampleRepositoryImpl5(),
    sampleRepository6: SampleRepositoryImpl6()
)

上記に、Repositoryのインスタンス生成も絡むとさらに複雑になってしまいますが、そこはあらかじめグローバル変数にRepositoryを格納しておくことで回避できます。

以下のようにすれば、Repositoryのインスタンス生成のコードが複雑だったとしても、DI先のインスタンス生成のコードが複雑になることはある程度防げます。
現在の業務で開発しているアプリ開発では、この方法を採用しています。

enum RepositoryContainer {
    static let sampleRepository1 = SampleRepositoryImpl1()
    static let sampleRepository2 = SampleRepositoryImpl2()
    ...
}

let viewModel = SampleViewModel(
    sampleRepository1: RepositoryContainer.sampleRepository1,
    sampleRepository2: RepositoryContainer.sampleRepository2,
    ...
)

GenericsによるDI

先ほどの例とは違い、イニシャライザを用いずGenericsによってDIを行う方法もあります。
実装を見れば、少しイメージも湧くと思うので、早速実装例を見てみましょう。

プロダクションコードの実装

Repositoryの実装

イニシャライザでDIする時のRepository実装例とかなり似ていますが、唯一違う点として、executeメソッドがstaticなメソッドになっている部分です。
staticにしている理由は、ViewModelの実装を見るとわかると思うので、ここでは一旦宙に浮かせておきます。

protocol SampleRepository {
    static func execute() -> String
}

struct SampleRepositoryImpl: SampleRepository {
    static func execute() -> String {
        return "Hello, World!"
    }
}

ViewModelの実装

DIを行うViewModelは以下のような実装になります。
ここでのポイントは、SampleViewModelの型に対してGenericsを使用していることです。
Genericsの型をSampleRepository型として定義することで、SampleViewModel内ではGenericsで定義されたSampleRepository型を参照することができます。
そして、ここでSampleRepositoryexecuteメソッドをstaticにしたことが効いてきます。
SampleViewModel内では、SampleRepositoryの型しか知ることができませんが、staticメンバは型さえわかれば参照できるので、Repository.execute()のように呼び出すことができます。

final class SampleViewModel<Repository: SampleRepository> {
    var state: String = ""

    func onAppear() {
        state = Repository.execute()
    }
}

ViewModelをインスタンス化する実装

DIするときは、GenericsでSampleRepositoryに準拠したSampleRepositoryImplを指定するだけです。

let viewModel = SampleViewModel<SampleRepositoryImpl>()

テストコードの実装

テスト用のRepository実装

executeメソッドの戻り値を制御できるように、expectedOutputもstaticプロパティとして定義します。
それ以外は、イニシャライザでのDIの時と同じです。

struct SampleRepositoryMock: SampleRepository {
    nonisolated(unsafe) static var expectedOutput: String = ""
    static func execute() -> String {
        return expectedOutput
    }
}

ViewModelのテストコード実装

先ほど実装したSampleRepositoryMockexpectedOutputに期待値を設定し、
SampleViewModelのインスタンス生成時に、GenericsでSampleRepositoryMock型を指定することで、テスト用のRepositoryを注入しています。

@Test func example2() async throws {
    SampleRepositoryMock.expectedOutput = "Hello, Test World!"
    let testViewModel = SampleViewModel<SampleRepositoryMock>()
    testViewModel.onAppear()
    #expect(testViewModel.state == "Hello, Test World!")
}

特徴

GenericsによるDIは、イニシャライザによるDIと比べて、プロパティやinitの実装が必要ないという点でやや手軽に実装できます。
ただ、この実装方法でも、DIを行う依存の数が増えるとやはりインスタンス生成時のコードは膨らみます。

let viewModel = SampleViewModel<SampleRepositoryImpl1, SampleRepositoryImpl2, SampleRepositoryImpl3, SampleRepositoryImpl4, SampleRepositoryImpl5, SampleRepositoryImpl6>()

好みにもよりますが、Genericsが大量に存在する場合と、イニシャライザのパラメータが多い場合では、イニシャライザの方が可読性が高いと思っています。(改行とタブによりある程度、整形できるため)
そのため、Repositoryが多いような中〜大規模の開発では、イニシャライザによるDIの方が綺麗に描けるというのが私の個人的な感想です。
逆に小規模の開発だと、簡潔にDIの実装が行える箇所が増えるかなと思います。(プライベートでサンプルアプリを作ると言ったシチュエーションぐらいで使うのがちょうど良さそうです)

TaskLocalによるDI

最後の例は、TaskLocalを使ったDIの方法です。
@TaskLocalのマクロが付与されたプロパティの値を、任意の範囲でのみ任意の値に設定することができるという特性を利用して、DIを行います。

以下で実際の実装例を見ていきましょう。

プロダクションコードの実装

Repositoryの実装

この方法では、今まで紹介したprotocolベースの実装ではなく、structとしてRepositoryを定義していきます。
structには、Repositoryに持たせたいメソッドをクロージャーとして定義し、プロダクションコード用と、テスト用それぞれにでクロージャーの中身を実装することで、用途によって振る舞いを変えることができます。

public struct SampleRepository: Sendable {
    let execute: @Sendable () -> String
}

// `liveValue`にプロダクションコード用の`SampleRepository`を定義する
extension SampleRepository: DependencyKey {
    public static var liveValue: SampleRepository {
        .init(execute: { "Hello, World!" })
    }
}

// `DependencyValue`に`SampleRepository`型のプロパティを定義する
extension DependencyValue {
    public var sampleRepository: SampleRepository {
        get { self[SampleRepository.self] }
        set { self[SampleRepository.self] = newValue }
    }
}

DependencyKeyDependencyValueという見慣れない型が出てきていますが、これは自前で実装しているもので、後続で実装内容を説明します。
ここでは、SwiftUIでカスタム@Environmentプロパティを追加するときと同じことをやっているというイメージを持ってもらえればと思います。

ViewModelの実装

SampleRepositoryのDIは、ViewModel側から見れば、@DependencyというPropertyWrapperで行っています。
そのため、イニシャライザもGenericsも必要なく非常に簡潔にViewModelを実装できます。

final class SampleViewModel {
    @Dependency(\.sampleRepository) var sampleRepository
    var state: String = ""
    
    func onAppear() {
        state = sampleRepository.execute()
    }
}

@Dependencyも自前の実装ですが、SwiftUIの@Environmentと同じようなものというイメージで見てください。
@Dependencyの中身の実装も後続で説明します。

ViewModelインスタン化の実装

インスタンス化の実装も非常に簡潔で、ここでSampleRepositoryを意識する必要はありません。

let viewModel = SampleViewModel()

DIを行うための基盤の実装

ここから、先ほど出てきた、DependencyKeyDependencyValue@Dependencyについて説明していきます。
上記のようにViewModelを簡潔に書けるようにするために、DIの仕組みを作るための実装が必要になります。

DependencyKeyの実装

DependencyKey@DependencyでDIできる型であることを示すマーカーの役割を持ちます。
また、実装の中身が存在することを担保するために、staticプロパティのliveValueを保持します。
liveValueにはプロダクションコード用のRepositoryの実装を設定する想定です。

protocol DependencyKey {
    associatedtype Value: Sendable
    static var liveValue: Value { get }
}

DependencyValueの実装

DependencyValueはDI対象(Repositoryなど)を保持しておく、ストレージの役割を持ちます。(javaのSpringフレームワークでいうところのDIコンテナのイメージです)
@DependencyRepositoryを取り出すときは、DependencyValueから取り出しています。

定義したsubscript<K>(key: K.Type) -> K.Value where K : DependencyKeyによって、
Repositoryのgetterとsetterを実装しています。
@Dependencyで取り出すRepositoryはこのgetterの処理で取得してきます。
getterではデフォルトではliveValueを返し、liveValueにプロダクションコード用の実装が設定されていれば、通常@Dependencyではプロダクションコード用のRepositoryが注入されます。

setterは、テスト用のRepositoryを注入するときに使います。

struct DependencyValue: Sendable {
    @TaskLocal public static var current = Self()
    private var storage: [ObjectIdentifier: any Sendable] = [:]
    subscript<K>(key: K.Type) -> K.Value where K : DependencyKey {
        get {
            guard let base = storage[ObjectIdentifier(key)],
                  let dependency = base as? K.Value else {
                
                return key.liveValue
            }
            return dependency
        }
        set {
            self.storage[ObjectIdentifier(key)] = newValue
        }
    }
}

withDependencyメソッドは、テスト用のRepositoryを注入するヘルパーメソッドです。
setDependencyでメソッド呼び出し元からDependencyValue(DIコンテナ)を渡して、任意のRepositoryを登録します。
編集されたDependencyValuewithValueメソッドで@TaskLocalなプロパティであるDependencyValue.currentに注入することで、withDependencyで返却したインスタンス内の@Dependencyにテスト用のRepositoryを注入することができます。

extension DependencyValue {
    static func withDependency<R>(_ setDependency: (inout DependencyValue) -> Void, operation: () -> R) -> R {
        var currentDependencyValue = DependencyValue.current
        setDependency(&currentDependencyValue)
        return DependencyValue.$current.withValue(currentDependencyValue) {
            return operation()
        }
    }
}

@Dependencyの実装

wrappedValueではcomputed propertyで、DependencyValuesubscriptのgetterで取得したRepositoryを返すようにしています。
この時のDependencyValueは、@Dependencyを持つ型のインスタンス生成時(今回の例だと、SampleViewModelのインスタンス生成時)のDependencyValue.currentになります。
そのため、withDependencyメソッドのクロージャ内で@Dependencyを持つ型のインスタンス生成を行ったときは、withDependencyメソッドで編集されたDependencyValue@Dependencyinitで参照されることになります。

@propertyWrapper struct Dependency<Value> {
    let dependencyValue: DependencyValue
    private let keyPath: KeyPath<DependencyValue, Value> & Sendable

    init(_ keyPath: KeyPath<DependencyValue, Value> & Sendable) {
        dependencyValue = DependencyValue.current
        self.keyPath = keyPath
    }

    var wrappedValue: Value { dependencyValue[keyPath: self.keyPath] }
}

テストコードの実装

先ほど説明した、withDependencyメソッドで、dependencyValueにテスト用のsampleRepositoryを設定し、operationクロージャーでテスト用のSampleRepositoryが注入されたSampleViewModelのインスタンスを生成しています。

@Test func example3() async throws {
    let testViewModel = DependencyValue.withDependency { dependencyValue in
        dependencyValue.sampleRepository = .init(execute: {
            return "Hello, Test World!"
        })
    } operation: {
        SampleViewModel()
    }

    testViewModel.onAppear()
    #expect(testViewModel.state == "Hello, Test World!")
}

ここまでで、TaskLocalによるDIのプロダクションコード〜テストコードの実装までが完了しました。

特徴

このDI方法は、最もDIされる型(今回の例ではSampleViewModel)を簡潔に実装できます。
イニシャライザによるDI、GenericsによるDIそれぞれで抱えていた、依存の数が増えるほどインスタンス生成時のコードが膨らんでいく問題を解決しています。

protocolを使わないstructベースのRepositoryを使うため、Mockoloのようにモック自動生成ツールは頼れませんが、そもそもstruct自体がRepositoryの持つインターフェースを表現するクロージャーを持っていて、Mockoloが生成するモッククラスとほぼ同じなのでMockoloを使えないデメリットはほぼないでしょう。

DIとしての機能だけ見れば、先に説明したイニシャライザによるDI、GenericsによるDIよりも優っていると言って良いと思います。
ただ、実装の説明を見ているときに感じたと思いますが、DIの仕組みはやや複雑です。
そのため、うまくDIできないといった場合の調査は他の方法よりかは大変になる場合が多いと思います。

TCAswift-dependenciesを触ったことがある方は、わかるかもですが、今回紹介した実装はswift-dependenciesの模倣品です。
プロダクションコードからテスト用に依存を注入できる仕組みという観点で最小限の実装にとどめていますが、本家のライブラリはさらに高機能です。
そのため、こういったDI方法で実装したい場合は、swift-dependenciesを使用するとお手軽です。
ただ、アプリの多くの箇所でOSSに依存することはリスクでもあるので、それほど高機能は望まず、テスト用にDIするだけなら自前で実装したほうがメンテナンス性も高いので、プロジェクトの状況などを鑑みての検討が必要でしょう。

おわり

いかがだったでしょうか。
今回Swiftの依存性注入の実装方法を改めて調べてみて、全然知らなかった方法があり、いろんな方法があるんだなと思いました(小並感)
今回紹介したのはあくまで実装例で、同じ方針でももっと良い実装はあると思います。
また、今回はあまり扱わなかったですが、Swiftでもサードパーティー製のDIライブラリがあるので、そういったツールを使うのも手段の一つです。(とはいえ他言語と違ってDIライブラリといえばこれみたいなのは現状なさそうですが)
今回調べた限りだと、Swiftにおいて、DIの銀の弾丸はなさそうなので、それぞれの手段の良し悪しを考慮しつつ、プロジェクトにとって適切なDI方法を都度考えるようにしたいです。

Discussion