コネヒト開発者ブログ

コネヒト開発者ブログ

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(中編)

こんにちわ。 iOSエンジニアの ohayoukenchan です。

前編URLRequestを作成するところまでお伝えできたので、今回は実際にURLSessionを前回の変更方針にあった非同期処理を適切に処理したいを解決する内容となっています。

URLSessionとは?

URLSessionはURLで示されるエンドポイントからデータをダウンロードしたり、エンドポイントにデータをアップロードしたりするためのAPIを提供します。 アプリは、このAPIを使用して、アプリが実行されていないときや、iOSではアプリがサスペンドされている間に、バックグラウンドでダウンロードを実行することもできます。

単純なHTTPの非同期通信を行うにはdataTask(with:completionHandler:)を用いればいいので、dataTask(with:completionHandler:)で書くとこのような感じになります。completionHandler:のclosureで処理されるdata, response, errorはすべてoptionalな値なので実際に利用するときはunwrapする必要があります。

let task = session.dataTask(with: url!) { data, response, error in
 
  if let error = error {
     // エラーが返って来た場合エラーハンドリング
    return
  }

  guard let data = data, let response = response as? HTTPURLResponse else {
    // dataが取得出来ない場合のハンドリング
    return
  }

  if response.statusCode == 200 {
    do {
      let json = try JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions.allowFragments)
    } catch {
      // デコードに失敗した場合のエラーハンドリング処理
    }
    // 処理...
  }
}
task.resume() // taskをsuspended状態からrunning状態にする

dataTaskPublisherでcombine publisherを操作していく

今回はURLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>のようなcombine publisherが欲しいのですが、 UrlSessionにはdataTaskPublisher(for:)があり、combine publisherを返してくれるので返り値の理想に近いこちらを使用していきます。

詳細はこちらをご確認ください。

Request処理とその結果を返すクラスを作っていきます。まずは、Protocolの定義をしていきます。今回はApiServiceとしました。 whereを使ってRequestに型制約をもたせておくと予期しないRequest型で実装してしまった場合、コンパイルエラーで気づくことができます。

protocol ApiService {
    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType
}

ApiServivceクラスはレスポンスエラーとデコードに失敗したときのパースエラーがあることを明示しておきます。

enum APIServiceError: Error {
    case responseError(Error)
    case parseError(Error)
}

次にApiServiceに準拠したApiServiceクラスを実装していきます。 継承されないようにfinal修飾子をつけておくとどこかで継承して利用される心配がなく安心かと思います。

final class ApiService: ApiService {

    func call<Request>(from request: Request) -> AnyPublisher<Request.Response, APIServiceError>
    where Request: APIRequestType {

        let urlRequest: URLRequest = request.buildRequest() // (1)

        let decorder = JSONDecoder()
        decorder.dateDecodingStrategy = .iso8601
        decorder.keyDecodingStrategy = .convertFromSnakeCase // (2)

        return URLSession.shared.dataTaskPublisher(for: urlRequest)
            .tryMap { (data, response) -> Data in
                if let urlResponse = response as? HTTPURLResponse {
                    switch urlResponse.statusCode {
                    case 200..<300:
                        return data
                    default:
                        throw try decorder.decode(ApiErrorResponse.self, from: data)
                    }
                }
                return data
            } // (3)
            .mapError { error in
                APIServiceError.responseError(error)
            } // (4)
            .flatMap {
                Just($0)
                    .decode(type: Request.Response.self, decoder: decorder)
                    .mapError { error in
                        return APIServiceError.parseError(error)
                    }
            } // (5) 
            .receive(on: DispatchQueue.main) // (6)
            .eraseToAnyPublisher()
    }
}

(1) let urlRequest: URLRequest = request.buildRequest()のところで前回作成したAPIRequestTypeに準拠したStruct型のリクエスト(前回SampleRequestとしたもの)のbuildRequest()を実行してURLRequestをつくります。

(2) dataTaskPublisher(for: urlRequest)の返り値dataをデコードする際の指定で、Date型にするときiso8601形式に、JSON keyがsnakeCaseの場合、structとのmapping ができるようになります。

(3) dataTaskPublisherURL session data taskをwrapしたpublisherを返すので.map()オペレータを使うことができます。publisherの中身は(data, response)のタプルです。レスポンス結果に応じて処理を振り分けたいので.map()ではなくtryMap()を使用しています。この場合、HttpStatusCodeが200から300までの値の場合リクエストは正常なレスポンスを返したとして後続の処理にdataを返します その他の場合はthrowキーワードでpublisherの処理を失敗させます

(4) mapErrortryMap()で処理が失敗した際にthrowキーワードで投げたエラーを別の型に変換します。ここではAPIServiceError.responseError(error)に変換していますが、画面側でAPIを叩いたときに、 URLリクエストの返り値としてAnyPublisher<Request.Response, APIServiceError>のようなcombine publisherを返して欲しいためです。

(5) .flatMapjust($0)ですがこちらは(data, response)のタプルのうちdataのみを扱うpublisherにしたいのでこのようにしています。そしてCodable(decodable)を使用してデコードしています。デコードに失敗した場合はレスポンスエラーのときと同じようにmapErrorを使ってAPIServiceError.parseError(error)に変換しています。

(6) URL sessionはバックグラウンドで処理されますが、UIの更新はメインスレッドで行いたいため、receive(on: DispatchQueue.main)でメインスレッドで返すように指定しています。

これでAPIServiceを実装が完了したので、いよいよUI側の処理を書いていくことが出来ます。

リクエストをcombine publisherで受け取る

UI側の処理はこのように簡潔に書けます。

let apiService = ApiService()

apiService.call(
  from: SampleRequest.List(query: "foo")
)
.sink(
  receiveCompletion: { [weak self] completion in
      switch completion {
      case .finished:
          break
      case .failure(let error):
          self?.errorSubject.send(error)
      }
  },
  receiveValue: { [weak self] result in
      self?.fooSubject.send(result.bar)
  }
)
.store(in: &cancellables)}

ApiServiceに実装したcall(from:)メソッドにリクエストを渡し、.sink(receiveCompletion:, receiveValue:)することでAnyPublisher<Request.Response, APIServiceError>の値を流します。receiveCompletion:ではcompletionが.failureだった場合APIServiceErrorを流してUI側の制御をします。errorSubjectをUI側で購読できるようにして値が流れてきたらUIAlertをだすなどの処理につなげます。値を受け取った場合、receiveValue:で受け取った値をUIに反映させていきます。

Swift5.3からMultiple Trailing Closuresが使えるのでもう少しシュッと書くことができます。

let apiService = ApiService()

apiService.call(
  from: SampleRequest.List(query: "foo")
)
.sink { [weak self] completion in
  switch completion {
  case .finished:
    break
  case .failure(let error):
    self?.errorSubject.send(error)
  }
} receiveValue: { [weak self] result in
  self?.fooSubject.send(result.bar)
}
.store(in: &cancellables)}

インデントが浅くなって可読性があがりました!

これでURLSessionを利用してレスポンスをCombineのAnyPublisherで受け取ることができるようになりました。 しかし、APIが正しく動作するのかのテストが書けていません。

ママリではViewModelの初期化時にApiServiceをDIすることでテストを書けるようにしています。 長くなってしまったのでテストについては後編でお伝えできればと思います。( ^ θ ^ )

余談

WWDC2021のMeet async/await in Swift - WWDC21 - Videos - Apple Developerにもありますが、非同期処理をasync/awaitキーワードを使って自然にかけるようになりましたね。 関数にasync throwsつけて、非同期処理が行われる処理にtry awaitをつけることで非同期処理を安全に処理できるようになります。 ママリにも早く適応させて行きたいず!

struct UserRequest {
    var session = URLSession.shared

    func load(from url: URL) async throws -> [User] {
        let (data, response) = try await session.data(from: url)
        // レスポンスエラーなど何らかの失敗処理

        let decoder = JSONDecoder()
        return try decoder.decode([User].self, from: data)
    }
}