Swiftに合わせて設計されたDIコンテナ、Swift DIの紹介
前置き
DI(Dependency Injection)は、持続可能なコードベースを構築する上でほぼ必須のデザインパターンだと言えます。Swiftでクライアントとサーバどちらを開発する場合にも必ず導入します。
そんなDIコンテナですが、個人的にSwiftにはこれといった定番フレームワークが無いと感じていました。
シンプルなDIコンテナならペライチの.swiftファイルで実装できてしまうため、ライブラリとして公開するまでもないでしょう。これまで多くのプロジェクト独自のコンテナ実装を目にしてきました。どれも似たような形かつシンプルで、たしかに公開するまでもありません。しかしシンプルであるが故に、機能的な脆さを感じることもあります。
逆に高機能なものは、メンテナンス性に難があって実用に耐えないと感じていました。
高機能を実現するためにコードベースに制約が生まれることがあり、それがSwiftとの親和性を欠き、小回りが効かなくなることがあります。
またSwiftは現在も大きな変化を遂げています。そのため、ツールが言語の進化に追いつけない場合もあります。
参考までに筆者がこれまで触れてきたDIフレームワークを挙げておきます(Java系のものが多いですが)。
- Spring BootのDI
- Koin
- Dagger2
- Needle
- その他、プロジェクトが独自に用意したDIコンテナ実装を何種類か
これらを踏まえて、私の思う理想的なDI実装に必要な要件は以下であるとわかってきました。
理想的なDI実装に必要な要件
コンテナ内のインスタンスは上書き可能であること
テストコードを書く上で部分的な差し替えの手続きが面倒だと、モチベーションが下がってしまいます。
上書きがサポートされていて差し替えが容易だと、モチベーションを維持しやすいです。
気持ちの問題のようですが、複雑なテストシナリオはただでさえ書く気持ちが重いのでサボりにつながります。
Spring BootではMockitoと組み合わせて@MockBean
で差し替える操作が非常に快適でした。
コンテナに階層構造を作れること
全ての要素をフラットに管理するコンテナも良いですが、「ほとんどのインスタンスが生成に要求するけどルートレベルでは得られない要素」があると面倒になりがちです。
多くの依存がファクトリ関数として存在することになります。
これを回避するために色々工夫が行われるでしょうが、良かれと思って作ったユーティリティが余計な手間を招くケースもあるでしょう。
DIコンテナ側が階層構造を持てると綺麗に収まると思います。
依存の登録ミスを素早く検知できること
依存の登録にミスにより値の取得に失敗する場合、多くはクラッシュさせるしかないと思います。
リファクタリングが行われた結果、深い画面のどこかで依存を取得できない状況になり、気づかず出荷されてクラッシュ。というシナリオは誰もが目にしたことでしょう。
外部ツールを使った静的解析を行うフレームワークは、この部分に大きなアドバンテージがあります。
一方で、この部分がツールのメンテナンス難易度を高くします。
登録ミスの検知レベルには3段階あると思っています。
- コンパイル時またはコンパイル前後の外部ツールによる静的解析により検出
- アプリケーション起動時に検出
- コンテナからインスタンスを取り出す際に検出
当然1が理想ですが、私は2までは妥協できると思っています。3は安全機構がないということであり、事故につながるので避けます。
言語機能だけで完結し、外部の解析ツールに依存しないこと
外部ツールが必要になるとセットアップが面倒になり、CIなども考えると手間は大きいです。
私はmockoloをaarch64のLinuxで実行できるようにするためのサポートを行いましたが、これも外部ツールである故のものです。
また、これまで述べたように外部ツールのメンテナンス性には課題があります。
Swiftの言語機能だけで完結する場合、extensionやoverloadによってハックができるかもしれませんし、何が起きているかも調べやすいです。
Swift DIの紹介
上記のようなことを悶々と考えつつ長らく良い実装が思いつかなかったわけですが、最近ようやく光明が差してきてDIフレームワークを実装できました。
以下の特徴を持っています。
- 値型のコンテナ
- マクロによるアノテーションベースの依存定義
- 子コンポーネントによる依存の上書き
- コンポーネント初期化時に依存登録チェック
使用例
import DI
extension AnyKey {
// (1) キーとなる値を定義する
static let name = Key<String>()
static let foo = Key<Foo>()
}
@Component(root: true)
struct RootComponent {
// (2) @Providesでアノテートして提供する値を定義
@Provides(.name)
var name: String { "RootComponent" }
@Provides(.foo)
func foo() -> Foo {
// (3) getを通して値を取得できる。子コンポーネントから上書きされうるため、自分のプロパティは直接呼び出さない。
return Foo(name: get(.name))
}
var childComponent: ChildComponent {
ChildComponent(parent: self)
}
}
@Component
struct ChildComponent {
// (4) 子コンポーネントは親コンポーネントの提供する値を上書きできる
@Provides(.name)
var name: String { "ChildComponent" }
func testFooName() {
print(get(.foo)) // => Foo(name: "ChildComponent")
}
}
仕組みの解説
(1)ではDIコンテナから値を取り出すためのキーを定義します。
キーとして型が使われるケースをよく見ますが、同じ型で違うものを複数提供したい場合やis-a関係がある場合など容易に破綻するので、明示的な方針にしています。
(2)では値の提供をアノテーションします。
プロパティか、引数のない関数に付与できます。
@Provides
マクロは、アノテートされた関数を以下のように展開します。
// 定義
@Provides(.foo)
func foo() -> Foo {
return Foo(name: get(.name))
}
// 展開されたもの
@Sendable private func __provide__foo(container: DI.Container) -> Foo {
var copy = self
copy.container = container
let instance = copy.foo()
assert({
let check = DI.VariantChecker(.foo)
return check(instance)
}())
return instance
}
DIコンテナはこの関数を通して値を取得し、これが上書きロジックの本質となっています。
DIコンテナはキーごとにそれぞれこの__provide_
関数を持つように初期化されます。
子コンポーネントが初期化される際は親コンポーネントのコンテナをコピーして、上書きながら初期化を行います。
DIコンテナがアノテートされた関数(ここではfunc foo()
)を呼び出す際は、それが定義されたコンポーネント自身をキャプチャした状態で呼び出されるため、その関数の中のコンテナは古い状態になっています。
それでは上書きが正しく機能しないため、__provide_
関数を通すことでコンテナの中身を子コンポーネントの状態に入れ替えています。
VariantCheckerは何?
VariantChecker
はアノテートされた関数が返している値がKey
で定義された型と一致しているかどうかを調べます。
事前のコンパイルエラーを分かりやすくするために用意されていて、実行時には何もしません。
アノテートされる関数の名前と返り値の型について
コンテナはマクロで生成された関数を通して値を取得するため、アノテートされる関数の名前はなんでも良いことになっています。上記例では、.foo
というキーのためにシグネチャと返り値の型と合わせて、foo
と3回もタイプする必要が出ています。
名前がなんでもいいのであれば、関数名をキーにして省略できるようにするだとか、
#Provides(.foo) {
Foo(...)
}
といったマクロによる宣言でもいいんじゃないかという改善案があります。
この設計方針については、記述が減って良いと感じる一方で、より魔術的な文法になってしまうという側面があります。今回は親しみやすいSwiftの値型コンテナの実装に近づけるために、冗長な方針をとりました。
(3)では値をコンテナから取り出しています。
このget
関数は、@Component
マクロによって自動で付与されるComponent
プロトコルに生えているものです。
@Component
マクロは、次のように展開されます。
struct ChildComponent {
...
static var requirements: Set<DI.AnyKey> {
[.foo]
}
var container = DI.Container()
init(parent: some DI.Component) {
initContainer(parent: parent)
}
private mutating func initContainer(parent: some DI.Component) {
assertRequirements(Self.requirements, container: parent.container)
container = parent.container
let __macro_local_3setfMu_ = container.setter(for: .name)
__macro_local_3setfMu_(&container, __provide__name)
}
}
extension ChildComponent: DI.Component {}
initContainer
関数で自身がProvideする値をコンテナにセットしています。
また、自身がgetするキーもSelf.requirements
として保持し、assertRequirements
でコンテナに要求されたキーが全て含まれているか検査します。
これが、コンポーネント初期化時の依存登録チェックです。
ここで、Provideするキーやgetするキーの一覧をどのようにして取得しているのか不思議に思うかもしれません。Attachedマクロはそれが付与された要素の中全てのコードを読み取れるため、ここではStructDeclに含まれる全ての@Provides
のアノテーションとget(_:)
の呼び出しを検査しています。
なお、initContainer
関数は規約としてinit
の末尾で呼び出すことにしています。
(init
が未実装であればマクロが勝手に定義してくれますし、すでに実装済みであれば内部でinitContainer
の呼び出しがあるかを検査して、なければ警告を出してくれます。)
依存登録チェックの例
例えば上記例のRootComponentでうっかり.foo
を提供し損ねていた場合。
import DI
extension AnyKey {
static let name = Key<String>()
static let foo = Key<Foo>()
}
struct Foo {
var name: String
}
@Component(root: true)
struct RootComponent {
@Provides(.name)
var name: String { "RootComponent" }
// @Provides(.foo)
// func foo() -> Foo {
// return Foo(name: get(.name))
// }
var childComponent: ChildComponent {
ChildComponent(parent: self)
}
}
@Component
struct ChildComponent {
@Provides(.name)
var name: String { "ChildComponent" }
func testFooName() {
print(get(.foo))
}
}
let rootComponent = RootComponent()
let childComponent = rootComponent.childComponent // Assertion failed: Keys not found in the container. missing: [DI.Key<Example.Foo>]
childComponent.testFooName()
このようにChildComponentの生成時に検知され、testFooName
の呼び出しを待たずにクラッシュさせられます。
その他の例
リポジトリのexampleディレクトリに、簡易ではありますがクライアント、サーバにおけるそれぞれの利用例が掲載されています。
おわりに
Swift DIは、自分で述べた理想的な要件のうち依存登録ミスの検知と外部の解析ツールに依存しない部分について、満たせてはいますが弱いです。
機能と使いやすさ、実装の簡潔さのトレードオフを強く感じました。
現実的な範囲で十分実用可能なラインを考えてこのあたりに線引きしました。
依存登録ミスのチェックはComponentの初期化時に行われるため、葉の多いコンポーネントツリーにしてしまうと恩恵を受けにくくなります。
Swift Testingはランタイムメタデータから動的にテストケースを集めるみたいな手法を実現していて、同じように何かいい方法でコンポーネントツリーをランタイムに構築して、全経路を初手で検査することができればと思っています。
Discussion