iOSDCで「コード生成による静的なDependency Injection」について話した & 口頭原稿を公開
このところかなり忙しく、iOSDCでちゃんとしたことを話せるのか不安でしたが、なんとか無事に終わりました。あまり会場を盛り上げることができず、後半はしどろもどろで死にたくなりましたが、面白かったと言ってくれた方もそれなりにいたので少し安心しました。
DIは今回の話以外にも色々なことに挑戦していて、最初はデフォルト引数を使った手動のinitializer injectionから始めて、SwinjectやCleanseなどのライブラリを試してみたり、Cake Patternを模倣してみたりしていました。それらを通じて、自分が求めるDIのプラクティスは
- 依存の宣言とインスタンスの取得のためのコードが単純かつ十分に少ない
- コンパイル時に依存関係の解決が検証される
というものだとわかりました。もしも「dependencyの宣言さえしておけば、あとはコンパイルエラーを直していけばDIが実現できる」というようなものが作れたら、iOSアプリ開発でも幅広く使われるようになるんじゃないかなあとも思っていました。
ちょうどこの頃にAndroidで使われているDaggerに触る機会がありました。Dagger自体は難しいし色々大変みたいなことを聞いていたのですが、コード生成で静的に解決するという考えが自分にはなかったので衝撃でした。そして、Swiftでも真似してみたいと思って挑戦したのが今回です。
自分は人前で話すときに一度全部の口頭原稿をつくっておいて、当日に様子をみながら説明を足したり削ったりするようにしています。せっかくなので、その口頭原稿をちょっと直して公開します。話し言葉なのでわかりづらいかもですが、、。
はじめに
それでは発表を始めます。「コード生成による静的なDependency Injection」というタイトルでishkawaが発表します。スライドのURLは先ほどハッシュタグ にツイートしたので、手元に欲しい方は見てみてください。
始めに自己紹介ですね。ishkawaというIDで活動しています。APIKitというライブラリを開発しました。 Swift実践入門という本を書いたりしました。まだ読んでない方は是非読んでみてください。
トークの概要はこのようになっています。初歩的なことから始めるので、DIは今日が初めてという方も理解できるのではないかと思います。
- Dependency Injection(DI)の概要
- DIをサポートする仕組みがなぜ必要か
- コード生成による静的なDI
DIの概要
はじめに、Dependency Injectionの基礎を5分くらいで簡単に説明します。Dependency Injectionって名前が強そうなので、ちょっと面を食らうかもしれませんが、概念自体は小さなものなので気軽に聞いてください。Dependency Injectionは頭文字を取ってDIと呼ばれています。
DIは一言で言ってしまえば「必要なものを外から渡すこと」です。Dependencyが必要なものを表していて、Injectionが外から渡すことを表しています。DIは今日が初めてという方も、「必要なものを外から渡すこと」というフレーズだけ覚えていれば、この後の話も大体理解できると思います。
- Dependency: 必要なもの
- Injection: 外から渡す
DIを具体的にイメージしてもらうために、画像のダウンローダーを作ることを考えてみます。ちょっと露骨に感じるかもしれませんが、この状態から始めましょう。
final class ImageDownloader {
private let urlSession = URLSession.shared
func downloadImage(with url: URL, completion: (UIImage) -> Void) {
let task = urlSession.dataTask(with: url) {...}
task.resume()
}
}
ImageDownloaderは画像をダウンロードするので、URLSessionを内部で使っています。この実装で画像をダウンロードする要件自体は満たせそうですね。
しかし、ImageDownloaderはURLSession.sharedという固定されたインスタンスを使うので、URLSessionの挙動を変えたい時に困ります。 例えば、バックグラウンドで実行するとか、通信をWi-Fiに限定するとか、スタブ化するとかそういう時です。スタブ化はユニットテストをする場合に重要になってきます。
では、URLSessionを差し替え可能にしましょう。今度は、URLSessionがinitializerの引数として渡されています。
final class ImageDownloader {
private let urlSession: URLSession
init(urlSession: URLSession) {
self.urlSession = urlSession
}
func downloadImage(with url: URL, completion: (UIImage) -> Void) {
let task = urlSession.dataTask(with: url) {...}
task.resume()
}
}
実は、これはDIの1つの形態になっています。ここではURLSessionがdependencyになっていて、それをinitializerで渡すのがinjectionになっています。たしかに、必要なものを外から渡していますね。
では、DIを取り入れることによって何が変わったか見てみましょう。
1つ目の変化は、dependencyの切り替えができるようになったということです。これで、先ほど挙げたバックグラウンドで実行するとか、通信をWi-Fiに限定するとか、スタブ化するとかということが可能になりました。また、こうした切り替えにImageDownloaderのコードの変更は不要です。
2つ目の変化は、dependencyの詳細を必要以上に決めずに済むということです。URLSessionが外から渡されるという構造になることで、決める必要のないことを外部に委譲して、ImageDownloaderの責務を小さくできています。
初めての方にもなんとなくDIを取り入れる目的は伝わったでしょうか。
今回は結構ゆるふわに説明しましたが、何がどう変わるのかもっときちんと知りたい方は「Inversion of Control」「Dependency Inversion Principle」などのキーワードで調べてみてください。
DIによる問題
ここまではメリットを説明してきましたが、当然デメリットもあります。
DIによる変化をもう1度見てみましょう。この2つですね。dependencyを外から渡すことによって、dependencyの切り替えができる、dependencyの詳細を必要以上に決めずに済むというメリットを得ていました。
一方で、これと同時に、dependencyの詳細を決めてインスタンスを渡すという責務が外側に生じています。dependencyが単純であれば大した責務ではありませんが、dependencyが複雑になってくると、この責務は重くなってきます。
一般的なアプリのViewControllerを考えてみましょう。ViewControllerのdependencyがImageDownloaderとAPIClientだとします。 ImageDownloaderのdependencyはURLSession、キャッシュ機構などが考えられます。 APIClientのdependencyはURLSessionやキャッシュのほか、認証情報の管理機構などが考えられます。
DIを導入すると、こうした依存関係を解決する責務をViewControllerは負いません。なので、ViewControllerを生成する側にこの責務が生じます。生成する側としては、ただ画面遷移をしたいだけなのに、図の右側のことまで全部決めなきゃいけないとなると、割りに合わない気がしてきますよね。
では、ここで一旦論点を整理してみましょう。DIなしの場合は密結合だがインスタンスの生成は簡単となっていて、DIありの場合は疎結合だがインスタンスの生成が面倒となっています。
- DIなし: 密結合だがインスタンスの生成は簡単
- DIあり: 疎結合だがインスタンスの生成が面倒
この状況でどちらを取るべきかというのは難しいですよね。ソフトウェア的に後者であるべきとは思っていても、疎結合にすることによるメリットがもしもそれほど想定できないとしたら、前者を選ぶのも不思議ではないと思います。
しかし、もし「疎結合だしインスタンスの生成が簡単」という選択肢があるとしたらどうでしょう。 これに近づけようというのが次の話です。
- DIなし: 密結合だがインスタンスの生成は簡単
- DIあり: 疎結合だがインスタンスの生成が面倒
- ???: 疎結合だしインスタンスの生成が簡単
DIをサポートする仕組み
DIを導入したことによる問題は、あるインスタンスを利用するにあたって、そのdependencyであるインスタンスをすべて取得しなければならないということでした。そうなったら、dependencyの取得をもっと簡単にする方法はないかと考えますよね。幸いこの分野には先人たちがたくさんいるので、他の言語などで実践されている方法を参考しながら、Swiftでもできないか考えます。
具体的な仕組みを見る前に、どうすればDIをサポートできるのか、抽象的な視点から見ていきます。 依存関係というものはこんなグラフでした。
問題としていたのは、一番左のものをインスタンス化するために、一番右のものまですべて何とかしてインスタンスを取得しなければならないということでした。もし、この右のものをなるべく自動的に取得できるとしたら、DIが簡単になりますよね。これがDIをサポートする仕組みです。
自動化は、自動的にインスタンスを取得できる型を方法とセットで登録することで実現します。一度自動的に取得するやり方を決めてしまえば、あとはその方法を使って自動的にインスタンスの取得が行われるようにしちゃおうというわけです。
インスタンスを自動的に取得する方法
インスタンスを自動的に取得する方法は、大きく分けるとこの2つがあります。Androidでよく使われているDaggerでもこの2種類の方法を提供しています。
- DI用のinitializerを登録する
- DI用のprovider methodを用意する
まずはじめに、DI用のinitializerから見ていきましょう。Androidでよく使われるDaggerの例です。Javaなのでinitializerではなくconstructorと呼びますね。
final class APIClient {
@Inject
APIClient(URLSession urlSession, Cache cache) {
...
}
}
Daggerは@Inject
アノテーションがついたconstructorをDIに使えるものとみなして、依存関係を解決するときに使用する候補にします。3行目のところがconstructorなのですが、引数はurlSessionとcacheになっていて、これがAPIClientのdependencyを宣言しています。
グラフで見ると、ちょうどこんな感じになります。
APIClientのインスタンスは自動的に取得できて、それにはURLSessionとCacheが必要という状況になっています。
次は、provider methodの方を説明します。provider methodは名前の通り、インスタンスを提供するメソッドです。
provider methodはDaggerではこのように実装します。@Module
アノテーションがついたクラスの、@Provides
アノテーションがついたメソッドがprovider methodとなります。
@Module
final class APIModule {
@Provides
static APIClient provideAPIClient(URLSession urlSession, Cache cache) {
...
}
}
provideAPIClientという箇所がprovider methodになっていて、戻り値の型APIClientで取得できるインスタンスの型を表します。そして、引数でdependencyを表しています。
DaggerはDIに使えるprovider methodとして持っておいて、依存関係を解決するときに使用する候補とします。大体constructorと同じですね。
ここまでで、どの型のインスタンスを自動的に取得できて、それには何が必要かを定義できるようになりました。依存関係のグラフで説明すると、ちょうど青の枠で囲まれた各ノードの定義ができるようになりました。
実際に欲しいのは、このグラフの全体なので、次のステップに進みましょう。
グラフの生成
次はグラフの生成です。
もう1度グラフを見てみましょう。各ノードは自分が依存しているものを知っています。
全体にあるノードが提供するものと、依存しているものを突き合わせていくと、グラフを構築できますね。そして、グラフの構築結果を静的に表現する方法の1つがコード生成です。
ちょっと話を端折るんですが、Daggerはこんな感じでコードを生成します。
@Generated
public final class DaggerAPIComponent implements APIComponent {
private Provider<URLSession> urlSessionProvider;
private Provider<Cache> cacheProvider;
private Provider<APIClient> apiClientProvider;
private void initialize() {
urlSessionProvider = ...
cacheProvider = ...
apiClientProvider = APIModule_APIClientFactory
.create(urlSessionProvider, cacheProvider);
}
@Override
public APIClient apiClient() {
return apiClientProvider.get()
}
}
ここで重要なのは、initializeという真ん中あたりのメソッドで、必要なものを順を追って組み立ててるということです。はじめにurlSessionProviderとcacheProviderを取得して、次にそれらを使ってapiClientProviderを取得しています。コード生成による依存関係の解決は、このようにして実現できるのだということがわかりましたね。
Swiftでの実践
では、こうした仕組みをSwiftで実現するにはどうしたらいいのか、考えます。Daggerをそのまま移植することはあきらめて、最小限の3つの要素を提供しましょう。
- DIに使用できるinitializerの定義
- DIに使用できるprovider methodの定義
- コード生成による依存関係の解決
DIに使用できるinitializer
まずはじめに、DIに使用できるinitializerですね。Daggerの場合はこのように実現していました。
final class APIClient {
@Inject
APIClient(URLSession urlSession, Cache cache) {
...
}
}
@Inject
アノテーションがDIに使用できることを示しています。Swiftにはアノテーションはないので、当然この方法は使えません。
代わりに、Injectableプロトコルを用意して、そこで宣言したinitializerをDIに使用できるものとして扱います。そして、dependencyはassociatedtypeのDependencyのストアドプロパティを使って表します。
protocol Injectable {
associatedtype Dependency
init(dependency: Dependency)
}
final class APIClient: Injectable {
struct Dependency {
let urlSession: URLSession
let cache: Cache
}
init(dependency: Dependency) {...}
}
この例はAPIClientになっていますが、そのdependencyにURLSessionとCacheを設定しています。 これで、DIに使用できるinitializerができました。
DIに使用できるprovider method
次は、provider methodです。Daggerでは、@Module
というアノテーションでprovider methodを提供する専用の場所を指定していました。
@Module
final class APIModule {
@Provides
static APIClient provideAPIClient(URLSession urlSession, Cache cache) {
...
}
}
アノテーションはSwiftにはないので、ここでもプロトコルをマークとして使います。
protocol Resolver {}
今回はResolverという名前にしました。中身がないのですが、これは単なるマークとして使うためです。実際には、これを継承したプロトコルを用意して、そこでprovider methodを宣言します。
protocol AppResolver: Resolver {
func provideURLSession() -> URLSession
func provideCache() -> URLSession
func provideAPIClient(urlSession: URLSession, cache: Cache) -> APIClient
}
例えば、このAppResolverのようなものです。ここにprovider methodのインターフェースを定義していきます。依存関係は、メソッドの引数で表しており、例えば、APIClientはURLSessionとCacheに依存しています。これで、provider methodのインターフェースが定義できました。
依存関係を解決するコード生成
次のステップは依存関係を解決するコードの生成です。
依存関係は、Injectableと、Resolverのprovider methodからわかるのですが、記述されているコードを解釈して、それを補完するコードを生成するということは、Swiftのコードを解釈する仕組みが必要です。
幸い、AppleはSwiftのコードを解釈するSourceKitを公開しており、それをラップしているSourceKittenというライブラリもあります。SourceKittenは例えば、こういう感じの解釈結果を渡してくれます。
{
"key.accessibility" : "source.lang.swift.accessibility.internal",
"key.kind" : "source.lang.swift.decl.struct",
"key.name" : "A",
"key.inheritedtypes" : [
{
"key.name" : "Injectable"
}
]
}
ここから、型の名前や、準拠しているプロトコルなどがわかります。今回は、これを使ってコードを解釈します。
依存関係の解決は、コード生成によって行います。Resolverのprotocol extensionに、全てのノードのインスタンスを取得するmethodを生やします。
例をみながらどのようなコードを生成するか見てみましょう。このような依存関係があったとします。AはBに依存し、BはABResolverのprovider methodによって提供されていますね。
struct A: Injectable {
struct Dependency {
let b: B
}
init(dependency: Dependency) {}
}
struct B {}
protocol ABResolver: Resolver {
func provideB() -> B
}
これに対して、このようなコードを生成します。
extension ABResolver {
func resolveA() -> A {
let b = resolveB()
return A(dependency: .init(b: b))
}
func resolveB() -> B {
return provideB()
}
}
resolveAでは内部でBを生成して、それを引数にとってAをインスタンス化しています。これがAとBの依存関係の解決です。
そして、このResolverを実際に使うには、ABResolverに準拠した型を用意します。ABResolverに必要なものはprovideB()なので、これを提供して実際にBやBに依存しているAを解決できるようにします。
final class AppABResolver: ABResolver {
func provideB() -> B {
return B()
}
}
これで依存解決をして、実際にインスタンスを取得できるようになりました。
🎉
めでたいですね。
プロトコルにした意味は?
ここで、プロトコルにした意味ってあったんだっけ?ということを振り返ります。
意味は大きく分けて2つあって、1つはprovider methodの実装なしでグラフが組めるということです。これによって、提供されるインスタンスの差し替えが可能になり、テストなどで特に便利です。
2つ目は利用時に必要なものが揃っていることがコンパイル時に保証されるということです。必要なものをプロトコルで宣言したことで、必要なものをすべて提供しなければコンパイルを通すことはできません。
自動的に取得できないdependencyは?
dependencyがすべてInjectableやprovider methodで取得できるとは限りません。例えば、ユーザーのプロフィール画面を考えてみてください。表示するユーザーのIDもdependencyですが、これは画面ごとに異なる値を渡すので、パラメーター化されているのが適切ですよね。こういうケースに対応するため、Injectableでもprovider methodでもない型は、resolveメソッドのパラメーターで渡すようにします。
先ほどのUserProfileViewControllerの状況をコードをこのようになります。実用的な雰囲気が出てきましたかね。
final class UserProfileViewController: UIViewController, Injectable {
struct Dependency {
let userID: Int64
let apiClient: APIClient
}
init(dependency: Dependency) {}
}
final class APIClient {...}
protocol AppResolver: Resolver {
func provideAPIClient() -> APIClient
}
UserProfileViewControllerのdependencyはuserIDとapiClientになっています。userIDのInt64はInjectableでもないですし、provider methodでも提供されていません。
これに対しては、このようなコードを生成します。
extension AppResolver {
func resolveAPIClient() -> APIClient {
return provideAPIClient()
}
func resolveUserProfileViewController(userID: Int64) -> UserProfileViewController {
let apiClient = resolveAPIClient()
return UserProfileViewController(dependency: .init(userID: userID, apiClient: apiClient))
}
}
こうすることで、APIClientのような自動的に解決できるところは自動的に解決し、残りの都度指定が必要なものだけをパラメーターとして受け取れるようになりました。
デモ
(実際にコードを変更しながら、依存解決のコードが変化するところを見せる)
コードの公開
今日お話しした内容のコードは、こちらのリンクで公開しています。もし興味があれば、読んでみてください。