iOSアプリに導入したプロトコル指向なAPI抽象レイヤーの設計

f:id:vasilyjp:20180927090637j:plain

iOSアプリエンジニアの@hiragramです。VASILYにジョインしてだいたい3か月経ちました。

今回は、僕がジョインしたプロジェクトに導入した、APIリクエストの抽象レイヤーの設計について紹介します。また、記事の最後にこの抽象レイヤーのコードをフレームワークとして切り出したもののリンクがありますので、興味がある方は見てみてください。

課題と方針

当プロジェクトでは、リアクティブフレームワークにRxSwift、通信ライブラリにAPIKit、JSONマッパーにHimotokiを採用しています。

従来のアプリの実装には、

  • ViewModelが直接APIKitをインポートして通信しており、通信のトリガーやレスポンスの処理が画面によってバラバラ
  • APIが取得対象のリソースを常にresultsというキーに配列で返すようになっており、必ず1個しか返さないAPIのレスポンスもresults.firstとOptionalになる形で取り出している
  • ある画面で取得したデータ(例えば自分のユーザーデータなど)を他の画面に反映するための仕組みが整備されておらず、反映漏れが多発

といった課題がありました。

そこで、今回のAPI抽象レイヤーの導入によって、

  • VMのレイヤーから通信やAPIの構造を隠蔽し目的のデータのみを意識すればよい設計
  • Observableベースのリアクティブなアプリ内のデータフロー
  • 取得した結果を他の画面にブロードキャストするためのObservableを用いたシンプルなデータ反映

を獲得することを目的としました。

エンドポイント定義

エンドポイントを定義するための EndpointDefinition というプロトコルを用意しました。

protocol EndpointDefinition {
    // 返されるJSONがどのような構造か
    associatedtype Response: ResponseDefinition

    static var path: String { get }
    // 接続先(デフォルトは`.production`、 他に`.mock` など)
    static var environment: Environment { get }
    // 送られるパラメータ
    var parameters: [String: Any] { get }
    // HTTPメソッド(アンダースコアはAPIKitと名前の衝突を防ぐため)
    var method: _HTTPMethod { get }
}

例えば、今日のおすすめメニューを以下のようなJSONで返すエンドポイントを考えます。

{
    "name": "チキンカレー",
    "price": 750
}

このエンドポイントは以下のように定義できます。

struct RecommendedMenu: EndpointDefinition {
    // Dishオブジェクトを1つ返す
    typealias Response = SingleResponse<Dish>
    static let path = "/menu/recommended"
    var method: _HTTPMethod = .get
    var parameters: [String: Any] = [:]
    init() {}
}

レスポンスのデータ構造

先述の RecommendedMenuResponse で使用している SingleResponse<Dish> は、「Dishオブジェクトとしてデコードできるオブジェクトが1つ」という構造のJSONが返されることを示します。 例えば、おすすめメニューが複数あり配列で返ってくる場合は ArrayResponse<Dish> とすることで

[
    {
        "name": "チキンカレー",
        "price": 750
    },
    {
        "name": "オムライス",
        "price": 700
    }
]

このようなJSONをパースできるようになります。 SingleResponseArrayResponseResponseDefinition というプロトコルに準拠しています。

protocol ResponseDefinition {
    // レスポンスの本質であるオブジェクトの型
    associatedtype Result
    // `JSONSerialization.jsonObject(with:options:)` で得たオブジェクトのキャスト先
    associatedtype JSON

    var result: Result { get }

    // JSONのオブジェクトを受け取って、resultに取り出した値をセットするイニシャライザ
    init(json: JSON?) throws
}

次に、複数のキーにオブジェクトが入っている複雑なJSONの場合を考えます。例えば、ページングの情報を示す info とメニューの配列を返す dishes があるようなケースです。

{
    "info": {
        "total_count": 50,
        "total_page": 3
    },
    "dishes": [
        {
            "name": "ポテトサラダ",
            "price": 400
        },
        {
            "name": "シーザーサラダ",
            "price": 450
        },
        {
            "name": "オムライス",
            "price": 700
        },
        {
            "name": "とんかつ定食",
            "price": 800
        },
        {
            "name": "和風ハンバーグ",
            "price": 650
        }
    ]
}

このようなJSONのデータ構造を定義するための CombinedResponse という型があります。 これは DataResponseDefinition に準拠する型を型パラメータに持つジェネリック型です(SingleResponseArrayResponseDataResponseDefinitionに準拠しています)。 これを使うと、上記のJSONは CombinedResponse<SingleResponse<Info>, ArrayResponse<Dish>> と表現できます。 CombinedResponse の具体的な実装はここでは省略しますが、興味がある方はコードを見てみてください。

APIKitとのブリッジ

さて、ここまででエンドポイントのモデル化が出来ました。次に実際にリクエストを飛ばすためにAPIKitとの境界になる部分を書きます。

struct GenericRequest<Endpoint: EndpointDefinition> {
    fileprivate let endpoint: Endpoint
    init(endpoint: Endpoint) {
        self.endpoint = endpoint
    }
}

GenericRequestは先ほどまでに定義したエンドポイントの型を型パラメータに持つジェネリック型です。この型に、APIKitのRequestに準拠するエクステンションを書きます。

extension GenericRequest: Request {
    typealias Response = Endpoint.Response.Result

    // プロトコルに準拠するための様々な実装(省略) ...

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Endpoint.Response.Result {
        guard let jsonObj = object as? Endpoint.Response.JSON else {
            fatalError()
        }
        // JSONオブジェクトから値を取り出す処理
        return try Endpoint.Response.init(json: jsonObj).result
    }
}

API抽象レイヤーの本体

実際に通信を行うためのAPIKitとのブリッジも出来ました。続いて実際に抽象レイヤーとして機能する部分を書いていきます。

public struct Repository {
    fileprivate static func request<Endpoint: EndpointDefinition>(_ endpoint: Endpoint) -> Single<Endpoint.Response.Result> {
        return Single.create(subscribe: { (observer) -> Disposable in
            let request = GenericRequest.init(endpoint: endpoint)
            let task = Session.send(request, callbackQueue: nil, handler: { (result) in
                switch result {
                case .success(let response):
                    observer(.success(response))
                case .failure(let error):
                    observer(.error(error))
                }
            })

            return Disposables.create {
                task?.cancel()
            }
        })
    }
}

request(_:)GenericRequest を使ってリクエストを投げて得られた結果をそのリクエストのオブジェクトが流れてくるRxSwiftの Single を返すメソッドです。 このメソッドはfileprivateにしておき、VMから利用するためのメソッドを生やします。

public extension Repository {
    public static func recommendedMenu() -> Single<Dish> {
        let endpoint = Endpoint.RecommendedMenu.init()
        return request(endpoint)
    }
}

これで、VMからは

Repository.recommendedMenu().subscribe(onSuccess: { (dish) in
    print("本日のおすすめは\(dish.name)(\(dish.price)円)です。")
}).disposed(by: bag)

このようにシンプルなコードでデータにアクセスすることができるようになりました。

取得した結果を他の画面にも反映できるようにする

APIの抽象レイヤーができたので、他画面へのデータ反映の仕組みを追加します。具体的には、 各モデルオブジェクト用にブロードキャストのためのストリームを用意して、他の画面の通信結果を受け取りたいVMがそれを購読します。抽象レイヤーは通信に割り込んで得られたオブジェクトをそのストリームに流します。

public final class GlobalStream<T> {
    fileprivate let subject = PublishSubject<T>.init()

    func publish(_ element: T) {
        subject.onNext(element)
    }
}

extension GlobalStream: ObservableType {
    public typealias E = T

    public func subscribe<O>(_ observer: O) -> Disposable where O : ObserverType, O.E == E {
        return subject.retry().subscribe(observer)
    }
}
public extension Repository {
    public struct GlobalStreams {
        public static let dish = GlobalStream<Dish>.init()
    }
}

ブロードキャスト用のストリームが用意できたので、値を流す側のコードを書きます。

Observableに、流れてきた値をGlobalStreamに流すbranchというメソッドを追加します。

public extension Observable {
    func branch(to globalStream: GlobalStream<E>) -> Observable<E> {
        return flatMap { element -> Observable<E> in
            globalStream.publish(element)
            return Observable.just(element)
        }
    }
}

次におすすめメニューのリクエストにbranchオペレータを追加して、取得したDishオブジェクトをブロードキャストするようにします。

public extension Repository {
    public static func recommendedMenu() -> Single<Dish> {
        let endpoint = Endpoint.RecommendedMenu.init()
        return request(endpoint)
            .asObservable()
            .branch(to: GlobalStreams.dish)
            .asSingle()
    }
}

他の画面で取得したデータを自分も受け取りたいVMは、GlobalStreamを購読することでデータを受け取ることが出来ます。

Repository.GlobalStreams.dish.subscribe(onNext: { (dish) in 
    print(dish)
}).disposed(by: bag)

まとめ

APIへのリクエストを抽象化して一箇所でのみ管理する仕組みを導入して上のレイヤーが知りたいことだけ公開する事によって、VMのコードがシンプルになりメンテナンス性が向上します。また、通信を一箇所で集中管理することによってすべての通信で共通して行いたい処理を追加したり、通信ライブラリを入れ替えたりするのが容易になります。

今回紹介したAPI抽象レイヤーを実装するためのプロトコルや構造体をAbstractionKitという名前でフレームワーク化して公開しました。全く同じコードでは無いのですが、今後も業務で得た知見をAbstractionKitに還元していきたいと思っているので、興味がある方はぜひ見てみてください。サンプルアプリもあります。

hiragram/AbstractionKit

次のステップとして、この記事で紹介したエンドポイントの定義をするコードをSwaggerのYAMLドキュメントから自動生成するジェネレータを開発しています。こちらも導入できたらブログで紹介できればと思っています。

VASILYではプロトコル指向なコードがかけるSwiftエンジニアを募集しています。ぜひオフィスに遊びに来てください。

カテゴリー