サポート対象がiOS13,14以上のアプリが増えていく中で、より需要を増していると思われるCombine。
中でも今回は、Combineを使った非同期処理の書き方を記事にしてみました。
せっかくなので、API処理をRequest/Responseをジェネリクス・プロトコルで共通化し、エラーハンドリングもちゃんと行いたいと思います。
完成コード
protocol APIClientProtocol: AnyObject {
func send<T: RequestProtocol>(_ request: T)
-> AnyPublisher<T.Response, APIError>
}
final class APIClient: APIClientProtocol {
private init() {}
static let shared = APIClient()
private var cancellables = Set<AnyCancellable>()
func send<T: RequestProtocol>(_ request: T)
-> AnyPublisher<T.Response, APIError> {
// baseURL + 各Requestのパスを合わせてURLを生成
guard let url = URL(string: request.baseURL)?
.appendingPathComponent(request.path) else {
return Fail<T.Response, APIError>(
error: APIError.failedToCreateURL
).eraseToAnyPublisher()
}
guard var componets = URLComponents(
url: url,
resolvingAgainstBaseURL: true
) else {
return Fail<T.Response, APIError>(
error: APIError.failedToCreateComponents(url)
).eraseToAnyPublisher()
}
// クエリ(パラメーター)を設定
componets.queryItems = request.parameters?.compactMap {
.init(name: $0.key, value: "\($0.value)")
}
// URLRequestの生成
guard var urlRequest =
componets.url.map({ URLRequest(url: $0) }) else {
return Fail<T.Response, APIError>(
error: APIError.failedToCreateURL
).eraseToAnyPublisher()
}
urlRequest.httpMethod = request.method.rawValue
urlRequest.allHTTPHeaderFields = [
"Content-Type": "application/json"
]
// 通信実行
return URLSession.shared.dataTaskPublisher(for: urlRequest)
.tryMap() { element -> Data in
// responseのチェック
guard let response =
element.response as? HTTPURLResponse else {
throw APIError.noResponse
}
// HTTPステータスコードのチェック
guard 200 ..< 300 ~= response.statusCode else {
throw APIError.unacceptableStatusCode(
response.statusCode
)
}
return element.data
}
.decode(type: T.Response.self, decoder: JSONDecoder())
.mapError { error -> APIError in
if let error = error as? DecodingError {
// Decodeエラーのハンドリング
return APIError
.parserError(error.localizedDescription)
} else if let error = error as? APIError {
// 上流のPublisherでエラーが発生していればここで返す
return error
} else {
return APIError
.unknown(error.localizedDescription)
}
}
.flatMap{ decodedData -> AnyPublisher<T.Response, APIError> in
// Responseデータのstatus項目が正常値であることをチェック
guard decodedData.status == "ok" else {
return Fail<T.Response, APIError>(
error: APIError.responseStatusError
).eraseToAnyPublisher()
}
return Just(decodedData)
.setFailureType(to: APIError.self)
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
APIに関しては、こちらのAPIを使う場合を想定しています。なので今回は基本的にGET通信を想定して作ってます。
※異なるAPIを使う場合もちょっとだけアレンジ必要ですが、基本的な構造は同じだと思います。
これから、この完成コードに至るまでのポイントとなるコードをいくつかピックアップして解説していきます。
共通化するためのRequestを実装する
完成コードのfunc send<T: RequestProtocol>(_ request: T)-> AnyPublisher<T.Response, APIError>
となっている箇所は、ジェネリクスによる共通化を行いました。
呼び出すAPIごとにRequestパラメーターとResponseの型は異なりますが、API通信をしてエラーハンドリングする部分は共通なため、こういった形でどんどん共通化していきます。
プロトコルを定義する
共通化する中で全てのRequestが共通で持っているプロパティやメソッドをプロトコルにまとめます。
こうすることで、先ほどの完成コード内で、抽象的な型であっても、各プロパティにアクセスができるようになります。
// MARK: - RequestProtocol
protocol RequestProtocol {
associatedtype Response: DataStructure
var method: HTTPMethod { get }
var baseURL: String { get }
var path: String { get }
var parameters: [String : Any]? { get set }
}
extension RequestProtocol {
var baseURL: String {
return "https://newsapi.org/v2/"
}
}
// MARK: - HTTPMethod
enum HTTPMethod: String {
case POST = "POST"
case GET = "GET"
}
// MARK: - DataStructure
protocol DataStructure: Encodable, Decodable {
var status: String { get }
}
最も重要なポイントとしては、associatedtype Response: DataStructure
のところです。
このように書くことで、実際にRequestProtocolを継承したオブジェクトを作る際に、RequestとResponseを1対1で紐づけることができます。
リクエストのサンプル
例えば、リクエストはこんな感じになります。
struct TopNewsRequest: RequestProtocol {
typealias Response = NewsEntity
var method: HTTPMethod = .GET
var path: String = "top-headlines"
var parameters: [String : Any]?
init(country: Country) {
parameters = [
"country": country.rawValue,
"apiKey": APIKey.newsAPI
]
}
}
typealias Response = NewsEntity
とすることで、TopNewsRequest
のResponseの型はNewsEntity
である。というのを確定しています。
Responseについて
ResponseはCodableによるDecode処理を行うため、Encodable
、Decodable
に準拠したStructにしておきます。
あとは、各APIに応じてプロパティを設定するだけですので、ここでは割愛します🙏
sendメソッドを呼び出す時のポイント
呼び出す時は、このような形で呼び出します。
func fetchTopNews(country: Country) -> AnyPublisher<NewsEntity, APIError> {
let request = TopNewsRequest(country: country)
return client.send(request)
}
func send<T: RequestProtocol>(_ request: T)-> AnyPublisher<T.Response, APIError>
となっているので、メソッドの戻り値は、T.Response
。
つまり、TopNewsRequest
であれば、先ほどtypealias Response = NewsEntity
としているため、T.Response
は、NewsEntity
であるということが確定します。
エラーハンドリングについて
1番最初に貼った完成コードのエラーハンドリングについて、1つ1つ解説していきます。
通信前にエラーが発生した場合の処理
URLSession
の非同期処理が走る前に、URLが変換できないなどの内部エラーが発生した場合は、
Fail
を使います。
Fail
は、Just
のエラー版だと考えていただければわかりやすいと思います。下記のような感じで即エラーを返すことができます。
Fail<T.Response, APIError>(error: APIError.failedToCreateURL).eraseToAnyPublisher()
1番最初のtryMap(_:)
CombineにおけるtryMap(_:)
は、下記のように定義されています。
tryという名前の通り、上流のPublisherを受け取り、その結果によって、自分自身でエラーをthrowすることができます。
今回の場合、受け取ったResponseのHTTPステータスコードが正常値の範囲を超えていたら、エラーをthrowしています。
2番目のmapError(_:)
mapError(_:)
は、上流のPublisherで発生したエラーを任意のエラーに変換することができます。
先ほどのtryMap(_:)
は、自分でエラーを発生させるというものですが、mapError(_:)
は、既に発生しているエラーの型を変換したいときに利用します。
本来、Codableを使ったDecode処理は、try-catchで囲まなくてはいけないため、.decode(type: T.Response.self, decoder: JSONDecoder())
はエラーが発生する可能性があります。
今回はOSが発生させるエラーを、アプリ内で定義しているエラー型へ変換したいため、mapError(_:)
を使っているということになります。
また、上流のPublisher(tryMap(_:)
)で自分が発生させたエラーもこちらで処理しておきます。
3番目のflatmap(maxpublishers:_:)
最後のハンドリングは、取得したResponseが持っているStatusCode(HTTPステータスコードとは別のもの)が、"ok"ではない時はエラーとして扱いたいというものです。
ここちょっとポイントなのですが、Responseの値によって自分でエラーを発生させたいということで、tryMap(_:)
を使うのが適切のように思われます。
しかし、tryMap(_:)
を使ってしまうと下記のようなコンパイルエラーが発生します。
「Cannot convert return expression of type 'AnyPublisher<T.Response, Error>' to return type 'AnyPublisher<T.Response, APIError>'」
理由としては、tryMap(_:)
は、自分が定義したAPIError
という独自の型を返すことができず、Apple純正のError
型を返すためです。
そこで使うのが、flatmap(maxpublishers:_:)
です。
flatMapの中で、Fail
を返すようにすることで、AnyPublisher<T.Response, APIError>
の型を保ったまま、下流のPublisherに処理をチェーンすることができるようになります。
おわりに
今回は、Combineを使ったAPI通信処理について書かせていただきました。
エラーハンドリングは、アプリによって異なってくると思うので、今回のこちらの記事をベースに、皆様のアプリ開発のお役に立てれば幸いです。
ではまた👋