Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

MirrativのAPI通信に関するiOS実装の改善とCodableの活用

こんにちは Mirrativ iOSエンジニアの千吉良です。

今回は Mirrativ iOSアプリにおいてAPI通信に関する実装の改善を行ない、関連してCodableを活用した話をしたいと思います。

Mirrativ iOSアプリのソースコードには歴史があり、API通信に関するコアの実装は約4年前に実装されたものでした。日々実装を行なっていていくつか課題を感じていた箇所がありましたが、API通信に関する実装はコード量も多く、新しい仕組みを導入して一気に刷新しようというわけにはいきません。既存のファイル構成などを活かしつつ軽量な仕組みを導入して、新規実装のみ新しい方法で実装したり、部分的に修正していけるような実装を考える必要がありました。

既存のコードへの課題感

既存のAPI通信に関する実装は大きく3つの部分

  1. レスポンスに関する定義
  2. リクエストに関する定義
  3. リクエスト処理と結果のハンドリング

があり、それぞれについて課題がありました。以下に載せるコードはあるGETリクエストに関するもので、コード中に定義されていないクラスも含まれているのですが、雰囲気だけでも感じていただければ幸いです。コード中のコメントは今回の説明のために追記しています。

1. レスポンスに関する定義

あるAPIのレスポンスを定義するためのコードです。コード中の JSON のデコードには SwiftyJSON を使用しています。

final class DiamondResponse {

  // レスポンスに含まれる情報
  class ProductsParams: JSONDeserializable {
      var diamond: Int64?
      var products: [Product] = []
      var isMaintenance: Int?

      final class Product: JSONDeserializable {
          var productID: Int?
          var coin: Int?
          var diamond: Int?

          init(json: JSON) {
              if json == nil { return }
              productID = APIUtil.int(json["product_id"])
              coin = APIUtil.int(json["coin"])
              diamond = APIUtil.int(json["diamond"])
          }
      }

      required init(json: JSON) {
          if json == nil { return }
          diamond = APIUtil.int64(json["diamond"])
          if let array = json["products"].array {
              products = array.compactMap({ Product(json: $0) })
          }
          isMaintenance = json["is_maintenance"].int
      }
  }

  // レスポンスの定義
  // ProductsParams を継承している
  final class Products: ProductsParams, APIResponse {
      // HTTPステータスコードなどの情報が含まれている
      var status: Status.StatusParams?

      required init(json: JSON) {
          super.init(json: json)
          if json == nil { return }
          status = Status.StatusParams(json: json["status"])
      }
  }

  // ...
}

課題は以下です。

  • 記述量が多い
  • プロパティが全てオプショナルなので、 レスポンスに必須なパラメータがある場合でも利用側のコードで nil を想定した記述をする必要がある

2. リクエストに関する定義

final class ProductsRequestParams: RequestParams {
    let PRODUCTS: String = "products"

    func getUrl() -> String? {
        // APIのパスを決定するための記述
        return APIUtil.makeUrl(API, DIAMOND, PRODUCTS)
    }

    func getRequestParams() -> Any {
        let params: [String: String] = [:]
        return params
    }

    // POSTするコンテンツの種類
    func getContentType() -> ContentType {
        // GETリクエストの場合には .none と書く必要がある
        return .none
    }
}

final class ProductsRequest {
    // GETリクエストで、レスポンスはDiamondResponse.Products型
    let request: GetRequest<DiamondResponse.Products>

    init(params: ProductsRequestParams, referer: String) {
        request = GetRequest<DiamondResponse.Products>(params: params, referer: referer)
    }

    func executeAsync(_ onComplete: @escaping (_ response: DiamondResponse.Products?, _ error: Error?) -> Void) {
        request.execute(onComplete)
    }
}

課題は以下です。

  • 記述量が多い
    • getContentType, executeAsync など、個々の定義から削れると思われる処理がある
    • レスポンスの型名などを複数の箇所に記述する必要がある

3. リクエスト処理と結果のハンドリング

let params = Diamond.ProductsRequestParams()
let request = Diamond.ProductsRequest(params: params, referer: referer)
request.executeAsync { [weak self] response, error in
    guard let self = self else {
        return
    }
    if let _ = APIUtil.getDestructiveErrorCodeIfAny(response) {
        // アラートでエラー表示
        MRAlertView.showAlert(self, title: nil, message: L_ERROR_ACCESS.localized)
        return
    }
    if let message = APIUtil.getErrorMessageIfAny(response, error: error) {
        // アラートでエラー表示
        MRAlertView.showAlert(self, title: nil, message: message)
        return
    }
    if let response = response {
        if let diamond = response.diamond {
            self.diamond = diamond
            self.diamondCountLabel.text = self.numberFormater.string(from: NSNumber(value: diamond))
        } else {
            self.diamond = 0
            self.diamondCountLabel.text = ""
        }
        self.products = response.products
        self.tableView.reloadData()
    }
}

課題は以下です。

  • 正常なレスポンスの場合にも nil を考慮する必要がある
  • エラーメッセージの取得方法が複数あり、不慣れな人に優しくない

方針

上の GetRequest に相当する新しいプロトコルを定義しました。 associatedtype で型の記述の重複をなくし、 protocol extension でパラメータが必要ない場合などの記述を削減します。

public protocol HTTPRequestRepresentation: URLConvertible {
    var uri: String { get }
    var method: HTTPMethod { get }
}

public protocol HTTPGetRequest: HTTPRequestRepresentation {
    // レスポンスの型を指定
    associatedtype Response: HTTPResponse

    // 上の getUrl に相当
    var endpoint: HTTPEndpointConvertible { get }

    var parameters: [String: String] { get }
}

extension HTTPGetRequest {
    public var method: HTTPMethod {
        return HTTPMethod.get
    }

    public var parameters: [String: String] {
        return [:]
    }
}

HTTPResponse はそれ自身が Codable で、HTTPステータスコード等の情報を含む struct として定義されています。

public struct HTTPResponseStatus: Codable {
    // ...
}

public protocol HTTPResponse: Codable {
    var status: HTTPResponseStatus { get }
}

新しい定義を用いて、上で例に挙げた部分のコードの書き換えを行いました。

改善後のコード

1. レスポンスに関する定義

struct Products: HTTPResponse {
    let status: HTTPResponseStatus

    struct Product: Codable {
        let productId: String
        let coin: String
        let diamond: String
    }

    let diamond: String
    let products: [Product]
    var isMaintenance: Int?
}

改善前

Codable に準拠して、もともと記述していたJSONのデコードのためのコードが不要になっています。必須な値については let で定義し、任意な値についてはオプショナルで定義しています。

json 内にある値の命名はスネークケースに従っているので、JSON のデコードの際にキー名のスネークケースとキャメルケースを自動的に変換するような設定を行なっています。

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

2. リクエストに関する定義

final class Products: HTTPGetRequest {
    typealias Response = DiamondResponse.Products

    let endpoint: HTTPEndpointConvertible = Endpoint.Diamond.products
}

改善前

削れる記述を削り、よりシンプルな定義になっています。

3. リクエスト処理と結果のハンドリング

let reqest = Diamond.Products()
HTTPClient.shared.call(reqest, referer: referer) { [weak self] result in
    guard let self = self else {
        return
    }
    switch result {
    case .success(let response):
        let diamond = response.diamond
        self.diamond = diamond
        self.diamondCountLabel.text = self.numberFormater.string(from: NSNumber(value: diamond))
        self.products = response.products
        self.tableView.reloadData()
    case .failure(let error):
        MRAlertView.showAlert(self, title: nil, message: error.message)
    }
}

改善前

HTTPClient という共通の処理を行うクラスを通じて通信を行ないます。成功時には必須な値はそのまま取得でき、失敗時には HTTPClient の内部でエラーメッセージを適切に取得するので、エラーの取り扱いに対する迷いを減らすことができました。

改善前のコードの合計行数はコメントを除いて 94 行だったのですが、修正後のコードの合計行数は 34 行になりました。POSTリクエストについてもbodyデータの生成などを含めて修正を行なっているのですが、複雑になるので今回は説明は省きました。

継続的な改善に向けて

今回はAPI通信部分についての改善をご紹介しましたが、Mirrativ では日々開発体験の向上のために時間を割いています。

Mirrativではもともと API Blueprint を利用してAPI仕様をMarkdownで記述してきたのですが、世間の時流やプログラム的な扱いやすさ含めて、現在 OpenAPI 3.0 (Swagger 3.0) に移行しています。

すでにあるE2Eのテストによるサーバ側のロジックの保証に加えて、今後はOpenAPIを使ったAPIのInput/OutputのValidationも導入し、APIの型の整合性の保証やクライアントでの不要なnullチェックの削減をして行こうと思っています。

Mirrativ は副業や業務委託で働かれている方も多いので、上記のような改善を継続的に行っていくためにドキュメントの整備と周知も行なっています。機能開発と改善活動を両立していけるような環境を目指したいですね。

We are hiring!

Mirrativ では一緒に開発してくれるエンジニアを募集しています!

体験入社や副業も大歓迎なのでお気軽にどうぞ!

www.mirrativ.co.jp