38
Help us understand the problem. What are the problem?
Organization

[Swift] URLSession+Combine+CodableでAPIクライアントを作る

※注意
本記事は Xcode 11.0 GM Seed の環境で動作確認したものです。正式版では動作が変わる可能性もあります。~
(うまく動作しない場合は、シュミレータやXcodeを再起動したりするとうまくいきます。)

前書き

過去に以下のような記事を上げていましたが、そのCombine版になります。

そのまんま置き換えというのは、厳しい部分がありましたがなんとか同じような書き方に集約しております。

Combineについては【iOS】Combineフレームワークまとめを見ていただくのが早いかと思います。

導入

1.環境

- MacOS Catalina 10.15 Beta 8
- Xcode 11.0 GM Seed (Swift 5)
- Alamofire (手軽に試すならPodがおすすめ)

をインストールしてください。

余談(Alamofireをインストールしていることに関して)

URLSession使うのにAlamofireを入れるんかい?という疑問が来そうなので、記載しておきます。

①当初はAlamofireをラップしたものでやろうとしていたが、URLSessionに用意されているDataTaskPublisherを用いたほうが綺麗だと気づいた
②基底クラスの作成において、Alamofireで用意されているtypealias、メソッド、エンコード部分が自前で作るより優れていたので採用したかった

(そのうちAlamofire本家が対応してくれるのではと思っている笑)

2.ネットワークプロトコルの作成

こちらに関しては、上記でもあげた以下
- [Swift]ObjectMapper+RxSwiftを実装した備忘録
- [Swift]Alamofire+RxSwift+CodableでAPIクライアントを作る

全く同じ記載があるので省略します。

3.リクエスト・レスポンスの作成

こちらも先の記事内に同様の記載あるので、必要ない方は読み飛ばして大丈夫です。レスポンスモデルが違うので一応記述しています。

①レスポンスの作成

例として、APIを叩くと以下のレスポンス(Json)が返ってくるとする。

Users.json
{
  "data": [
    {
      "name": "Bob",
      "id": 0
    },
    {
      "name": "Sam",
      "id": 1
    }
  ]
}

上記のJsonからレスポンスを作成。

UserResponse.swift
// MARK: - Response

struct UserResponse: Codable {
    var data: [UserModel]
}

struct UserModel: Codable {
    let name: String
    let id: Int
}

②リクエストの作成

[2]で作成したプロトコルに適応するリクエストになるよう作成。

UserRequest.swift
import Alamofire

// MARK: - Request

struct UserRequest: BaseRequestProtocol {
    typealias ResponseType = UserResponse

    var method: HTTPMethod {
        return .get
    }

    var path: String {
        return "/xxx/yyy/zzz" // ※必要に応じて各自でセットしてください
    }

    var parameters: Parameters? {
        return nil
    }
}

余談

こちらのMyjsonというとサービスを使うと、指定したjsonhttpsで返してくれるURLを発行してくれるので、サクッと試したい方はお使いください。

サービスが終了しました。json-server などで代用しましょう。

4.ネットワーククライアントを作成

①DataTaskPublisherに関して

URLSessionに用意されたDataTaskPublisherを用いることで比較的きれいに書くことができます。

URLSession.shared.dataTaskPublisher(for: /* URLRequest or URL */)

以下の記事を見ていただけるとわかるかと思いますが、

自前で作るとなると

- Subscriber
- Subscription

とかを意識する必要があり、結構めんどくさいです。

DataTaskPublisherでその辺をまるっと考えなくて済むのはありがたい感じです。
(細かいところ処理わけしたいとかある場合は、結局自前でやることになるかと思います)

②コード

エラーの種類を先に定義しておきます。各自の状況に応じて拡張してください。

// MARK: - Error
enum NetworkError: Error {
    case networkError(code: Int, description: String)
    case decodeError(reason: String)
    case irregularError(info: String)
}

実際に作成したコードは以下になります。

NetworkPublisher.swift

import Alamofire
import Combine

// MARK: - NetworkPublisher

struct NetworkPublisher {

    // MARK: Variables

    private static let successRange = 200..<300
    private static let retryCount: Int = 1
    private static let decorder: JSONDecoder = {
        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return jsonDecoder
    }()

    // MARK: Methods

    static func publish<T, V>(_ request: T) -> AnyPublisher<V, NetworkError>
        where T: BaseRequestProtocol, V: Codable, T.ResponseType == V {

            return URLSession.shared
                .dataTaskPublisher(for: try! request.asURLRequest()) // 絶対に落ちない保証があるので`try!`
                .timeout(.seconds(20), scheduler: DispatchQueue.main)
                .retry(retryCount)
                .validate(statusCode: successRange) // ※[5.オプション]で後述
                .decode(type: V.self, decoder: decorder)
                .mapDecodeError() // ※[5.オプション]で後述
                .eraseToAnyPublisher()
    }
}

備考

メソッドの説明を記載しておきます。
- .timeout(Scheduler, scheduler: Queue) : タイムアウトの設定
- .retry() : 名前の通り失敗したときのリトライ回数を指定
- .validate(statusCode: Range) : ステータスコードの判定。独自関数につき後述
- .decode(type: Class, decoder: JSONDecoder) : 指定した型にデコード
- .mapDecodeError() : デコード失敗時のエラー変換。独自関数につき後述
- .eraseToAnyPublisher() : 型消し(Combineではお作法みたいなもの)

③使用例

実際に使用した際の記述例になります。

var cancellables: Set<AnyCancellable> = []

func publish() {
    let request = UserRequest()

    NetworkPublisher.publish(request)
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { result in
            switch result {
            case .finished: print("finished")
            case .failure(let e): print("failure", e) // failure
            }
        }, receiveValue: { value in
            print("receiveValue", value) // success
        }).store(in: &cancellables)
}

deinit {
    cancellables.forEach { $0.cancel() }
}

実行結果(成功時)

receiveValue [UserModel(name: "Bob", id: 0), UserModel(name: "Sam", id: 1)]
finished

備考

sinkに関して
成功時でもreceiveValueで値を受け取った後に、receiveCompletionfinishedに入ってくる点に注意。
流れが逆になってる感じが若干違和感ですが、プログレスを消す処理などは成功・失敗共にここで一元管理できそう。

② RxSwiftで考えた時
- .receive(on: ).observeOn()
- .sink().subscribe()
- .store(in: ).disposed(by: )
- Set<AnyCancellable>Disposebag
だと思ってみると上記のコードもスーッと入ってこないでしょうか?
やっていることは同じです。(内部的には違うでしょうが笑)

④拡張(RxSwiftぽく使うために)

上記でも記載したが、値を受け取った後に、receiveCompletionfinishedに入ってくるのが気持ち悪いので、
RxSwiftっぽく使えるようにもう一段階ラップしたものも用意してみた

NetworkPublisher.swift
static func sink<T, V>(_ request: T, _ scheduler: DispatchQueue = DispatchQueue.main,
                       success: @escaping (V) -> Void,
                       failure: @escaping (Error) -> Void,
                       completion: @escaping () -> Void = {}) -> AnyCancellable

    where T : BaseRequestProtocol, V == T.ResponseType, T.ResponseType : Codable {

        publish(request)
            .receive(on: scheduler)
            .sink(receiveCompletion: { result in
                switch result {
                case .finished: break
                case .failure(let e): failure(e)
                }
                completion()
            }, receiveValue: success)
}

使用例

~省略~

let request = UserRequest()

NetworkPublisher.sink(request, success: { value in
    print("success", value)
}, failure: { error in
    print("failure", error)
}).store(in: &cancellables)

~省略~

こちらのほうがより直感的で、かつ必要に応じてcompletionのクロージャをセットすれば良い仕組みになっている。
(あくまでも個人的な好み)

5.オプション

[4]のネットワーククライアント内で説明していなかったものになります。
実際はなくても動作しますが、あったほうが良いと思い作成。

①DataTaskPublisherを拡張

StatusCodeのバリデーションに対応

Alamofireに用意されているValidateのようなことがしたかったので作成。

extension Publisher {

    func validate<S>(statusCode range: S) -> Publishers.TryMap<Self, Data>
        where S:Sequence, S.Iterator.Element == Int {

        tryMap {
            guard let output = $0 as? (Data, HTTPURLResponse) else {
                throw NetworkError.irregularError(info: "irregular error")
            }
            guard range.contains(output.1.statusCode) else {
                throw NetworkError.networkError(code: output.1.statusCode, description: "out of statusCode range")
            }
            return output.0
        }
    }
}

また、特定のエラーコードの場合は特定の処理をしたい

ex
- 強制アプデのアラートが出る
- 再ログインのために、ルート画面をスプラッシュやログイン画面に入れ替える

みたいなことは、こちらで一元管理することもできます。

Decodeのエラーに対応

  • デコードに失敗した場合
  • エラーだけど成功として違うレスポンスが返ってくる場合

などのハンドリングに対応できます。

extension Publisher {

    func mapDecodeError() -> Publishers.MapError<Self, NetworkError> {
        mapError {
            switch $0 as? DecodingError {
            case .keyNotFound(_, let context):
                return .decodeError(reason: context.debugDescription)
            default:
                return .decodeError(reason: $0.localizedDescription)
            }
        }
    }
}

DecodingError はいくつか種類があるので、分岐を細かく設定する場合は各自で設定してください。

後書き

RxSwift導入した頃は、なんやこれ、、、と思っていたが、、、
いざCombineに移行しようとしたときに、その便利さにあらために気づかされたりしました笑

とはいえ、公式でサポートしてくれるのはありがたいことであり、
RxSwiftはライブラリとしても地味に大きかったりするので(セットアップとかアプデ時間かかるよね問題)
その辺は移行して解消できたらなぁとは思いつつ。

コードの指摘等あればお願いしますmm

動作するリポジトリを置いておきます

追記1

リポジトリではAPIのURL(エンドポイント)が古くなっているので、自前で返ってくるものを設定してください。
(それにあわせてレスポンスの型も修正してください)

追記2

続編を書きました

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
38
Help us understand the problem. What are the problem?