サブする iOS で取り組んでいる DI(Dependency Injection)可能な実装について


iXIT で iOSエンジニアをしています、 @branch10480 です。今回は私が担当しているプロダクト サブする で対応している 「DI(Dependency Injection)可能な実装」について書いていきたいと思います。

依存性注入:DI(Dependency Injection)って何?

この DI (Dependency Injection) については Wikipedia ではこのように記載されています。

DIを利用したプログラムを作成する場合、コンポーネント間の関係はインタフェースを用いて記述し、具体的なコンポーネントを指定しない。具体的にどのコンポーネントを利用するかは別のコンポーネントや外部ファイル等を利用することで、コンポーネント間の依存関係を薄くすることができる。

最初、「具体的なコンポーネントを記載しない」、「コンポーネント間の依存関係を薄くすることができる」などがあまりピンと来ず、どういうケースで役に立つのかがイメージできませんでした。

ですのでその時の自分に説明するつもりで書いてみようと思います。

DI ができる実装をすると何の役に立つの?

ずばり結論から言ってしまうと、

  1. モックやスタブなど、テストモジュール(テストデータを返すモジュールなど)への差し替えができるようになる
  2. 開発サーバや外部サービス(Firebaseなど)を実際に用意しなくてもよくなる
  3. プロジェクト内にテストコードが実装できるようになる

ことがメリットになります。

具体例を交えてイメージできるようにします

今回は仮にパスポートデータの取得とその件数によってメッセージを表示する機能を例にしましょう。イメージとしてはこんな感じです。


仮の仕様としては

  1. データ取得を行う
  2. データ件数が0件の時は「データがありません」というアラートを表示する

これでいきましょう。

※ なお、説明を簡略化するため、これから記載するコードではデータ取得を呼び出す部分を ViewController に記載します。

DI できない構成を確認する

DI を意識していない場合の実装の例を書いてみました。
まずは Passport オブジェクト と APIクライアント定義はこう。

/// パスポートクラス
class Passport {
    // JSONからPassportオブジェクト配列に変換して返す
    static func parse(from json: JSON) -> [Passport] {
        // ...
    }
}

/// APIクライアント
class APIClient {
    static let apiServerBaseURL = "https://dev-server"

    /// パスポート取得API
    static func getPassports(completion: @escaping ([Passport])->Void) {
        let url = URL(string: apiServerBaseURL + "/passport")

        // 開発サーバへデータ取得
        // ...
        // 結果 JSON をサーバから受け取る -> JSONから Passport オブジェクトの配列を生成
        // 便宜的にサーバからのレスポンスJSON を JSON とする
        let passports = Passport.parse(from: JSON)
        completion(passports)
    }
}

続いて呼び出す方の View 側の処理はこうです。

/// View側
class ViewController: UIViewController {
    /// 画面ロード時
    override func viewDidLoad() {
        super.viewDidLoad()

        APIClient.getPassports(completion: { passports in
            if passports.isEmpty {
                // 「データがありません」アラート表示処理
            }
        })
    }
}

この構成の問題点と修正の方針

ここまでで仕様は満たせたはずです。

しかしこれだと常に APIサーバ(https://dev-server) をコールするため、ちゃんと稼働するテスト用APIサーバを用意する必要があります。APIがまだ開発中の場合だと開発しにくいですね。

この状況は 外部のAPIサーバに依存している 状態と言えます。

ここからはこの依存している部分を差し替え可能な形にして他の依存パーツに置き換え(依存性注入:DI)できるように組み替えていきます。

protocol を使ってモジュールの抽象化する

Swift では protocol を使ってモジュールを抽象化できます。
今回はデータの取得部分を抽象化します。

データ取得部分の機能としては、「パスポート情報を取得する」機能が提供できれば今回のサンプルの要件は満たせそうです。
よって、このように protocol を決めます。

/// パスポートデータ提供プロトコル
protocol PassportProviderProtocol {
    func getPassports(completion: @escaping ([Passport])->Void)
}

protocol について軽く補足
protocol は、定義した protocol 型の変数にオブジェクトを入れ、外部からそれを使おうとした時にアクセスできるプロパティやメソッドの一覧表です。

逆にいうとここに書かれている以外のものへは外部からアクセスできませんし、内部がどうなっているかはわかりません。(今回の示す例でいうと、PassportProviderProtocol として入っているオブジェクトは実際にサーバと通信しているのか内部でオブジェクトを生成しているだけなのかは外からは分かりませんが、getPassports()メソッドが使えることだけははっきりしているということを示します。)

このプロトコルに準拠するようにAPIクライアント、モックを作成します。
APIClientクラスは : PassportProviderProtocol と記述し、このプロトコルに準拠しているという宣言を加えます。

/// APIクライアント
class APIClient: PassportProviderProtocol {
    static let apiServerBaseURL = "https://dev-server"

    /// パスポート取得API
    static func getPassports(completion: @escaping ([Passport])->Void) {
        let url = URL(string: apiServerBaseURL + "/passport")

        // 開発サーバへデータ取得
        // ...
        // 結果 JSON をサーバから受け取る -> JSONから Passport オブジェクトの配列を生成
        // 便宜的にサーバからのレスポンスJSON を JSON とする
        let passports = Passport.parse(from: JSON)
        completion(passports)
    }
}

もうひとつ、テスト用のモックを作りましょう。
こちらも PassportProviderProtocol に準拠させます。

/// APIクライアントモック
class APIClientMock: PassportProviderProtocol {
    enum TestPattern {
        case empty
        case notEmpty
    }
    var testPattern: TestPattern = .empty

    /// パスポート取得
    func getPassports(completion: @escaping ([Passport])->Void) {
        // テスト用の結果をここで作る
        let passports: [Passport]
        switch testPattern {
        case .empty:
            passports = []
        case .notEmpty:
            passports = [Passport()]
        }
        completion(passports)
    }
}

※ このモックは APIClientMock.testPattern を変えると結果の振る舞いが変わるように組んであります。

View からの呼び出しイメージ

さて、差し替えできる準備は整いました!
View側の実装はこうなります。

/// View側
class ViewController: UIViewController {
    var passportProvider: PassportProviderProtocol = APIClient()

    /// 画面ロード時
    override func viewDidLoad() {
        super.viewDidLoad()

        passportsProvider.getPassports(completion: { passports in
            if passports.isEmpty {
                // 「データがありません」アラート表示処理
            }
        })
    }
}

PassportProviderProtocol 型のプロパティ passportsProvider をし、こうすることで本番用の APIClient オブジェクトと開発・テスト用の APIClientMock オブジェクトを差し替えできるようになりました!

使用イメージはこんな感じです。

let vc = ViewController()
vc.passportProvider = APIClientMock()   // テスト用モジュールで差し替えたい時
self.navigationController?.pushViewController(vc, animated: true)

まとめ

今回は DI について説明してみました。
実際この構成で開発を行うとAPIの開発が同時並行でもサーバ側開発進捗にほぼ影響を受けずに進行できます。

加えて、モジュールを protocol として切り分ける必要が出てくるので、依存する機能の範囲を意識しやすくなると感じました。テストコードの実装はまだ着手できていないので、ここも形にできたらまとめてみようと思います。

長くなってしまいましたが読んでいただき、ありがとうございました!
次回は @oswhk さんです!よろしくお願いします。