Moyaとは
MoyaはAlamofireのラッパーで、iOSアプリを作る時に作成されるAPI通信周りの処理をまるっと引き受けてくれるやつです。触ろうと思えばAlamofireを直接さわれるので、Moyaを使って出来ないことは無いです。Alamofireを利用しているならぜひ使いたいライブラリですね。
また、公式にRxSwiftに対応しているため、独自にRxSwift用にレスポンスの変換処理を書く必要がないのも嬉しいです。Moyaに限らずAPI通信周りを担ってくれるほとんどのライブラリはEitherの形でレスポンスが返ってきます。それを自分たちでRxに変換するコードをわざわざ書いているのであれば、Moyaを使って必要な開発にリソースを割いた方がいいです。
Moyaのインストール
CartfileにMoya/Moya
を追加します。
github "Moya/Moya"
その後、carthage bootstrap --platform ios
を実行し、Build Phases
にビルド済みのframeworkの追加とRun Scriptの設定を行いましょう。
RxSwiftと一緒に利用したいのでRxMoya.framework
とRxSwift.framework
も追加しています。
基本的な使い方
TargetTypeというプロトコルに準拠する形で接続先やパラメータなどを書いていきます。
import Moya
// URL用に文字列をエスケープするためのやつで直接的には関係ありません
extension String {
var urlEscaped: String {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
}
}
enum GitHub {
case userProfile(String)
}
extension GitHub: TargetType {
var baseURL: URL { return URL(string: "https://api.github.com")! }
var path: String {
switch self {
case .userProfile(let name):
return "/users/\(name.urlEscaped)"
}
}
var method: Moya.Method {
return .get
}
var task: Task {
switch self {
case .userProfile(_):
return .requestPlain
}
}
var headers: [String : String]? { return nil }
// テストの時などに、実際にAPIを叩かずにローカルのjsonファイルを読み込める
var sampleData: Data {
let path = Bundle.main.path(forResource: "samples", ofType: "json")!
return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
}
}
import Foundation
struct Profile: Codable {
let login: String
let url: URL
let name: String?
let email: String?
}
参考: https://github.com/Moya/Moya/blob/master/docs/Examples/Basic.md
参考: https://github.com/Moya/Moya/blob/master/docs/Targets.md
呼び出しは以下のように行います。
let disposeBag = DisposeBag()
let provider = MoyaProvider<GitHub>()
provider.rx.request(.userProfile("kouheiszk"))
.filterSuccessfulStatusCodes()
.map(Profile.self)
.subscribe(onSuccess: { (profile) in
print(profile)
}) { (error) in
print(error)
}
.disposed(by: disposeBag)
provider
に対してrx
を付けてrequest
を叩くことでレスポンスをsubscribeできるようになります。
map(Profile.self)
のところで、レスポンスのJSONをProfileにマッピングしています。ProfileはCodableに準拠していれば特に何も考える必要がありません。
とても簡単ですね。
応用的な使い方
基本的な使い方で書いた様なAPIのリクエスト先をcaseで記述する方法だと、リクエスト先が増えた際にcaseが増え、TargetTypeが要求するvar path
やvar method
などの中のswitch-caseも増えていきます。2〜3個ならまだ大丈夫ですが、それ以上にcaseが増えると定義が分散して読み辛いコードが出来上がることが容易に想像できます。
caseで分岐しない、API仕様書をそのまま写した様な書き方をしたいという欲求が出てくると思います。
実はMoyaではこのAPI仕様書をそのまま写した様な書き方ができるので、APIのリクエスト先が複数になる場合ははじめからこの書き方にしておいた方がいいです。
caseではなくstructでAPIを定義
新たににTargetType
に準拠するGitHubApiTargetType
というprotocolを定義します。baseURL
など各APIで共通の項目はextensionで定義しておきましょう。
protocol GitHubApiTargetType: TargetType {
}
extension GitHubApiTargetType {
var baseURL: URL { return URL(string: "https://api.github.com")! }
var headers: [String : String]? { return nil }
var sampleData: Data {
let path = Bundle.main.path(forResource: "samples", ofType: "json")!
return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
}
}
enumのcaseを削除し、GitHubApiTargetType
に準拠したstructを定義してきます。
enum GitHub {
struct GetUserProfile: GitHubApiTargetType {
var method: Method { return .get }
var path: String { return "/users/\(name.urlEscaped)" }
var task: Task { return .requestPlain }
let name: String
init(name: String) {
self.name = name
}
}
}
APIの定義がまとまり見やすくなりました。
呼び出しはほとんど変わらず、以下のようになります。
let disposeBag = DisposeBag()
let provider = MoyaProvider<GitHub.GetUserProfile>()
provider.rx.request(GitHub.GetUserProfile(name: "kouheiszk"))
.filterSuccessfulStatusCodes()
.map(Profile.self)
.subscribe(onSuccess: { (profile) in
print(profile)
}) { (error) in
print(error)
}
.disposed(by: disposeBag)
共通のProviderを利用する
APIの定義は見やすくなったのですが、今度はproviderを各APIリクエスト毎に都度生成しなくてはいけなくなってしまいました。
GitHub.GetUserRepositories(name: "kouheiszk")
のようなAPIリクエストを行いたいと思った時に、
let provider = MoyaProvider<GitHub.GetUserRepositories>()
provider.rx.request(GitHub.GetUserRepositories(name: "kouheiszk"))
の様に新たにProviderを作る必要があるということです。
理想としては共通のProviderを利用して、さらにはそれすら隠蔽して
Api.request(GitHub.GetUserProfile(name: "kouheiszk"))
Api.request(GitHub.GetUserRepositories(name: "kouheiszk"))
のように、リクエストしたいですよね。
型消去
それぞれの型に対応したProvider。そうです型消去です。型消去をしていきましょう。
AnyGitHubApiTargetType
を作成し、MoyaProvider<AnyGitHubApiTargetType>()
とするイメージです。
しかし早まってはいけません。こういうことはもっと前に考えついている人がいるはずです。
Moyaのドキュメントを見てみるとやはりありました。どうやら以下のようにすれば出来るみたいです。
let provider = MoyaProvider<MultiTarget>()
let target = MultiTarget(GitHub.GetUserRepositories(name: "kouheiszk"))
provider.rx.request(target)
参考:https://github.com/Moya/Moya/blob/master/docs/Examples/MultiTarget.md
これをベースに、理想の呼び出し方を実現させてみましょう。
Codableなレスポンスの型はリクエスト毎に異なるはずなので、GitHubApiTargetType
にこのレスポンスの型を記述するようにします。
protocol GitHubApiTargetType: TargetType {
associatedtype Response: Codable
}
struct GetUserProfile: GitHubApiTargetType {
typealias Response = Profile
...
}
リクエストを行うApiクラスは以下のようになります。Providerが開放されると、APIのリクエスト結果をSubscribeできなくなってしまうので、ここではシングルトンでProviderが開放されないようにしています。
class Api {
static let shared = Api()
private let provider = MoyaProvider<MultiTarget>()
func request<R>(_ request: R) -> Single<R.Response> where R: GitHubApiTargetType {
let target = MultiTarget(request)
return provider.rx.request(target)
.filterSuccessfulStatusCodes()
.map(R.Response.self)
}
}
呼び出しは以下のようになります。
let disposeBag = DisposeBag()
Api.shared.request(GitHub.GetUserProfile(name: "kouheiszk"))
.subscribe(onSuccess: { (profile) in
print(profile)
}) { (error) in
print(error)
}
.disposed(by: disposeBag)
とてもシンプルになりました。
完成
最後に作成したApi.swift
の内容を全て貼っておきます。各APIはリクエストの種類毎にApi+User.swift
の様にファイル分割するといいと思います。
//
// Api.swift
// MoyaSample
//
// Created by Kouhei Suzuki on 2018/03/02.
// Copyright © 2018年 Kouhei Suzuki. All rights reserved.
//
import Moya
import RxSwift
extension String {
var urlEscaped: String {
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
}
}
class Api {
static let shared = Api()
private let provider = MoyaProvider<MultiTarget>()
func request<R>(_ request: R) -> Single<R.Response> where R: GitHubApiTargetType {
let target = MultiTarget(request)
return provider.rx.request(target)
.filterSuccessfulStatusCodes()
.map(R.Response.self)
}
}
protocol GitHubApiTargetType: TargetType {
associatedtype Response: Codable
}
extension GitHubApiTargetType {
var baseURL: URL { return URL(string: "https://api.github.com")! }
var headers: [String : String]? { return nil }
var sampleData: Data {
let path = Bundle.main.path(forResource: "samples", ofType: "json")!
return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
}
}
enum GitHub {
struct GetUserProfile: GitHubApiTargetType {
typealias Response = Profile
var method: Method { return .get }
var path: String { return "/users/\(name.urlEscaped)" }
var task: Task { return .requestPlain }
let name: String
init(name: String) {
self.name = name
}
}
}