⚠️

【iOS】APIのエラーハンドリング

2024/12/14に公開

エラーハンドリングの目標

  • ユーザー体験の向上: エラーが発生しても、ユーザーが次に取るべき行動を明確にできる。
  • デバッグ性の向上: 開発者がエラーの原因を特定しやすくする。
  • システムの安定性の向上: エラーがシステム全体に波及することを防ぐ。

全体のフロー

  1. リクエストを発行: API クライアントがリクエストを送信。
  2. レスポンスを解析: ステータスコードとデータを解析して適切なエラー型に変換。
  3. ドメインエラーを変換: レスポンスエラーをサービス固有のエラーに変換。
  4. UIに通知: エラーをユーザーに通知し、必要に応じて再試行や操作案内を提示。
  5. ログを記録: エラー情報を収集し、デバッグまたは分析に役立てる。

構造

APIのエラーハンドリングを以下のレイヤーに分けて設計する

  1. データ層 (Network/API レベル)
  2. ドメイン層 (サービスレベル)
  3. プレゼンテーション層 (UI レベル)

今回扱うアプリ

https://github.com/akitorahayashi/techtrain_book_reviewer

1. データ層 (Network/API レベル)

  • API呼び出しの結果を取得し、エラーを適切にラップして上位層に渡す。
  • 通常は Result 型 (.success or .failure) やErrorプロトコルに準拠した型(カスタムエラー型とする)を使用。

Result型

Result 型は Swift 標準ライブラリで提供される型で、処理結果が 成功 か 失敗 のいずれかである場合に使用する。

// TechTrainAPIErrorについては次の例で定義した
func fetchData(completion: @escaping (Result<String, TechTrainAPIError>) -> Void) {
    let success = false // 成功か失敗かをシミュレーション

    if success {
        // 成功時にデータをラップしてクロージャに渡す
        completion(.success("データ取得成功"))
    } else {
        // 失敗時にサーバーエラーをラップしてクロージャに渡す
        completion(.failure(.serverError(
            statusCode: 403,
            messageJP: "認証エラー",
            messageEN: "You are not authorized user"
        )))
    }
}

カスタムエラー型

Result型を使って、エラー時にErrorプロトコルに準拠した型を扱うイメージ

TechTrainAPIError.swift
enum TechTrainAPIError: Error {
    case invalidURL
    case networkError
    case serverError(statusCode: Int, messageJP: String, messageEN: String)
    case decodingError
    case keychainSaveError
    case unknown
    
    var localizedDescription: String {
        switch self {
        case .invalidURL:
            return "無効なURLです。"
        case .networkError:
            return "ネットワークエラーが発生しました"
        case .serverError(let statusCode, let messageJP, let messageEN):
            return "サーバーエラーが発生しました (\(statusCode)):\nJP: \(messageJP)\nEN: \(messageEN)"
        case .decodingError:
            return "データの解読に失敗しました。"
        case .keychainSaveError:
            return "Keychain 保存エラーが発生しました"
        case .unknown:
            return "不明なエラーが発生しました。"
        }
    }

    ...続く

2. ドメイン層(サービスレベル)

  • API クライアントやデータベースのエラーを集約してドメイン特有のエラーに変換。
  • この層では、エラーのリカバリや再試行のロジックを記述することなどもある。
TechTrainAPIError.swift
    ...続き
    /// サービス独自のエラー型
    enum ServiceError: Error {
        case invalidRequest(messageJP: String, messageEN: String)
        case unauthorized(messageJP: String, messageEN: String)
        case notFound(messageJP: String, messageEN: String)
        case conflict(messageJP: String, messageEN: String)
        case serviceUnavailable(messageJP: String, messageEN: String)
        case serverIssue(messageJP: String, messageEN: String)
        case underlyingError(TechTrainAPIError)
        case unknown
        
        var localizedDescription: String {
            switch self {
            case .invalidRequest(let messageJP, _):
                return messageJP
            case .unauthorized(let messageJP, _):
                return messageJP
            case .notFound(let messageJP, _):
                return messageJP
            case .conflict(let messageJP, _):
                return messageJP
            case .serviceUnavailable(let messageJP, _):
                return messageJP
            case .serverIssue(let messageJP, _):
                return messageJP
            case .underlyingError(let error):
                return error.debugDescription
            case .unknown:
                return "不明なエラーが発生しました。"
            }
        }
    }

    // サービス層でエラーを細分化
    func toServiceError() -> ServiceError {
        switch self {
        case .serverError(let statusCode, let messageJP, let messageEN):
            switch statusCode {
            case 400:
                print("Service: バリデーションエラー - \(messageJP)")
                return .invalidRequest(messageJP: messageJP, messageEN: messageEN)
            case 401:
                print("Service: 認証エラー - \(messageJP)")
                return .unauthorized(messageJP: messageJP, messageEN: messageEN)
            case 404:
                print("Service: リソースが見つかりません - \(messageJP)")
                return .notFound(messageJP: messageJP, messageEN: messageEN)
            case 409:
                print("Service: 競合エラー - \(messageJP)")
                return .conflict(messageJP: messageJP, messageEN: messageEN)
            case 503:
                print("Service: サービス利用不可 - \(messageJP)")
                return .serviceUnavailable(messageJP: messageJP, messageEN: messageEN)
            default:
                print("Service: サーバー側の問題 - \(messageJP)")
                return .serverIssue(messageJP: messageJP, messageEN: messageEN)
            }
        default:
            return .underlyingError(self)
        }
    }
}

サービス独自のエラーに変換してViewControllerなどで扱いやすくする

BookReviewService.swift
func postBookReview(
        title: String,
        url: String,
        detail: String,
        review: String,
        token: String,
        completion: @escaping (Result<Void, TechTrainAPIError.ServiceError>) -> Void
    ) {
        let headers = ["Authorization": "Bearer \(token)"]
        let endpoint = "/books"
        let parameters: [String: Any] = [
            "title": title,
            "url": url,
            "detail": detail,
            "review": review
        ]
        
        TechTrainAPIClient.shared.makeRequest(to: endpoint, method: "POST", parameters: parameters, headers: headers) { result in
            switch result {
            case .success:
                completion(.success(()))
            case .failure(let error):
                // サービス独自のエラーに変換
                completion(.failure(error.toServiceError()))
            }
        }
    }

3. プレゼンテーション層 (UI レベル)

  • ユーザーにエラーを視覚的に通知し、次のアクションを促す。
  • UIAlertControllerやLabelなどを使ってエラーを表示。
EditBookReviewVC.swift
private func createReview() {
        guard validateInputs(), let token = getToken() else { return }
        
        BookReviewService.shared.postBookReview(
            title: editView.titleTextField.text!,
            url: editView.urlTextField.text!,
            detail: editView.detailInputField.text!,
            review: editView.reviewInputField.text!,
            token: token
        ) { [weak self] result in
            DispatchQueue.main.async {
                switch result {
                case .success:
                    self?.showAlert(title: "成功", message: "レビューが投稿されました", completion: {
                        self?.clearFields()
                    })
                case .failure(let error):
                    // アラートでエラーの内容を表示
                    self?.showError(message: error.localizedDescription)
                }
            }
        }
    }

まとめ

適切にエラーを扱うことで、ユーザー体験の向上、デバッグ性の向上、そしてシステムの安定性を高めることができる。

Discussion