🎙️

[Swift]いち画面で複数のデータを受け付けるケースの設計方針をはやく言いたい

2024/06/12に公開

皆様こんにちは。mikanでiOSエンジニアをしているSabです。
突然ですが、現在mikanではこれまでFirestoreとCloudSQLに分けて保存されていたデータをCloudSQLに統合しようというプロジェクトが進んでいます。

今回はそのプロジェクトにおけるiOSアプリの対応として、チームで採用した設計方針と実装におけるTipsを紹介したいと思います。

プロジェクト概要

チームで採用した設計方針をはやく言いたいのは山々ですが、このテーマを語るにあたりまずはプロジェクトの概要を説明しなければなりません。

最初にクラウドにデータを持つようになった当時のmikanアプリでは、教材やユーザーの情報をFirestoreに持っていました。
そこから運用していく中で一部の教材をCloudSQLに持つようになり、現在はFirestoreとCloudSQLのそれぞれから必要な情報を取得してくるという仕様になっています。

しかし、この状態には様々な課題を感じていたため、今回FirestoreのデータをCloudSQLに統合して一本化しようというプロジェクトがスタートしました。
(このあたりの課題感や意思決定の背景はバックエンドチームがブログを書いてくれるかも...!?)

アプリ側で必要な対応

教材データはさほど多くない上、更新が限定的でコントロール可能なため移行はスムーズなのですが、ユーザーデータは量も多く、またユーザーのアプリ利用によって頻繁に更新されます。
これに対応するためにデータ移行はユーザー単位で分割して実行される予定のため、クライアント側では移行前と移行後のユーザー状態を考慮する必要がでてきました。

具体的には、ユーザーデータの移行が終わっている場合はCloudSQLと通信してデータを取得、移行が終わっていなければFirestoreからデータを取得するというものです。

また、この機にデータの整理なども入るため各画面でFirestoreとCloudSQLから取得できるデータが若干異なっており、画面側は全てのユーザーデータの移行が完了するまでその両方を受け入れられるようにしなければなりません。

具体的にはこんなイメージです。

/// Firestoreから取得できる教材データ
public struct FirestoreBook: Equatable, Codable {
  public var name: String
  public var description: String
  public let imageUrl: URL
  public var wordCount: Int
  .
  .
  .
}

/// 統合後のCloudSQLから取得できる教材データ
public struct IntegratedBook: Identifiable, Equatable {
  public let id: ID
  public let name: String
  public let imageUrl: URL
  public let availability: Availability
  .
  .
  .
}

考えうる対応方針

ここでチームで採用した設計方針をはやく言いたい...のですが、「ユーザーの状態によって通信先を分け、いずれのデータであっても画面が同様に機能する」という要件に対して、考えられる主な対応としては以下のようなものがあるかと思います。

  • Viewのための構造体を定義しマッピングする
  • Viewで必要なプロパティを定義したprotocolを継承させる
  • enumでパターン分けする

それぞれのざっくりしたイメージを見てみましょう。

Viewのための構造体を定義しマッピングする

先に例示した各structとは別にプレゼンテーションレイヤーで扱うためのstructを定義する方法です。

/// Viewで使うための教材データ
public struct BookEntity: Equatable {
  public let name: String
  public let imageUrl: URL
  .
  .
  .

  public init(_ firestore: FirestoreBook) {
    self.name = firestore.nema
    self.imageUrl = firestore.imageUrl
    .
    .
    .
  }

  public init(_ integrated: IntegratedBook) {
    self.name = integrated.nema
    self.imageUrl = integrated.imageUrl
    .
    .
    .
  }
}
メリット
  • 画面側の実装時にデータ取得元の考慮が不要
デメリット
  • ドメインレイヤーとやり取りする際に都度変換する必要がある
  • ユーザーの移行完了ステータスを常に参照する必要がある

最も単純な対応かと思います。
単独の画面における対応だけ考えたら良い方針と感じますが、今回のケースはアプリ利用中ほぼ全ての画面で同様の対応が必要となります。
基本的なアプリの構造として、各画面で必要な情報はそれぞれの画面に遷移した時に通信して取得するようになっているため、この対応方針だとそれぞれの画面に遷移する度にユーザーの移行完了ステータスをチェックし、その結果に応じて通信先を切り替えるという処理が必要になってきます。

Viewで必要なプロパティを定義したprotocolを継承させる

プレゼンテーションレイヤーで使うプロパティ群をprotocolとして定義し、先に例示したstructに継承させる方法です。

public protocol BookProperties {
  public var name: String { get }
  public var imageUrl: URL { get }
  .
  .
  .
}

extension FirestoreBook: BookProperties { }
extension IntegratedBook: BookProperties { }
メリット
  • 画面側の実装時に共通の型として扱える
  • 型をキャストすることでどこから取得したデータか判別できる
デメリット
  • 型のキャストをコードで保証できない
  • 実装者がBookPropertiesを継承したValue Objectを把握している必要がある

型の情報を残しつつ抽象的に扱えるようにする方針です。
View側ではBookPropertiesを取り回すことになります。
Viewで必要な値にシンプルにアクセスでき、元の型情報も参照可能なためドメイン層でユーザーの移行完了ステータスを都度確認せずとも前の画面から受け取ったデータの元の型をチェックすることで通信先を判別することが可能となります。

private func fetchDetail(book: BookProperties) async throws -> BookDetailProperties {
  if book is FirestoreBook {
    /// Firestoreからデータを取得して返す
    return detailData
  }

  if book is IntegratedBook {
    /// CloudSQLからデータを取得して返す
    return detailData
  }

  throw TypeUnmatchError()
}

ただ、上記のコードからもわかる通りいずれの型にもキャストできないケースを考慮した実装が必要となります。
また、protocolを継承したValue Objectと、キャストによる分岐を記述している箇所を正しく把握していないと運用の中で記述漏れなどが発生しやすいです。

enumでパターン分けする

それぞれの通信先から受け取るデータをenumでパターン分けして定義する方法です。
パースしたValue ObjectはAssociated Valuesとして持ちます。

public enum BookEntityType: Equatable {
  case firestore(FirestoreBook)
  case cloudSql(IntegratedBook)
}
メリット
  • 画面側の実装時に共通の型として扱える
  • 受け取る可能性があるパターンを網羅的に記述できる
デメリット
  • データを利用する際に都度パターンマッチでAssociated Valuesを取り出さないといけないため冗長

Value Objectの情報を持ちつつenumでより明確に型で縛る方針です。
View側ではBookEntityTypeを受け取り、Associated Valuesを参照してデータを利用していくことになります。

/// データ利用時
public func setBook(_ bookType: BookEntityType) {
  switch bookType {
    case .firestore(let firestoreBook):
      let name = firestoreBook.name
      ・
      ・
      ・
    case .cloudSql(let integratedBook):
      let name = integratedBook.name
      ・
      ・
      ・
  }
}

/// 通信先の判別
private func fetchDetail(book: BookEntityType) async throws -> BookDetailType {
  switch book {
    case .firestore:
      /// Firestoreからデータを取得して返す
      return .firestore(detailData)
    case .cloudSql:
      /// CloudSQLからデータを取得して返す
      return .cloudSql(detailData)
  }
}

これまでの方法の比較してより型が明確に扱えています。
ただし、データを扱うシーンではswitch caseif caseなどのパターンマッチが必要となるため、冗長な記述になりがちです。

mikanが採用した対応

チームで採用した設計方針をはやく言いたいっ...!!
ということで、上記の中から最後に紹介した「enumでパターン分けする方針」を採用しました。

やっと言えた〜 (´Д`)

主な理由としては当該画面で扱いうるデータに対応したコードが漏れなく書かれているか、開発者が考慮できていない型が処理に混ざってこないか、といった点にコンパイル時点で気付くことができるためです。

これにより複数名で運用しているプロダクトであってもより安全で、意図が伝わりやすいコードになると考えています。

また、今回のように複数パターンのValue Objectを画面で受け取って分岐する処理はプロダクトの変化とともに現れたり、消えたりと不変なものではありません。
そのため、enumで柔軟性を持たせた設計にすることでパターンの追加や削除時にも大きな再設計を必要とせず運用していけることも大きなメリットと捉えています。

デメリットの解消

とはいえ、やはりデータを利用する毎にパターンマッチを書いてAssociated Valuesを取り出し、それぞれで必要な値にアクセスするのは手間がかかりますし、コードの見通しも悪くなります。

@Observable
final class Model {
  let name: String
  let imageUrl: URL

  init(_ bookType: BookEntityType) {
      switch bookType {
        case .firestore(let firestoreBook):
          self.name = firestoreBook.name
          self.imageUrl = firestoreBook.imageUrl
        case .cloudSql(let integratedBook):
          self.name = integratedBook.name
          self.imageUrl = integratedBook.imageUrl
      }
  }
}

できれば以下のように受け取ったValue Objectから直接必要な値にアクセスして利用したいところです。

@Observable
final class Model {
  let name: String
  let imageUrl: URL

  init(_ bookType: BookEntityType) {
    self.name = bookType.name
    self.imageUrl = bookType.imageUrl
  }
}

Tipsと言えるほどの事ではないかもしれませんが、この課題を解消するためにDynamic Member Lookupを採用しました。
これによりコンパイラはBookEntityTypeが任意のプロパティを持つ前提でコンパイルし、プロパティへのアクセスはKeyPathを使って実行時に動的に解釈されるようになります。

public protocol BookProperties {
  public var name: String { get set }
  public var imageUrl: URL { get set }
  .
  .
  .
}

@dynamicMemberLookup
public enum BookEntityType: Equatable {
  case firestore(FirestoreBook)
  case cloudSql(IntegratedBook)

  public subscript<T>(dynamicMember keyPath: WritableKeyPath<KingdomWordType, T>) -> T {
    get {
      switch self {
        case .firestore(let firestoreBook):
          return firestoreBook[keyPath: keyPath]
        case .cloudSql(let integratedBook):
          return integratedBook[keyPath: keyPath]
      }
    }
    set {
      switch self {
        case .firestore(var firestoreBook as BookProperties):
          firestoreBook[keyPath: keyPath] = newValue
          self = .firestore(firestoreBook as! FirestoreBook)
        case .cloudSql(var integratedBook as BookProperties):
          integratedBook[keyPath: keyPath] = newValue
          self = .cloudSql(integratedBook as! IntegratedBook)
      }
    }
  }
}

この対応をする事で先ほどの課題を解消し、理想的な書き方で値にアクセスできるようになりました🎉

(再掲)

@Observable
final class Model {
  let name: String
  let imageUrl: URL

  init(_ bookType: BookEntityType) {
    self.name = bookType.name
    self.imageUrl = bookType.imageUrl
  }
}

おわりに

最後まで読んでくださりありがとうございます🙇‍♂️
はやく言いたいと言っておきながら、なんだかんだ回りくどい記事になってしまいすみません...

mikanではプロダクトの改善を一緒に進めていける仲間を求めています!
もし少しでも興味を持ってくださった方は是非お気軽にお話ししましょう☕️

https://herp.careers/v1/mikan/NXWAVs9SJgBZ

mikan blog

Discussion