172
114

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 5 years have passed since last update.

第2のドワンゴAdvent Calendar 2017

Day 14

1分で分かる Dependency Injection in Swift

Last updated at Posted at 2017-12-13

この記事は第2のドワンゴ Advent Calendar 2017 14日目です。
昨日はnaariさんで、「TASBOTを作る」でした。

はじめに

前提として、プログラムは日々変わっていくものだと思います。
クラス同士の密結合がある場合、一部の変更が加わっただけでも変更があったクラスを扱うファイルを全て変更しないといけないかもしれません。
密結合という話で良く例として挙げられるものがシングルトンでの依存関係だと思います。

ここではシングルトンを採用しない例をし、その後でDependency Injectionのお話をしようと思います。
Dependency Injectionはイニシャルを取られたDIと略されます。

シングルトンに関して知っている方は、Dependency Injectionとはまで飛ばしてもらっても構いません。

シングルトンとは

GoFにより定義されたデザインパターンの内の一つで、アプリケーションの中にあるオブジェクトを一つのみ存在させるということであり、一つだけメモリを確保するということです。

Swiftでシングルトンパターンを実装してみます。

Person.swift
class Person {
    // ※スレッドセーフであり、遅延初期化
    static let shared = Person()
    private init() {}
    
    var name = "hogehoge"
}

let person = Person.shared
var test1 = person
person.name // "hogehoge"
test1.name // "hogehoge"
test1.name = "testtest"
person.name // "testtest"
test1.name // "testtest"

test1のnameを書き換えるとpersonのnameも一緒に変わっています。
オブジェクトを1つだけ存在させるというのはこのことで、複数インスタンスの生成やディープコピーができないようにする仕組みがシングルトンには必要です。

なので、structで定義してしまうと...

Person.swift
struct Person {
    static let shared = Person()
    private init() {}
    
    var name = "hogehoge"
}

let person = Person.shared
var test1 = person
person.name // "hogehoge"
test1.name // "hogehoge"
test1.name = "testtest"
person.name // "hogehoge"
test1.name // "testtest" // ここが違う!!

値型である為、インスタンスのディープコピーができてしまうので、これではシングルトンパターンとは言えません。

また、classで定義していても、こうしたコピーができる仕組みを提供している場合も同じことが言えます。

Person.swift
class Person: NSCopying {
    static let shared = Person()
    private init() {}
    
    func copy(with zone: NSZone? = nil) -> Any {
        let newInstance = Person()
        newInstance.name = name
        return newInstance
    }
    
    var name = "hogehoge"
}

let person = Person.shared
var test1 = person.copy() as! Person
person.name // "hogehoge"
test1.name // "hogehoge"
test1.name = "testtest"
person.name // "hogehoge"
test1.name // "testtest"

NSCopyingプロトコルが継承されているとクローニングを許してしまうことになるので、これもまたシングルトンパターンとは言えません。

シングルトンのどういう時に問題が起きるか

一つのオブジェクトのみ存在しているということに何の問題があるでしょうか。
使用していると特に問題が無いように思えますが、テストコードを書く時に思わぬ失敗を招くことがあります。
結論から言うと、シングルトンは前のテスト結果が後のテストにまで影響されてしまうということです。

今回は下記のようなHogeSingletonを用意します。

そもそもこんなテストしないだろうとは思いますが、あんまり良い案が思い浮かばずすみません。:bow:

HogeSingleton.swift
class HogeSingleton {
    static let shared = HogeSingleton()
    private init() {}
    
    private(set) var somethingCount = 0
    
    func doSomething() {
        // 何らかの処理
        somethingCount += 1
    }
}

doSomething()を呼ぶたびにその回数がカウントされるsomethingCountがあります。
続いて、HogeSingletonのテストを行ってみましょう。

HogeSingletonTests.swift
class HogeSingletonTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testCount() {
        XCTAssertEqual(HogeSingleton.shared.somethingCount, 0)
    }

    func testDoSomething() {
        let count = 10
        for _ in 0 ..< count {
            HogeSingleton.shared.doSomething()
        }
        XCTAssertEqual(HogeSingleton.shared.somethingCount, count)
    }
}
  1. testCountメソッド: 初期値が0であるかどうか
  2. testDoSomethingメソッド: 10回doSomethingを呼ぶと、somethingCountが10になっているかどうか

2つのテストがあるとして、これを実行してみます。

Test Case '-[xxxxxxxxxx.HogeSingletonTests testCount]' started.
Test Case '-[xxxxxxxxxx.HogeSingletonTests testCount]' passed (0.002 seconds).
Test Case '-[xxxxxxxxxx.HogeSingletonTests testDoSomething]' started.
Test Case '-[xxxxxxxxxx.HogeSingletonTests testDoSomething]' passed (0.001 seconds).

テストが通りました。
しかし、testCountって命名ミスったな、 testSomethingCountにしようかなと思って変えるとどうなるでしょうか。

HogeSingletonTests.swift
class HogeSingletonTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testSomethingCount() {
        XCTAssertEqual(HogeSingleton.shared.somethingCount, 0)
    }

    func testDoSomething() {
        let count = 10
        for _ in 0 ..< count {
            HogeSingleton.shared.doSomething()
        }
        XCTAssertEqual(HogeSingleton.shared.somethingCount, count)
    }
}

テストを実行してみます。

Test Case '-[xxxxxxxxxx.HogeSingletonTests testDoSomething]' started.
Test Case '-[xxxxxxxxxx.HogeSingletonTests testDoSomething]' passed (0.002 seconds).
Test Case '-[xxxxxxxxxx.HogeSingletonTests testSomethingCount]' started.
xxxxxxxxxx.swift:23: error: -[xxxxxxxxxx.HogeSingletonTests testSomethingCount] : XCTAssertEqual failed: ("10") is not equal to ("0") - 
Test Case '-[xxxxxxxxxx.HogeSingletonTests testSomethingCount]' failed (0.006 seconds).

失敗してしまいました。

これはXCTestCaseのテストが名前順で実行されるようで、testSomethingCountより先にtestDoSomethingのテストが呼ばれてしまい、somethingCountが0ではない為に失敗してしまいます。

この場合、毎回テスト前に初期化できるような仕組みが欲しいと思うようになります。
しかし、シングルトンパターンを使っているとイニシャライザをプライベートにしているので、HogeSingleton()というような初期化が難しいです。

そもそも順番によってテスト結果が左右されることは安全であるとはいえないのです。

シングルトンのまとめ

シングルトンが多数存在していると、メモリを確保することが特性なので、モバイルアプリケーションでは特にメモリとの戦いを強いられることがあるかもしれません。

.swift
class Hoge {
    class var shared: Hoge {
        return Hoge()
    }
    
    var name = "hogehoge"
}

シングルトンを絶対使わないというのは間違いだと思いますが、本当にシングルトンを採用しなくてはならないのか、上記のようなComputed Propertyじゃダメなのか、イニシャライザをプライベートにしないといけないのかを深く考えてみるのもいいかもしれません。

Dependency Injectionとは

オブジェクトを外から注入するということです。

オブジェクトを注入するタイミングでモックオブジェクトに差し替えたりすることができるので、テストを楽にできるようなります。
ぱっと見では実装コードを理解するのが難しかったりしますが、その点においてトレードオフになるかと思います。

では、どのように注入するのでしょうか。
どのタイミングでオブジェクトを注入しておくかによって名前が異なります。

  • Initializer Injection
  • Method Injection

etc..
今回はこの2つについての例を挙げようと思います。
DIにおいて、アンチパターンのことをBastard Injectionと呼ぶそうです。

Initializer Injection

.swift
protocol SocialPostService {
    func send(message: String)
}

class TwitterService: SocialPostService {
    func send(message: String) {
        // 投稿をする処理
    }
}

class SocialClient {
    let service: SocialPostService

    init(service: SocialPostService) {
        self.service = service
    }

    func send(message: String) {
        service.send(message: message)
    }
}

初期化時にオブジェクトを注入するパターンです
基本的なDIの説明でも、このパターンでの実装が最も多く、扱いやすいこともメリットです。

init(service: SocialPostService = TwitterService())
このようなデフォルトパラメーターをセットしておくことがBastard Injectionと呼ばれています。

Method Injection

.swift
protocol MethodInjectable {
    associatedtype Dependency
    func inject(with dependency: Dependency)
}

protocol SocialPostService {
    func send(message: String)
}

class SocialClient: MethodInjectable {
    
    struct Dependency {
        let service: SocialPostService
    }
    
    private var service: SocialPostService!
    
    func inject(with dependency: Dependency) {
        self.service = dependency.service
    }
    
    func send(message: String) {
        service.send(message: message)
    }
}

メソッドでオブジェクトを注入するパターンです。
注入をいつでもできるようになったり、private var service: SocialPostService!この部分において、varで宣言・nilを許容するかどうかなど、考えることが多くなるので、Initializer Injectionでの注入を選択したいところです。

しかし、Storyboardを使用したViewControllerはinit経由での初期化ができないので、Initializer Injectionは対象外になると思います。
その場合、Method Injectionを使って、初期化時に必要な物を注入するジェネレータを用意してあげると便利かと思われます。

.swift
protocol MethodInjectable {
    associatedtype Dependency
    func inject(with dependency: Dependency)
}

class SecondViewController: UIViewController, MethodInjectable {
    struct Dependency {
        let text: String
    }
    
    private var text: String = ""
    
    func inject(with dependency: Dependency) {
        text = dependency.text
    }
}

class SuperViewControllerHyperFactoryUltraGenerator {
    
    private init() {}
    
    static func makeSecondViewController(text: String) -> SecondViewController {
        let sb = UIStoryboard(name: "Second", bundle: nil)
        let vc = sb.instantiateInitialViewController() as! SecondViewController
        vc.inject(with: .init(text: text))
        return vc
    }
    
}

let secondVC = SuperViewControllerHyperFactoryUltraGenerator.makeSecondViewController(text: "")

Dependency Injectionのまとめ

色々な記事を見ていると、Initializer Injectionを最も多く見受けることができます。

DIでStrategyパターン(Initializer InjectionにてSocialClientでは、SocialPostServiceプロトコルでTwitterServiceの実装クラスを隠蔽していること)を使用するのは、実装変化時でも影響を及ぼさないよう(できるだけ疎結合)にするためです。

DIコンテナとは

DIコンテナは大抵の場合、Object Poolパターンで実装されており、複数個のオブジェクトを1つのコンポーネントで管理するものです。
コンポーネントの生成、依存解決にはDIコンテナを扱うことも多いかもしれません。
というのは、DIで実装していくと引数が多くなりがちですが、同じオブジェクトをまた生成するというコストを削減することが可能だからです。
DIとDIコンテナは相性が良い為に一緒に紹介されることがあります。

簡易的なDIコンテナを実装してみます。

DIContainerSample.swift
class Container {
    private var instances = [ObjectIdentifier: Any]()
    private let locker = NSRecursiveLock()
    
    func register<T>(_ type: T.Type, factory: @escaping () -> T) {
        locker.lock()
        defer { locker.unlock() }
        
        let key = ObjectIdentifier(type)
        instances[key] = factory() as Any
    }
    
    func resolve<T>(_ type: T.Type) -> T? {
        locker.lock()
        defer { locker.unlock() }
        
        let key = ObjectIdentifier(type)
        return instances[key] as? T
    }
}

このDIコンテナを扱ったオブジェクトの注入をしてみます。

.swift
protocol SocialPostService {
    func send(message: String)
}

class TwitterService: SocialPostService {
    func send(message: String) {
        // 投稿をする処理
    }
}

class FacebookService: SocialPostService {
    func send(message: String) {
        // 投稿をする処理
    }
}

class SocialMockService: SocialPostService {
    func send(message: String) {}
}

let container = Container()
container.register(SocialPostService.self) {
    TwitterService()
}

let service = container.resolve(SocialPostService.self)
service?.send(message: "HogeHoge")

Keyと生成したオブジェクトをセットで保管されているので、生成されたオブジェクトを取り出せます。
DIコンテナを扱うことで、生成されたオブジェクトを保持しておくことができるので、シングルトンの代用としての活用もできます。

まとめ

例で出したSocialMockServiceにいつでも差し替えられることが分かると思います。
容易にテストダブルに差し替えることができるので、実際のネットワークのシステム障害によってテストの結果が左右されないようにすることが簡単にできると思います。

DIを扱うことでテストがしやすくなったのは確かですが、実行コードが何なのかXcodeでコマンドジャンプを繰り返さないと分からなかったり、デメリットはいくつかあるようです。
得意不得意を見極めて実装できるエンジニアになりたいです。

ありがとうございました:bow: ぎりぎり〜

172
114
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
172
114

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?