Cloud Firestore iOS Codelab

1. 概要

目標

この Codelab では、Swift で iOS 上に Firestore をバックエンドとするレストランおすすめアプリを構築します。ここでは以下について学びます。

  1. iOS アプリから Firestore へのデータの読み取りと書き込みを行う
  2. Firestore データの変更をリアルタイムでリッスンする
  3. Firebase Authentication とセキュリティ ルールを使用して Firestore データを保護する
  4. 複雑な Firestore クエリを作成する

前提条件

この Codelab を開始する前に、次のものがインストールされていることを確認してください。

  • Xcode バージョン 14.0 以降
  • CocoaPods 1.12.0 以降

2. サンプル プロジェクトを取得する

コードのダウンロード

まず、サンプル プロジェクトのクローンを作成し、プロジェクト ディレクトリで pod update を実行します。

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

Xcode で FriendlyEats.xcworkspace を開き、実行します(Cmd+R)。GoogleService-Info.plist ファイルがないため、アプリは正しくコンパイルされ、起動時にすぐにクラッシュします。これは次のステップで修正します。

3. Firebase を設定する

Firebase プロジェクトを作成する

  1. Google アカウントを使用して Firebase コンソールにログインします。
  2. ボタンをクリックして新しいプロジェクトを作成し、プロジェクト名(例: FriendlyEats)を入力します。
  3. [続行] をクリックします。
  4. Firebase の利用規約が表示されたら、内容を読み、同意して [続行] をクリックします。
  5. (省略可)Firebase コンソールで AI アシスタンス(「Gemini in Firebase」)を有効にします。
  6. この Codelab では Google アナリティクスは必要ないため、Google アナリティクスのオプションをオフに切り替えます
  7. [プロジェクトを作成] をクリックし、プロジェクトのプロビジョニングが完了するまで待ってから、[続行] をクリックします。

アプリを Firebase に接続する

新しい Firebase プロジェクトに iOS アプリを作成します。

Firebase コンソールからプロジェクトの GoogleService-Info.plist ファイルをダウンロードし、Xcode プロジェクトのルートにドラッグします。プロジェクトを再度実行して、アプリが正しく設定され、起動時にクラッシュしなくなったことを確認します。ログインすると、次の例のような空白の画面が表示されます。ログインできない場合は、Firebase コンソールの [Authentication] でメールアドレス/パスワードのログイン方法が有効になっていることを確認してください。

d5225270159c040b.png

4. Firestore にデータを書き込む

このセクションでは、アプリの UI にデータを入力できるように、Firestore にデータを書き込みます。これは Firebase コンソールから手動で実行できますが、ここでは、アプリ自体で実行して、基本的な Firestore の書き込みを例示します。

このアプリのメインのモデル オブジェクトは、レストランです。Firestore データは、ドキュメント、コレクション、サブコレクションに分割されます。各レストランは、restaurants という名前の最上位のコレクションにドキュメントとして保存されます。Firestore データモデルについて詳しくは、こちらのドキュメントでドキュメントとコレクションをご確認ください。

Firestore にデータを追加する前に、レストラン コレクションへの参照を取得する必要があります。RestaurantsTableViewController.didTapPopulateButton(_:) メソッドの内部 for ループに次のコードを追加します。

let collection = Firestore.firestore().collection("restaurants")

コレクション参照を取得したので、データを書き込むことができます。追加したコードの最後の行の直後に、次のコードを追加します。

let collection = Firestore.firestore().collection("restaurants")

// ====== ADD THIS ======
let restaurant = Restaurant(
  name: name,
  category: category,
  city: city,
  price: price,
  ratingCount: 0,
  averageRating: 0
)

collection.addDocument(data: restaurant.dictionary)

上記のコードは、レストランのコレクションに新しいドキュメントを追加します。ドキュメント データは、Restaurant 構造体から取得した辞書から取得されます。

もう少しです。Firestore にドキュメントを書き込む前に、Firestore のセキュリティ ルールを開き、データベースのどの部分をどのユーザーが書き込めるようにするかを記述する必要があります。今のところ、認証済みユーザーのみがデータベース全体に対する読み取りと書き込みを行えるようにします。本番環境のアプリでは少し緩すぎますが、アプリの構築プロセスでは、実験中に認証の問題が頻繁に発生しないように、十分な緩さが必要です。この Codelab の最後では、セキュリティ ルールを強化し、意図しない読み取りと書き込みの可能性を制限する方法について説明します。

Firebase コンソールの [ルール] タブで次のルールを追加し、[公開] をクリックします。

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

セキュリティ ルールについては後で詳しく説明しますが、お急ぎの場合は、セキュリティ ルールのドキュメントをご覧ください。

アプリを実行してログインします。次に、左上の [Populate] ボタンをタップします。これにより、レストラン ドキュメントのバッチが作成されますが、アプリにはまだ表示されません。

次に、Firebase コンソールの Firestore データタブに移動します。レストラン コレクションに新しいエントリが表示されます。

Screen Shot 2017-07-06 at 12.45.38 PM.png

おつかれさまです。これで、iOS アプリから Firestore にデータが書き込まれました。次のセクションでは、Firestore からデータを取得してアプリに表示する方法を学びます。

5. Firestore からのデータを表示する

このセクションでは、Firestore からデータを取得してアプリに表示する方法を学びます。主要なステップは、クエリの作成とスナップショット リスナーの追加の 2 つです。このリスナーは、クエリに一致するすべての既存データについて通知され、リアルタイムで更新を受信します。

まず、フィルタリングされていないデフォルトのレストラン リストを提供するクエリを作成してみましょう。RestaurantsTableViewController.baseQuery() の実装を見てみましょう。

return Firestore.firestore().collection("restaurants").limit(to: 50)

このクエリは、「restaurants」という名前の最上位のコレクションから最大 50 件のレストランを取得します。クエリができたので、Firestore からアプリにデータを読み込むために、スナップショット リスナーをアタッチする必要があります。stopObserving() の呼び出しの直後に、RestaurantsTableViewController.observeQuery() メソッドに次のコードを追加します。

listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
  guard let snapshot = snapshot else {
    print("Error fetching snapshot results: \(error!)")
    return
  }
  let models = snapshot.documents.map { (document) -> Restaurant in
    if let model = Restaurant(dictionary: document.data()) {
      return model
    } else {
      // Don't use fatalError here in a real app.
      fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
    }
  }
  self.restaurants = models
  self.documents = snapshot.documents

  if self.documents.count > 0 {
    self.tableView.backgroundView = nil
  } else {
    self.tableView.backgroundView = self.backgroundView
  }

  self.tableView.reloadData()
}

上記のコードは、Firestore からコレクションをダウンロードし、ローカルで配列に保存します。addSnapshotListener(_:) 呼び出しは、サーバー上のデータが変更されるたびにビュー コントローラを更新するクエリにスナップショット リスナーを追加します。更新は自動的に行われるため、手動で変更をプッシュする必要はありません。このスナップショット リスナーはサーバーサイドの変更の結果としていつでも呼び出すことができるため、アプリが変更を処理できることが重要です。

辞書を構造体にマッピングしたら(Restaurant.swift を参照)、いくつかのビュー プロパティを割り当てるだけでデータを表示できます。RestaurantsTableViewController.swiftRestaurantTableViewCell.populate(restaurant:) に、次の行を追加します。

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

この入力メソッドは、テーブル ビューのデータソースの tableView(_:cellForRowAtIndexPath:) メソッドから呼び出されます。このメソッドは、以前の値型のコレクションを個々のテーブル ビューのセルにマッピングします。

アプリを再度実行し、先ほどコンソールで確認したレストランがシミュレータまたはデバイスに表示されていることを確認します。このセクションを正常に完了すると、アプリは Cloud Firestore でデータの読み書きを行います。

391c0259bf05ac25.png

6. データの並べ替えとフィルタリング

現在、アプリにはレストランのリストが表示されますが、ユーザーが自分のニーズに基づいてフィルタすることはできません。このセクションでは、Firestore の高度なクエリを使用して、フィルタリングを有効にします。

点心料理店をすべて取得するシンプルなクエリの例を次に示します。

let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")

whereField(_:isEqualTo:) メソッドは、その名が示すように、設定した制限を満たすフィールドを持つコレクションのメンバーのみをクエリしてダウンロードします。この例では、category"Dim Sum" のレストランのみをダウンロードします。

このアプリでは、複数のフィルタを連結して、「サンフランシスコのピザ」や「ロサンゼルスのシーフードを人気順に表示」などの特定のクエリを作成できます。

RestaurantsTableViewController.swift を開き、次のコードブロックを query(withCategory:city:price:sortBy:) の中央に追加します。

if let category = category, !category.isEmpty {
  filtered = filtered.whereField("category", isEqualTo: category)
}

if let city = city, !city.isEmpty {
  filtered = filtered.whereField("city", isEqualTo: city)
}

if let price = price {
  filtered = filtered.whereField("price", isEqualTo: price)
}

if let sortBy = sortBy, !sortBy.isEmpty {
  filtered = filtered.order(by: sortBy)
}

上記のスニペットは、複数の whereField 句と order 句を追加して、ユーザー入力に基づく単一の複合クエリを作成します。これで、クエリはユーザーの要件を満たすレストランのみを返すようになりました。

プロジェクトを実行し、料金、都市、カテゴリでフィルタできることを確認します(カテゴリ名と都市名を正確に入力してください)。テスト中、ログに次のようなエラーが表示されることがあります。

Error fetching snapshot results: Error Domain=io.grpc Code=9
"The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..."
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}

これは、Firestore はほとんどの複合クエリについてインデックスを必要とするためです。クエリでインデックスを必須にすると、Firestore は大規模なデータを高速で処理できます。エラー メッセージからリンクを開くと、Firebase コンソールで、正しいパラメータが入力された状態のインデックス作成 UI が自動的に開きます。Firestore のインデックスの詳細については、ドキュメントをご覧ください

7. トランザクションでデータを書き込む

このセクションでは、ユーザーがレストランに対するレビューを投稿する機能を追加します。これまでのところ、すべての書き込みはアトミックで、比較的単純でした。いずれかの書き込みがエラーになった場合は、単にユーザーに再試行を求める方法が考えられます。ユーザーがそうしなかった場合、アプリは自動的に書き込みを再試行します。

レストランにレビューを追加するには、複数の読み取りと書き込みを調整する必要があります。最初に、レビュー自体を送信する必要があります。次に、レストランの評価数と平均評価を更新する必要があります。一方が失敗して他方が成功した場合、データベースのある部分のデータが別の部分のデータと一致しなくなるという矛盾した状態に陥ります。

幸いなことに、Firestore は単一のアトミック オペレーションで複数の読み取りと書き込みを実行できるトランザクション機能を備えているため、データの整合性を保証できます。

次のコードを RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:) のすべての let 宣言の下に追加します。

let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in

  // Read data from Firestore inside the transaction, so we don't accidentally
  // update using stale client data. Error if we're unable to read here.
  let restaurantSnapshot: DocumentSnapshot
  do {
    try restaurantSnapshot = transaction.getDocument(reference)
  } catch let error as NSError {
    errorPointer?.pointee = error
    return nil
  }

  // Error if the restaurant data in Firestore has somehow changed or is malformed.
  guard let data = restaurantSnapshot.data(),
        let restaurant = Restaurant(dictionary: data) else {

    let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
      NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
    ])
    errorPointer?.pointee = error
    return nil
  }

  // Update the restaurant's rating and rating count and post the new review at the
  // same time.
  let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
      / Float(restaurant.ratingCount + 1)

  transaction.setData(review.dictionary, forDocument: newReviewReference)
  transaction.updateData([
    "numRatings": restaurant.ratingCount + 1,
    "avgRating": newAverage
  ], forDocument: reference)
  return nil
}) { (object, error) in
  if let error = error {
    print(error)
  } else {
    // Pop the review controller on success
    if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
      self.navigationController?.popViewController(animated: true)
    }
  }
}

更新ブロック内では、トランザクション オブジェクトを使用して行うすべてのオペレーションは、Firestore によって単一のアトミック更新として扱われます。サーバーで更新が失敗した場合、Firestore は自動的に数回再試行します。つまり、エラー条件は、デバイスが完全にオフラインになっている場合や、ユーザーが書き込みを試みているパスに書き込む権限がない場合など、繰り返し発生する単一のエラーである可能性が高いということです。

8. セキュリティ ルール

アプリのユーザーがデータベース内のすべてのデータを読み書きできないようにする必要があります。たとえば、レストランの評価は誰でも見られるようにし、評価を投稿できるのは認証済みのユーザーのみにする必要があります。クライアントで優れたコードを記述するだけでは不十分です。完全に安全にするには、バックエンドでデータ セキュリティ モデルを指定する必要があります。このセクションでは、Firebase セキュリティ ルールを使用してデータを保護する方法について説明します。

まず、この Codelab の冒頭で作成したセキュリティ ルールを詳しく見てみましょう。Firebase コンソールを開き、[Firestore] タブの [Database] > [Rules] に移動します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{any}/ratings/{rating} {
      // Users can only write ratings with their user ID
      allow read;
      allow write: if request.auth != null
                   && request.auth.uid == request.resource.data.userId;
    }

    match /restaurants/{any} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

ルール内の request 変数は、すべてのルールで使用できるグローバル変数です。追加した条件により、ユーザーが操作を行う前にリクエストが認証されるようになります。これにより、認証されていないユーザーが Firestore API を使用してデータに不正な変更を加えることを防ぐことができます。これは良いスタートですが、Firestore ルールを使用すると、さらに強力な処理を行うことができます。

レビューのユーザー ID が認証済みユーザーの ID と一致するように、レビューの書き込みを制限したいと考えています。これにより、ユーザーが互いに成りすまして不正なレビューを投稿することを防ぐことができます。

最初の match ステートメントは、restaurants コレクションに属するドキュメントの ratings という名前のサブコレクションに一致します。allow write 条件により、レビューのユーザー ID がユーザーの ID と一致しない場合、レビューの送信が禁止されます。2 番目の match ステートメントでは、認証済みのユーザーがデータベースに対してレストランの読み取りと書き込みを行うことを許可しています。

これはレビューに非常に有効です。セキュリティ ルールを使用して、アプリに以前に書き込んだ暗黙的な保証(ユーザーは自分のレビューのみを書き込むことができる)を明示的に記述したためです。レビューの編集機能や削除機能を追加する場合、この同じルールセットによって、ユーザーが他のユーザーのレビューを編集したり削除したりすることも防ぐことができます。Firestore ルールは、ドキュメント全体ではなく、ドキュメント内の個々のフィールドへの書き込みを制限するために、よりきめ細かい方法で使用することもできます。これを利用して、ユーザーがレストランの評価、平均評価、評価数のみを更新できるようにし、悪意のあるユーザーがレストランの名前や場所を変更する可能性を排除できます。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /restaurants/{restaurant} {
      match /ratings/{rating} {
        allow read: if request.auth != null;
        allow write: if request.auth != null
                     && request.auth.uid == request.resource.data.userId;
      }

      allow read: if request.auth != null;
      allow create: if request.auth != null;
      allow update: if request.auth != null
                    && request.resource.data.name == resource.data.name
                    && request.resource.data.city == resource.data.city
                    && request.resource.data.price == resource.data.price
                    && request.resource.data.category == resource.data.category;
    }
  }
}

ここでは、書き込み権限を create と update に分割して、許可するオペレーションをより具体的に指定できるようにしています。どのユーザーもレストランをデータベースに書き込むことができます。これにより、この Codelab の冒頭で作成した [Populate] ボタンの機能が維持されます。ただし、レストランが書き込まれると、その名前、場所、価格、カテゴリは変更できなくなります。具体的には、最後のルールでは、レストランの更新オペレーションで、データベース内の既存のフィールドの名前、都市、価格、カテゴリを同じに保つことが求められます。

セキュリティ ルールでできることについて詳しくは、ドキュメントをご覧ください。

9. まとめ

この Codelab では、Firestore での基本的な読み書きと高度な読み書きを実行する方法と、セキュリティ ルールを使用してデータアクセスを保護する方法を学びました。完全な解答コードは、codelab-complete ブランチにあります。

Firestore の詳細については、以下のリソースをご覧ください。