70
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Swift】Bastard Injection の問題点、あるいは依存性逆転の原則について。または needle というDIコンテナの紹介。

Last updated at Posted at 2022-02-26

私達は、物事を分割して考えることしかできない。

それ故、私達は1つのクラスにすべてを書かず、クラスを分割する。

class FeatureA {
    func foo(): String {
        let b = FeatureB() // Use `FeatureB`
        return b.bar() + "!!"
    }
}

class FeatureB {
    ...
    func bar(): String {
        // do something
    }
}

ここでは、FeatureA クラスが FeatureB クラスを利用している。こういう時、 FeatureAFeatureB に依存していると表現される。FeatureB が存在しなければ、FeatureA は利用できないからだ。

image.png

前述のコードは問題なく動作するが、FeatureA のユニットテストをしたい時に問題が起こる。FeatureB をテスト用のモックに差し替えられないのだ。

だから、私達は Protocol という道具を使って、FeatureB を差し替えられるようにする。

例えば、以下のように。

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB) {
        self.b = b
    }

    func foo(): String {
        return b.bar() + "!!"
    }
}

// Introduce protocol
protocol FeatureB {
    func bar(): String
}

// Implement `FeatureB`
class FeatureBImpl: FeatureB {
    ...
    func bar(): String { ... }
}

インターフェースと実装を分離する意味で、実装クラスの末尾には Impl という単語を付けるように変更した。

image.png

UMLに詳しくない人のために補足しておくと、点線矢印が依存(dependency)、白抜きの三角矢印が実装(implementation)を表す。この記事を読み進める上では、それだけ理解しておけば概ね十分なはずだ。

ここでは FeatureB という依存性を外部から与えられるになっているが、これは一般的に『DI(依存性注入)』と呼ばれる。この例では、コンストラクタ経由でそれを行えるようにしているため、『コンストラクタインジェクション』などというパターン名が付けられている。

さて、これによって FeatureAImpl のユニットテストはもはや容易になった。

// Mock for unit-test
class FeatureBMock: FeatureB {
    func bar(): String { return "Hello" }
}

let mock = FeatureBMock()
let a = FeatureAImpl(b: mock) // Constructor Injection
XCTAssertEqual("Hello!!", a.foo())

ところで、プロダクションコードにおいて FeatureBImpl を生成するのはどこでやるべきだろう? 呼び出し元でいちいち生成するのは面倒だし、そもそも我々はテスタビリティのためだけに Protocol を導入したのだから、生成するのは常に FeatureBImpl のみだ。

だから私達は通常、以下のようにデフォルト引数で用意する。

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB = FeatureBImpl()) { // Bastard Injection
        self.b = b
    }
}

// Initialize and use
let a = FeatureAImpl()
a.foo()

これは十分うまく機能する。

依然として FeatureB はユニットテスト用のモックに差し替え可能だし、FeatureAImpl の利用者に FeatureBImpl を意識させる必要もないし、ボイラープレート的なコードも存在しない。

ただし、これは DI においてアンチパターンの一種とされており、”Bastard Injection”などと呼ばれている。

この記事のタイトルに含まれているものだ。

モジュール間の結合

しかし、Bastard Injection の何がマズイのだろうか? インターネット上の記事では以下のように説明されることが多いように思う。

FeatureAImpl は依然として FeatureBImpl に依存しており、これは結合度が強い状態である。

image.png

あぁ、確かにそのとおりだ。

実際のところ FeatureAImplFeatureBImpl なしではコンパイルすることができない。もちろん、私達はソフトウェア開発において、結合度が弱いに越したことはないことを理解している。

しかし、具体的にどのようなデメリットが発生するのだろう?  我々は、単に”結合度を弱くしたい”という理由だけで、Bastard Injection の手軽さを手放すようなことはしたくない。それがゼロコストであるならば話は別だが。

結論から言えば、複数のモジュールに分割した時にモジュール間に依存が発生し、それが大きな問題になることがある。他にも細かい問題が起こりうるが、おそらくこれが一番大きいだろうと思う。

例えば、FeatureAFeatureB をそれぞれ AB というモジュールに分割したとする。

// 📦 A

import B // Dependency to B module

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB = FeatureBImpl()) {
        self.b = b
    }
    ...
}


// 📦 B

protocol FeatureB { ... }

class FeatureBImpl: FeatureB {
    ...
}

外部モジュールに公開するためには public 修飾子が必要となるが、本質的なコードに着目できるようにコード例からは除外している。

当然ながらモジュール間には AB という依存関係が発生する。言い換えると、A モジュールをビルドするためには、B モジュールが必須になる。

image.png

結果として、ビルド時間が増加したり、モジュールごとに独立して開発する際の妨げになることがある。それはコードベースやモジュールの数が増えるに従って、より現実的な問題として現れてくる。

また、iOSアプリ開発などにおいては、機能(Feature)ごとにモジュールを分割している場合、モジュール間で相互に画面遷移が発生した場合に、循環依存が発生してビルドできないという問題にも遭遇しうる。

もちろん、これが課題になることもあれば、そうでないこともある。

少なくとも小さなプロジェクトであれば、問題が表面化するケースは少ないはずだ。しかし、昨今ではマルチモジュールによる開発も一般的になりつつあり、こうした問題を解決しておけるに越したことはない。

しかし、この問題は Bastard Injection を無くしただけでは解決できないことが分かる。FeatureBImpl への依存を無くしても、依然として FeatureB Protocol には依存しているからだ。

では、どのように解決できるのだろう?

依存性逆転の原則

例えば、以下のようにしたらどうだろう?

// 📦 A

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB) { // Remove Bastard Injection
        self.b = b
    }
    ...
}

protocol FeatureB { ... } // Moved from B module


// 📦 B

import A // Dependency

class FeatureBImpl: FeatureB { // new Dependency
    ...
}

このコード例では、Bastard Injection していた箇所をなくし、 FeatureB Protocol を A モジュールに移動している。

image.png

この変更によって、モジュール間の依存関係は AB という向きから、BA という逆の依存関係を持つようになった。

これは『依存性逆転の原則』とも呼ばれており、それを説明する際の一般的なコード例にあたる。

  1. 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。

  2. 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。

引用:Wikipedia(依存性逆転の原則)

しかし、モジュール間の依存関係 BA は依然として残っており、モジュール単位で独立してビルド・開発することは実現できていない。これを解決するにはどうしたらよいだろうか?

インターフェース用のモジュール

1つの方法として、インターフェース用のモジュールを別途用意するやり方が考えられる。

// 📦 Interface

protocol FeatureA { ... } // new!
protocol FeatureB { ... } // Moved from A module


// 📦 A

import Interface

class FeatureAImpl: FeatureA {
    let b: FeatureB

    init(b: FeatureB) {
        self.b = b
    }
    ...
}


// 📦 B

import Interface

class FeatureBImpl: FeatureB {
    ...
}

ここでは Interface というモジュールを新たに用意し、そこに Protocol を移動している。ついでに FeatureA についても Protocol を導入している。

image.png

これによって AB のモジュール間の依存性はなくなり、どちらも Interface というインターフェースだけを含むモジュールにのみ依存するようになった。

  • AInterface
  • BInterface

『依存性逆転の原則』の言葉に合わせるなら、”モジュールは双方ともインターフェースという抽象モジュールにのみ依存する”ようになった。

Bastard Injection を無くした代償

さて、これですべての問題が解決したかというとそんなことはない。

Bastard Injection を無くした為、今度は呼び出し元で依存するオブジェクトを生成しなくてはならなくなる。

// 📦 Main

import Interface
import FeatureA
import FeatureB

// Dependency objects
let b = FeatureBImpl()

let a = FeatureAImpl(b: b) // DI
a.foo()

image.png

この例では、大したことが無いように見えるが、仮に FeatureAImpl がより多くの依存オブジェクトを必要としていた場合はどうだろう? あるいは、FeatureBImpl が依存オブジェクトを必要としていた場合は? さらにその依存オブジェクトが別の依存オブジェクトを必要としていたら?

// 📦 Main

...

// dependency objects
let e = FeatureEImpl()
let d = FeatureDImpl(e: e) // DI
let c = FeatureCImpl()
let b = FeatureBImpl(d: d) // DI

let a = FeatureAImpl(b: b, c: c) // DI
a.foo()

実のところ依存関係は複雑になるのが一般的であり、Bastard Injection を手放した結果として、呼び出し元におけるボイラープレートのコードが大量に増えることになる。

私たちはオブジェクトを組み立てるコードを書きたいわけではない、単にそれを利用したいだけだ。

いっそのこと、こうしたオブジェクトの依存関係を勝手に解決してくれたらいいのに!

過去の賢明な開発者たちも同じように考えた。その結果として『DIコンテナ』というものが生み出された。

DIコンテナ

DIコンテナの考え方はシンプルで、依存関係を事前に定義しておき、オブジェクトを生成する際の依存関係はDIコンテナ側で勝手に解決してしまおうというものだ。

以下は擬似コードだが、DI コンテナの動作イメージを理解するのには役立つはずだ。

let container = DIContainer()

// Register objects
container.register(FeatureAImpl.class)
container.register(FeatureBImpl.class)

// Get object
let a = container.get(for: FeatureA.class)
a.foo()

事前に FeatureAImplFeatureBImpl を登録しておくことで、FeatureAImplFeatureBImpl という依存関係が勝手に解決された FeatureAImpl が手に入る。

これは依存関係がどれだけ深くネストしていようと変わらない。言い換えると、FeatureA が欲しいとだけ言えば、依存関係が解決されたオブジェクトが手に入り、我々はそれをただ使うだけだ。

Bastard Injection を利用した時とコード量は殆ど変わらないことが分かるはずだ。

uber/needle

Swift においても DI コンテナはいくつか開発されているが、ここでは Uber がメンテしている needle を紹介しようと思う。

なぜ needle なのかと言えば、私が触ったことがあるのが needle だけという単純な理由からなのだが、needle はコンパイルタイムセーフで、ボイラープレートに相当するコードを自動生成する仕組みを備えており、開発者にとって扱いやすい DI コンテナであろうと思う。

日本においても実プロダクトに導入しているケースもあるようだ。

needleでDI改善に取り組み始めた話
needleでモジュール間の画面遷移を実現した話 REALITY Advent Calendar #17

なお、この記事は needle の入門記事ではないし、もちろん needle の API リファレンスでもない。なんとなく needle の大まかな雰囲気を理解してもらおうという趣旨なので、実際に利用する際はリポジトリの README などを参照して欲しい。

さて、DIコンテナを利用する際は、依存関係の定義とオブジェクトの登録が必要になる。needle では、例えば以下のように書くことができる。

import NeedleFoundation

class RootComponent: BootstrapComponent {

    // register object
    var b: FeatureB {
        FeatureBImpl()
    }
    
    // child component
    var aComponent: AComponent {
        AComponent(parent: self)
    }
}

protocol ADependency: Dependency {
    var b: FeatureB { get } // depended to FeatureB
}

class AComponent: Component<ADependency> {

    // register object
   var a: FeatureA {
        FeatureAImpl(b: dependency.b) // resolve dependency
    }
}

これは初見では複雑に見えるかもしれない。

順に見ていこう。まず、needle を構成する要素の1つである Component からだ。

Component という名称がややこしいが、これは一種の名前空間・スコープを表現するものだ。そして、needle は階層的な依存関係定義を推奨しており、Component は階層的に定義できる。

このコード例では、以下のような名前空間・スコープが生まれていることが分かる。

class RootComponent: BootstrapComponent {
    ...

    // child component
    var aComponent: AComponent {
        AComponent(parent: self)
    }
}

class AComponent: Component<ADependency> {
    ...
}

image.png

この階層的な名前空間に、オブジェクトを配置していくイメージとなる。

次に、needle を構成するもう1つの要素である Dependency だ。これはその名のとおり依存を表現するもので、今回のコードでは FeatureB への依存を定義している。

protocol ADependency: Dependency {
    var b: FeatureB { get } // Dependency to FeatureB
}

そして、この依存を利用しているのが以下のコードだ。

class AComponent: Component<ADependency> {
    var a: FeatureA {
        FeatureAImpl(b: dependency.b) // resolve dependency
    }
}

すなわち、AComponentADependency という依存を持っており、より具体的に言うならば FeatureB に依存している。

この AComponent には FeatureA 型の a プロパティが定義されているが、これが DI コンテナへのオブジェクトの登録に相当するコードになる。dependency というプロパティは Component に用意されており、ジェネリクスで指定した Dependency のプロパティにアクセスできる。

image.png

こうして、FeatureAImpl の依存関係を解決して生成することを実現している。

ところで、この dependency.b で手に入る実体、すなわち FeatureBImpl はどこで生成されるのだろう? その定義を行っているのが、RootComponent における以下のコードとなる。

class RootComponent: BootstrapComponent {

    // register object
    var b: FeatureB {
        FeatureBImpl()
    }
    ...
}

まとめると、以下のようにオブジェクトが配置されているイメージとなる。

  • RootComponent
    • b: FeatureB = FeatureBImpl()
    • AComponent
      • a: FeatureAImpl(b: dependency.b)

image.png

FeatureAImpl を生成する際に、dependency.b とすることで、親の Component である RootComponent から b を取り出していることになる。これは階層がどれだけネストしていようと関係なく、needle は祖先のどこかに依存オブジェクトが定義されていれば、自動的に引っ張ってくる。

それはいささか魔法のように聞こえるかもしれないが、needle ではそれに相当するコードを自動生成するコマンドラインツールを提供している。

$ needle generate Sources/needle.generated.swift Sources

このコマンドは Sources ディレクトリ内のすべてのソースを解析し、実行する際に必要な Sources/needle.generated.swift を生成する。あとはビルド対象にこのソースを含めれば準備は完了となる。

さて、最後にコンテナからオブジェクトを取得するコードを見てみよう。

起動時にregisterProviderFactories という関数を一度だけ呼ぶ必要があることにだけ注意しよう。

import NeedleFoundation

registerProviderFactories() // Required

let component = RootComponent() // Create `RootComponent`

let a = component.aComponent.a // Get object
a.foo()

let b = component.b // In the same way
b.bar()

これで無事に Bastard Injection を利用すること無く、目的となるものが手に入った。

needle は複雑に感じただろうか。

しかし、結局のところ以下の繰り返しであり、依存関係の定義とオブジェクトの配置を行っているに過ぎず、慣れれば一種の DSL として受け入れられるはずだ。

  • Component で(階層的な)名前空間を作る
  • Dependency で Component が依存するものを定義する
  • 実体となるオブジェクトを生成するプロパティを Component 階層の祖先のどこかに設置する

また、この記事では説明しなかったが、インスタンスを Singleton として扱えるようにする shared という便利なメソッドも用意されている。インスタンス生成の責務を DI コンテナに一任できるのは一つのメリットと言える。

ところで、我々はモジュールを分割しているのだった。needle のコードを含め、各コードがどのモジュールに配置されているのか全体のコード例を示す(依然として public は省略している)。

// 📦 Interface

protocol FeatureA {
    func foo(): String
}

protocol FeatureB {
    func bar(): String
}


// 📦 Main

import Interface
import A
import B
import NeedleFoundation

class RootComponent: BootstrapComponent {

    var b: FeatureB {
        FeatureBImpl()
    }

    var aComponent: AComponent {
        AComponent(parent: self)
    }
}

registerProviderFactories()

let a = RootComponent().aComponent.a
a.foo()


// 📦 A

import Interface
import NeedleFoundation

protocol ADependency: Dependency {
    var b: FeatureB { get }
}

class AComponent: Component<ADependency> {
   var a: FeatureA {
        FeatureAImpl(b: dependency.b)
   }
}

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB) {
        self.b = b
    }

    func foo(): String {
        return bar() + "!!"
    }
}


// 📦 B

import Interface

class FeatureBImpl: FeatureB {
    init() {}

    func bar(): String {
        // do something
    }
}

モジュール間の依存は必要最低限になっており、ボイラープレートのようなコードも殆ど存在しないことが分かる。

Bastard Injection を利用し続けるメリット

さて、この記事では Bastard Injection の問題点と、それをやめた場合にコストとなるオブジェクトの依存解決を DI コンテナによって解決できるという内容を書いてきたが、それでも Bastard Injection を利用し続けるメリットはあるだろうか?

第1に、Bastard Injection は殆どのケースで十分に機能する。テスタビリティは確保できるし、それをやるためのコストも殆どない。実際に Apple は過去の WWDC において、Bastard Injection を利用したテスト方法を紹介している。

第2に、Bastard Injection は驚くほどシンプルだ。コンストラクタのデフォルト引数をサポートしていない言語では面倒だが、それをサポートしている Swift においては最低限のコード量で済む。needle は最低限のコード量で済むようになっており、依存関係を明確化できるというメリットもあるが、それでも管理するコード量は増える。

第3に、コードの追跡がしやすい。実装クラスがコンストラクタのデフォルト引数に書かれているので、Xcode から実装クラスをたどることは容易だ。Bastard Injection を利用しない場合においても、命名規約(Impl など)によってカバーできるだろうが、コードジャンプはしづらくなるはずだ。

つまるところ、あなたが Bastard Injection による現実的な課題に遭遇していない場合、それを廃止して DI コンテナを利用するように切り替えるべき強い理由はない。

しかし、プロジェクトの後半になってからテストや CI を導入するのに大きな傷みを伴うのと同様、あとから DI コンテナを導入するのにもそれなりの痛みが伴うはずだ。

あなたが新規プロジェクト、あるいは今後発展しうる可能性のある小さなプロジェクトで作業している場合、早めに導入することで、そのコストを最小限にできる可能性はある。

詳細ではなく抽象に依存する

ここまで長々と見てきたが、結局のところ私達は何を手に入れたのだろうか。

最初のコードの問題点は、FeatureAImplFeatureBImpl という詳細(実装)を知っていたことにある(名前は Impl 付きに変更している)。

class FeatureAImpl {
    func foo(): String {
        let b = FeatureBImpl() // Depends on details
        return b.bar() + "!!"
    }
}

class FeatureBImpl {
    func bar(): String { ... }
}

それを FeatureB という抽象に依存するようにし、詳細(実装)である FeatureBImpl を知らなくすることで、FeatureB を任意の詳細(実装)に差し替え可能にし、FeatureAImpl 単体でコンパイルすることを可能にした。

class FeatureAImpl {
    let b: FeatureB

    init(b: FeatureB) { // Depends on abstraction
        self.b = b
    }

    func foo(): String {
        return bar() + "!!"
    }
}

protocol FeatureB {
    func bar(): String
}

class FeatureBImpl: FeatureB {
    init() {}

    func bar(): String { ... }
}

マルチモジュールにおいては、Interface という抽象モジュールを用意することで、モジュール間の依存を無くすことで、モジュール単体でのビルドを可能にし、任意のモジュールに差し替えることも可能にした。

// 📦 Interface

protocol FeatureA { ... }
protocol FeatureB { ... }


// 📦 A

import Interface // Depends on abstraction

class FeatureAImpl: FeatureA {
    let b: FeatureB

    init(b: FeatureB) { // Depends on abstraction
        self.b = b
    }
    ...
}


// 📦 B

import Interface // Depends on abstraction

class FeatureBImpl: FeatureB {
    func bar(): String { ... }
}

結局のところ、詳細ではなく抽象に依存するように変更したに過ぎない。

  1. 上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。

  2. 抽象は詳細に依存してはならない。詳細(具象的な実装内容)が抽象に依存するべきである。

シングルモジュールにおいては FeatureBImpl という詳細を剥がすために FeatureB という抽象を導入し、マルチモジュールにおいては B モジュールという詳細を剥がすために、Interface という抽象を導入した。

詳細を知らないようにすることで、私達はそれを差し替え可能にし、独立してビルドすることを可能にする。それをクラス間で行うか、モジュール間で行うか、それだけの違いなのである。

image.png

DIコンテナは、単にこれを補完するための仕組みの1つに過ぎない。

まとめ

  • Bastard Injection は殆どのケースで十分に機能する。
  • ただし、マルチモジュールの場合にモジュール間の依存が発生する。
  • モジュール間の依存は、ビルド時間の増大などの様々な問題を引き起こす。
  • Bastard Injection を廃止し、依存性逆転の原則を適用することでそれを解消できる。
  • オブジェクトを組み立てる処理が大変になるが、DI コンテナによって負担を軽減できる。
  • uber/needle は Swift 向けの DIコンテナの1つで、コンパイルタイムセーフである。
  • テストや CI と同様、プロジェクトの早期に導入することで、将来的な導入コストを減らせる。
  • 詳細を知らないようにすれば、差し替え可能になり、独立してビルドできる。

Q&A - 追記:2022/03/02

Q. FeatureAFeatureBを同一のInterfaceモジュールに配置したのはなぜ?

もちろん分けても構わない。

例えば、今回のケースにおいてはInterfaceAInterfaceBという2つのモジュールに分けても全く問題ない。

しかし、InterfaceAInterfaceBに何らかの依存があった場合、インターフェース用のモジュール同士で依存が発生する。

より極端なケースにおいては以下のようなケースも考えられる。

// 📦 InterfaceA
protocol A {
    func createB() -> B
}

// 📦 InterfaceB
protocol B {
    func createA() -> A
}

これはInterfaceAInterfaceBでモジュール間の循環参照が発生しており、これはSwiftコンパイラによって弾かれる。

cyclic dependency declaration found: A -> B -> InterfaceB -> InterfaceA -> InterfaceB

一方で、循環参照が発生しないように設計するのであれば、import InterfaceXxxという宣言によって、依存しているインターフェース用のモジュールがソースコード上で明確化されるというメリットも考えられる。

私もインターフェース用のモジュールを分割した経験はないので、このあたりのノウハウをお持ちの方はコメントなりで教えて欲しい。

あとがき

久しぶりの技術記事のためか、翻訳記事のような文体になってしまいましたが、誰かしらの参考になれば幸いです。

なお、私自身 uber/needle を深く触ったわけではない為、この記事では十分に言及できていない可能性があります。例えば、現状で見えている大きな欠点として、associatedtypeを持つプロトコルを DI の対象にできない、といった問題を見つけています。

より実践的に触った際は、あらためて記事に起こしたいと思います。

70
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
70
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?