Swiftで実装する基本的な依存性注入
こんにちは。株式会社PREVENTでiOSエンジニアをしている佐藤です。
Swiftでのアプリ開発で、依存性注入の実装方法について検討することがあり、どんな依存性注入ができるのかを調べたので、
その時の知見を記事にします!
依存性注入(DI)とは
依存性注入(長いので以降は略してDIと呼ぶ)は、プログラミングをしていると、言語問わずよく聞くと思います。
そもそもDIがよくわからんという場合は、まずは以下記事を見た後で、本記事を見る方がわかりやすいと思います。
以降では、本題であるSwiftでの基本的なDIを行うための実装を紹介します。
以下のような簡単なViewModelを例に、DIの実装を説明していきます。
例のViewModelの内容は単純で、onAppear
を呼び出すと、execute
で取得した値でstate
を更新するというものです。
イニシャライザによるDI
最も基本的なのは、イニシャライザでDIする方法です。
インスタンス生成時にイニシャライザの引数に依存するRepositoryを指定することで、DIを行います。
具体的な実装は以下のとおりです。
プロダクションコードの実装
Repositoryの実装
SampleRepository
というprotocolを定義し、アプリを動作させたときの実際の動作の実装は、SampleRepository
に準拠したSampleRepositoryImpl
に行います。
こうすることで、SampleRepositoryImpl
はSampleRepository
型として振る舞えるようになります。
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をどのようにテストするかを考えてみます。
SampleViewModel
はinit
でSampleRepository
型を注入できるようになっているので、SampleRepositoryImpl
を実装した要領でテスト用のRepositoryを注入すればテストできるようになりそうです。
テスト用のRepository実装
expectedOutput
をexecute
で返すことで、execute
の戻り値を制御できるようにします。
SampleRepositoryMock
もSampleRepository
に準拠させることで、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
型を参照することができます。
そして、ここでSampleRepository
のexecute
メソッドを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のテストコード実装
先ほど実装したSampleRepositoryMock
のexpectedOutput
に期待値を設定し、
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 }
}
}
DependencyKey
やDependencyValue
という見慣れない型が出てきていますが、これは自前で実装しているもので、後続で実装内容を説明します。
ここでは、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を行うための基盤の実装
ここから、先ほど出てきた、DependencyKey
やDependencyValue
、@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コンテナのイメージです)
@Dependency
でRepository
を取り出すときは、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
を登録します。
編集されたDependencyValue
をwithValue
メソッドで@TaskLocal
なプロパティであるDependencyValue.current
に注入することで、withDependency
で返却したインスタンス内の@Dependency
にテスト用のRepository
を注入することができます。
extension DependencyValue {
static func withDependency<R>(_ setDependency: (inout DependencyValue) -> Void, operation: () -> R) -> R {
var currentDependencyValue = DependencyValue.current
setDependency(¤tDependencyValue)
return DependencyValue.$current.withValue(currentDependencyValue) {
return operation()
}
}
}
@Dependency
の実装
wrappedValue
ではcomputed propertyで、DependencyValue
のsubscript
のgetterで取得したRepository
を返すようにしています。
この時のDependencyValue
は、@Dependency
を持つ型のインスタンス生成時(今回の例だと、SampleViewModel
のインスタンス生成時)のDependencyValue.current
になります。
そのため、withDependency
メソッドのクロージャ内で@Dependency
を持つ型のインスタンス生成を行ったときは、withDependency
メソッドで編集されたDependencyValue
が@Dependency
のinit
で参照されることになります。
@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できないといった場合の調査は他の方法よりかは大変になる場合が多いと思います。
TCAやswift-dependenciesを触ったことがある方は、わかるかもですが、今回紹介した実装はswift-dependenciesの模倣品です。
プロダクションコードからテスト用に依存を注入できる仕組みという観点で最小限の実装にとどめていますが、本家のライブラリはさらに高機能です。
そのため、こういったDI方法で実装したい場合は、swift-dependenciesを使用するとお手軽です。
ただ、アプリの多くの箇所でOSSに依存することはリスクでもあるので、それほど高機能は望まず、テスト用にDIするだけなら自前で実装したほうがメンテナンス性も高いので、プロジェクトの状況などを鑑みての検討が必要でしょう。
おわり
いかがだったでしょうか。
今回Swiftの依存性注入の実装方法を改めて調べてみて、全然知らなかった方法があり、いろんな方法があるんだなと思いました(小並感)
今回紹介したのはあくまで実装例で、同じ方針でももっと良い実装はあると思います。
また、今回はあまり扱わなかったですが、Swiftでもサードパーティー製のDIライブラリがあるので、そういったツールを使うのも手段の一つです。(とはいえ他言語と違ってDIライブラリといえばこれみたいなのは現状なさそうですが)
今回調べた限りだと、Swiftにおいて、DIの銀の弾丸はなさそうなので、それぞれの手段の良し悪しを考慮しつつ、プロジェクトにとって適切なDI方法を都度考えるようにしたいです。
Discussion