2019/8/13 続編を書きました
[Swift] URLSession+Combine+CodableでAPIクライアントを作る
前書き
objective-c
+swift3
という混在するプロジェクトでコーディングを行っている。
近い将来アプリのフルリニューアルを行うことが決定しており、レガシーコードの書き換える中で、通信系がブラックボックス化されており依存度が高いものであるため、新しく作り直すことになった。
swift4
に移行したかったが、objective-c
のコードをすべてswift
化してから行いたかったので、一旦現状のまま実装できるものとしてObjectMapper
を採用した(Realmとの相性を考えて)。また、それに伴いRx
の導入も行なった。
この手の記事は多いので、個人的な備忘録としてここに記録する。
現状
Alamofire
+ Curry
+ Argo
予定
Alamofire
+ RxSwift
+ ObjectMapper
(AlamofireObjectMapper)
導入
実際に、プロジェクトに導入する手順で行っていく。
1. インストール
ライブラリの導入はCarthage
を使用。(Cocoapods
,SPM
など導入できるのであれば何でもおk)
github "Alamofire/Alamofire" ~> 4.4
github "ReactiveX/RxSwift"
github "Hearst-DD/ObjectMapper" ~> 2.2
github "tristanhimmelman/AlamofireObjectMapper" ~> 4.0
※ObjectMapperは最新のものだとエラーになるかもしれないので、あえて低いものを指定
2. ネットワークプロトコルの作成
コメントアウトで説明してあります。
import Alamofire
import ObjectMapper
import AlamofireObjectMapper
// MARK: - Base API Protocol
protocol BaseAPIProtocol {
associatedtype ResponseType
var method: HTTPMethod { get }
var baseURL: URL { get }
var path: String { get }
var headers: HTTPHeaders? { get }
}
extension BaseAPIProtocol {
var baseURL: URL {
return try! "https://~~~".asURL()
}
var headers: HTTPHeaders? {
return nil // 必要であれば個々に設定
}
}
// MARK: - BaseRequestProtocol
protocol BaseRequestProtocol: BaseAPIProtocol, URLRequestConvertible {
var parameters: Parameters? { get }
var encoding: URLEncoding { get }
}
extension BaseRequestProtocol {
var encoding: URLEncoding {
// parameter の変換の仕方を設定
// defaultの場合、get→quertString、post→httpBodyとよしなに行ってくれる
return URLEncoding.default
}
func asURLRequest() throws -> URLRequest {
// requestごとの pathを設定
var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path))
// requestごとの methodを設定(get/post/delete etc...)
urlRequest.httpMethod = method.rawValue
// headersを設定
urlRequest.allHTTPHeaderFields = headers
// timeout時間を設定
urlRequest.timeoutInterval = TimeInterval(30)
// requestごとの parameterを設定
if let params = parameters {
urlRequest = try encoding.encode(urlRequest, with: params)
}
return urlRequest
}
}
upload用のプロトコルを作成できるようにBaseAPIProtocol
で切り出しています。
3. ネットワークマネージャーを作成
Alamofire
にRxSwift
を盛り込む。
また、シングルトン(もどき、厳密には違う)で作成しています。
長いので解説はちょっとあれですが、、、
import UIKit
import Alamofire
import ObjectMapper
import AlamofireObjectMapper
import RxSwift
// MARK: - API Manager
struct APIManager {
// MARK: - Static Variables
private static let successRange = 200..<300
private static let contentType = ["application/json"]
// MARK: - APICallProtocol Methods
/// API呼び出しメソッド
static func call<T, V>(_ request: T, _ disposeBag: DisposeBag,
onNext: @escaping (V) -> Void, onError: @escaping (Error) -> Void)
where T : BaseRequestProtocol, V == T.ResponseType, T.ResponseType : Mappable {
_ = observe(request)
.observeOn(MainScheduler.instance)
.subscribe(onNext: { onNext($0) },
onError: { onError($0) })
.disposed(by: disposeBag)
}
/// RxでAPIを呼び出すメソッド ※下部に追記あり
static func observe<T, V>(_ request: T) -> Observable<V>
where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {
return Observable<V>.create { observer in
let calling = callForJson(request) { response in
switch response {
case .success(let result): observer.onNext(result as! V)
case .failure(let error): observer.onError(error)
}
observer.onCompleted()
}
return Disposables.create() { calling.cancel() }
}
}
/// Json 形式で取得するメソッド
static func callForJson<T, V>(_ request: T, completion: @escaping (APIResult) -> Void) -> DataRequest
where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {
return customAlamofire(request).responseJSON { response in
switch response.result {
case .success(let result): completion(.success(mappingJson(request, result as! Parameters)))
case .failure(let error): completion(.failure(error))
}
}
}
/// Alamofireカスタマイズメソッド
static func customAlamofire<T>(_ request: T) -> DataRequest
where T: BaseRequestProtocol {
return Alamofire
.request(request)
.validate(statusCode: successRange)
.validate(contentType: contentType)
}
/// Jsonをレスポンスモデルに mappingするメソッド
static func mappingJson<T, V>(_ request: T, _ result: Parameters) -> V
where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {
// 必ずmappingされるので「!」を使用
return Mapper<V>().map(JSON: result)!
}
}
/* APIレスポンスの結果分岐
success: ObjectMapperに対応したレスポンスモデルを返す
failure: サーバーからのエラーログを返す
*/
// MARK: - ResultType
enum APIResult {
case success(Mappable)
case failure(Error)
}
4. レスポンスモデルの作成
サーバーから取得できる値をswift
で扱える形にする必要がある。そのためのモデルを作成する。
前提として、サーバーからはJson形式で取得されるものとする。
以下、レスポンスのJson。今回は階層構造のものを用意した。
{
"data": [
{
"title": "タイトル",
"description": "説明文",
"icon": "https://xxx.jpg",
"date": "2017-01-01 00:00:00",
"id": "00000",
},
],
"result": true
}
上記をObjectMapper
に対応したレスポンスモデルにすると
import ObjectMapper
struct xxxResponse: Mappable {
var data: [xxxDataModel]?
var result: Bool?
init?(map: Map) { }
mutating func mapping(map: Map) {
data <- map["data"]
result <- map["result"]
}
}
struct xxxDataModel: Mappable {
var title: String?
var description: String?
var icon: URL?
var date: Date?
var id: Int?
init?(map: Map) { }
mutating func mapping(map: Map) {
title <- map["title"]
description <- map["description"]
icon <- (map["icon"], URLTransform()) // String -> URL 変換
date <- (map["date"], ISO8601DateTransform()) // String -> Data 変換
id <- (map["id"], TransformOf<Int, String> // String -> Int 変換
(fromJSON: { Int($0!) },toJSON: { $0.map { String($0) } }))
}
}
階層構造のものは別のstructで定義されている。
map["~~"]
でセットする際に、必要な型に変換できる。(ObjectMapperが用意してくれている)
5. リクエストの作成
必要なリクエストパラメータが
[
"keyword": String,
"ids": [Int]
]
の場合、
import Alamofire
import ObjectMapper
// MARK: - Request
enum xxxRequest: BaseRequestProtocol {
typealias ResponseType = xxxResponse
case post(keyword: String, ids: [Int])
var method: HTTPMethod {
switch self {
case .post: return .post
}
}
var path: String {
return "xxx/yyy/zzz" /// 個々に設定する
}
var parameters: Parameters? {
switch self {
case .post(let keyword, let ids):
return [
"keyword": keyword,
"ids": ids
]
}
}
}
実用例
class ViewController: UIViewController {
private let disposeBag = DisposeBag()
private func callForXXX() {
let request = xxxRequest.post(keyword: "キーワード", ids: [00000, 11111, 10101])
APIManager.call(request, disposeBag, onNext: onNext, onError: onError)
}
private func onNext(with result: Mappable) {
// do something
}
private func onError() {
// error handling
}
}
通信結果を一元管理できるようにしようか迷ったが、同じ通信でも処理をわけたいことも考えられたので、引数として成功時・失敗時のメソッドを渡す仕組みにした。
追記
上記のネットワークマネジャーに関して
onCompleted
で特に処理を行っていないので、Success/Failureいずれかだけをかえす、
Rx
のSingle
に書き換えました。以下になります。
static func observe<T, V>(_ request: T) -> Single<V>
where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {
return Single<V>.create { observer in
let calling = callForJson(request) { response in
switch response {
case .success(let result): observer(.success(result as! V))
case .failure(let error): observer(.error(error))
}
}
return Disposables.create() { calling.cancel() }
}
}
後書き
結局自分でブラックボックスのようなコードを作ってしまった気がする笑
レスポンスのJSON形式がJSONObjectの場合しか対応していないので(関わっているプロジェクトがその形式のため)、他で使用する場合を考えて少しだけ汎用的にすべきだったかと。
参考
[2017年版]RxSwift + Alamofire + ObjectMapper + RealmのSwift実装について
Swiftを使ったAPIクライアントの実装方法
HTTP通信部分にRxSwiftを使ってみる