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() {} }
レスポンスのデータ構造
先述の RecommendedMenu
の Response
で使用している SingleResponse<Dish>
は、「Dishオブジェクトとしてデコードできるオブジェクトが1つ」という構造のJSONが返されることを示します。
例えば、おすすめメニューが複数あり配列で返ってくる場合は ArrayResponse<Dish>
とすることで
[ { "name": "チキンカレー", "price": 750 }, { "name": "オムライス", "price": 700 } ]
このようなJSONをパースできるようになります。
SingleResponse
、ArrayResponse
は ResponseDefinition
というプロトコルに準拠しています。
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
に準拠する型を型パラメータに持つジェネリック型です(SingleResponse
やArrayResponse
もDataResponseDefinition
に準拠しています)。
これを使うと、上記の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に還元していきたいと思っているので、興味がある方はぜひ見てみてください。サンプルアプリもあります。
次のステップとして、この記事で紹介したエンドポイントの定義をするコードをSwaggerのYAMLドキュメントから自動生成するジェネレータを開発しています。こちらも導入できたらブログで紹介できればと思っています。
VASILYではプロトコル指向なコードがかけるSwiftエンジニアを募集しています。ぜひオフィスに遊びに来てください。