はじめに
個人開発にResolverというライブラリを使ってみました。
ResolverやDIについて色々とメモしていたのですが、サンプルを挟めば記事として役に立ってもらえるのではと思ったので投稿します。
(元がメモなので語尾がちょっと雑な部分もありますがご容赦ください)
Resolverとは
swift製の軽量DI/サービスロケーターライブラリ。
DIとは
依存性の注入のこと。
依存性とはオブジェクト等の持つ依存関係のこと。
それらを外部から与えることができるようにするデザインパターンをDI(Dependency Injection)と言う。
例えばViewModelでUseCaseを保持し、UseCaseの関数を呼び出している設計の場合、ViewModelはUseCaseに依存していると言える。
class LoginViewModel {
let loginUseCase = LoginUseCase()
func login() {
loginUseCase.login()
}
}
この時ViewModelが依存しているUseCaseを外部から与えることができると以下の利点がある。
- ユースケースに応じて依存を差し替えることができる。
- 例えばテストの時にモックに差し替えたり
- 抽象に依存しておいてその抽象に依存した具体ならなんでも注入できるようにするなど
なので結果としてテストが書きやすく、再利用性の高い作りになる。
またinitializerによって注入する場合や引数によって注入する場合、利用側から利用時に必要な依存対象が分かり、使い方を理解しやすくなる。
モックに差し替えるとは
テスト用のダミーの結果を返すインスタンスをモックと呼ぶ。
依存をモックに差し替えることで安全かつ網羅的にテストが書ける。
例えば通信結果を取得して何かする関数のテストでは動作が通信結果に依存する。
しかしテストの度に通信を送るのは無駄にリクエストを増やしたりDBにデータを操作しないといけなかったりでよろしくないので、結果のダミーを用意してそれを返すようにしたい。
enum LoginError: LocalizedError {
case dummy
}
protocol LoginUseCaseProtocol {
func login(_ completion: (Result<Void, Error>) -> Void)
}
struct LoginFailureUseCaseMock: LoginUseCaseProtocol {
func login(_ completion: (Result<Void, Error>) -> Void) {
completion(.failure(LoginError.dummy))
}
}
struct LoginUseCase: LoginUseCaseProtocol {
let loginRepository: LoginRepositoryProtocol
init(loginRepository: LoginRepositoryProtocol) {
self.loginRepository = loginRepository
}
func login() async throws {
try await loginRepository.login()
}
}
// ViewModelのテストの時にmockに差し替える。
class LoginViewModel {
let loginUseCase: LoginUseCaseProtocol
init(loginUseCase: LoginUseCaseProtocol) {
self.loginUseCase = loginUseCase
}
}
// 型を抽象化しているのでmockでも初期化できる。
let loginViewModel = LoginViewModel(loginUseCase: LoginFailureUseCaseMock())
抽象に依存しておいて抽象に依存した具体を注入するとは
型をprotocolで定義しておけば、そのprotocolに準拠したものならなんでも注入できる。
例えば書籍を登録する画面のViewModelが登録する本に依存していたとして、本の種類はたくさんあるけどprotocolは共通という場合は本の全種類分登録画面を用意しなくても良い。
class BookRegisterViewModel {
let book: BookProtocol // bookを差し替えても使える
let bookRegisterUseCase: BookRegisterUseCase
init(book: BookProtocol, useCase: BookRegisterUseCase) {
self.book = book
self.bookRegisterUseCase = BookRegisterUseCase
}
func register() {
bookRegisterUseCase.register(book: book)
}
}
依存性注入の方法
DIは依存性を注入することだが、その注入の仕方も色々ある。
Resolverでは以下の6つの方法に対応している。
1.Interface Injection
2.Property Injection
3.Constructor Injection
4.Method Injection
5.Service Locator
6.Annotation
いずれの方法も依存対象のインスタンスを登録しておき、Resoverに解決を任せる。
Service Locatorとは
依存先のオブジェクトを解決するための役割もったオブジェクトのこと。
Service Locatorに依存を登録おいて利用時にLocator経由で解決する。そのため利用時まで具体的な型が決まらないインスタンスに対しても依存の解決を行うことができる。
Service Locatorが依存を解決してくれれば上位レイヤーで対象物を初期化して下位レイヤー注入する必要がない。
ので不要なpublicやimportが減る。ViewModelの初期化でrepositoryまで一気に初期化するようなコードもなくなる。
しかし依存対象を先に登録しておく必要があり、登録漏れがあると依存を解決できない。
以下の記事が参考になる。
ServiceLocatorが推奨されない理由も解説してある。
本来不要であるServiceLocatorへの依存が発生してしまう
依存関係が分かりにくくなる
テストが困難になる
なるほどなあと思う。
一方でレイヤーごとにターゲットを分割しているiOSプロジェクトではServiceLocatorに依存解決を任せるメリットもあると思う。
ServiceLocatorから依存が取り出せることでマルチモジュールなプロジェクトで一貫して依存の解決を担うモジュールが用意できる。
依存関係の分かりにくさは後述する@Injectedによるアノテーションの使用とかで解決できるし、テストもServiceLocatorを丸っとモックに差し替えられるのはそれはそれで楽でいいかなという気もする。
なのでネックになるのはやっぱり依存対象の登録漏れだと思う。
しかしクックパッドではこの課題をsourceryによるコードの自動生成で防止している。
Resolverの特徴
-
軽量
- 700行くらいの単一のswiftfileで書かれている。
- コードが少ないしシンプルなので最悪サポートされなくなってもプロジェクトに組み込んで自分でもメンテできるのではと思う。(実際は大変だろうけど)
-
annotationによる自動的な依存解決をサポートしてる。
- @Injectをつけて宣言したプロパティはResolverが登録されたサービスから一致するものを探して注入する。
- Service Locatorに毎回アクセスして依存を取り出すようなコードを書かなくて良いので記述量が減る。
あとswinjectよりパフォーマンスに優れていたり、ユニットテストが完全に用意されてたりするのが売りっぽい。
詳しくは公式を参照
Resolverによる依存の登録と解決
Resolverを使ってやることは大きく分けると「依存対象の登録」「依存の解決」の二つだけ。
依存対象の登録
依存対象の登録はregister関数によって行う。
import Resolver
extension Resolver: ResolverRegistering {
public static func registerAllServices() {
register { LoginUseCase() }
}
}
上記のようにregisterメソッドのクロージャ内でで初期化したインスタンスがResoverに登録される。
registerAllServices
に記載したregisterはResolverがよしななタイミングで実行してインスタンスを登録してくれる。
実装を見てみるとresolveやregisterが呼ばれるタイミングで一回だけ実行するようにフラグで制御していた。
@inline(__always)
private func registrationCheck() {
guard registrationNeeded else {
return
}
if let registering = (Resolver.root as Any) as? ResolverRegistering {
type(of: registering).registerAllServices()
}
registrationNeeded = false
}
上記のregisterAllServices
のタイミング以外でも、自分で登録することもできる。
registerはResolverのstaticな関数のため、Resolver.register
を呼べば任意のタイミングでの登録も可能。
依存の解決
resolve()
を呼ぶことで解決できる。
class LoginViewModel {
let loginUseCase: LoginUseCase
init(loginUseCase: LoginUseCase) {
self.loginUseCase = loginUseCase
}
}
let loginViewModel = LoginViewModel(loginUseCase: Resolver.resolve())
Swift5.1から@Injectedというアノテーションによる解決をサポートしており、個人的にはこっちが好きである。
依存性が注入されることが明示できて、かつresolve()
を使って自分で解決する必要がない。
class LoginViewModel {
@Injected var loginUseCase: LoginUseCase
}
let loginViewModel = LoginViewModel() // initするだけで登録しておいたuseCaseが勝手に注入される。
Resolverを使うと何が嬉しいか
ResolverというよりService Locatorによる恩恵でもあるが、Resolverを使うと個別のレイヤーで依存性を解決できる。
例えば自分でUseCaseの依存性をViewModelに注入するときは以下のようなコードを書く。
let loginViewModel = LoginViewModel(
loginUseCase: LoginUseCase(
loginRepository: .init()
)
)
class LoginViewModel {
let loginUseCase: LoginUseCaseProtocol
init(loginUseCase: LoginUseCaseProtocol) {
self.loginUseCase = loginUseCase
}
func login() {
self.loginUseCase.login()
}
}
protocol LoginUseCaseProtocol {
var loginRepository: LoginRepositoryProtocol { get }
init(loginRepository: LoginRepositoryProtocol)
func login()
}
class LoginUseCase: LoginUseCaseProtocol {
var loginRepoisitory: LoginRepositoryProtocol
init(loginRepository: LoginRepositoryProtocol) {
self.loginRepository = loginRepository
}
func login() {
self.loginRepoisitory.login()
}
}
上記のコードのようにレイヤーごとにprotocolを用意してinitializerで注入していく場合、以下の課題がある。
- 毎回initで全部の依存性を渡さないといけない。
- ネストが深いし読みづらい。
- 上位レイヤーのインスタンスを初期化する時に下位レイヤーの依存性も一気に解決しないといけない。
- viewModelの初期化にrepositoryまでアクセスするのは辛かった。
- repositoryをdata層、viewModelの初期化をpresentation層で行う場合にpresentation層でdata層をimportしないといけない。
- かと言ってdefault引数でinitするのは依存性を注入できるようにしていること分かりにくくなるのでなんだかなあといった感じになる。
Resolverを使うと以下のようになる。
let loginViewModel = LoginViewModel()
class LoginViewModel {
@Injected var loginUseCase: LoginUseCaseProtocol
func login() {
self.loginUseCase.login()
}
}
protocol LoginUseCaseProtocol {
var loginRepository: LoginRepositoryProtocol { get }
func login()
}
class LoginUseCase: LoginUseCaseProtocol {
@Injected var loginRepoisitory: LoginRepositoryProtocol
func login() {
self.loginRepoisitory.login()
}
}
register { LoginUseCase() as LoginUseCaseProtocol }
register { LoginRepository() as LoginRepositoryProtocol }
それぞれの層でのinitでの注入が不要になり、ViewModelの初期化でRepositoryまで初期化しなくて良くなる。
@Injectedで依存が注入されることも分かりやすいのが個人的に良いと思う。
当然デメリットもあり、依存対象の登録が漏れていると@Injectedのプロパティの解決の時にクラッシュする。
クラッシュ自体を防止するために、依存を解決できなかった場合にnilを入れるようにしたりもできる。
class InjectedViewController: UIViewController {
@OptionalInjected var service: XYZService?
func load() {
service?.load()
}
}
あと初期化時に依存を解決できない場合に備えて@LazyInjectedで遅延注入したりできる。
class NamedInjectedViewController:UIViewController {
@LazyInjected var service:XYZNameService // searviceが呼び出されるまで解決されない。
func load(){
service.load() // このタイミングで解決される。
}
}
あと引数を渡して依存を解決したりもできる。
詳しくはこちらを参照。
やっぱり依存対象の登録漏れ自体を防止できるのが一番なのでクックパッドがやっているように、依存対象のDescriptorを用意して、DescriptorのOutputの型はregisterの実装を強制するみたいなことができると安心。
ついでに「@Injectedで宣言される型やresolve()によって解決される型はDiscriptorの作成を強制する」のようなルールもSouceryで設定できたらいいなと思うけどそこまでできるんだろうか。要調査。
Swinjectとの比較
SwinjectはYAMLの設定ファイルからコードの自動生成する機能がある。
まとめ
- マルチモジュールなiOSプロジェクトではResolverに依存解決を任せるメリットはある。
- ボイラープレートを減らす。
- 依存解決のために望まないレイヤーの公開をしなくてよくなるなどなど。
- 怖いのは依存対象の登録漏れ。
- しかしsouceryのようにコードの自動生成ツールを使えば依存対象の登録を強制でき、登録漏れを防止できそう。(まだ未検証。これからやっていき。)
※ DI周りの知識は参照する記事やフレームワークによって色々と認識が違う部分が多そうだったので、本記事でもそれは違うのではというところがあるかと思います。もしそういった箇所があればお気軽にコメントしていただけると幸いです。