Cloud Firestore iOS コードラボ

1。概要

目標

このコードラボでは、iOS 上の Firestore を利用したレストランおすすめアプリを Swift で構築します。次の方法を学習します:

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

前提条件

このコードラボを開始する前に、以下がインストールされていることを確認してください。

  • Xcode バージョン 14.0 (またはそれ以降)
  • CocoaPods 1.12.0 (またはそれ以降)

2. Firebaseコンソールプロジェクトを作成する

Firebaseをプロジェクトに追加する

  1. Firebase コンソールに移動します。
  2. [新しいプロジェクトの作成]を選択し、プロジェクトに「Firestore iOS Codelab」という名前を付けます。

3. サンプルプロジェクトを入手する

コードをダウンロードする

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

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

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

Firebaseをセットアップする

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

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)

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

もうすぐそこです。Firestore にドキュメントを書き込む前に、Firestore のセキュリティ ルールを開き、データベースのどの部分をどのユーザーが書き込み可能にするかを記述する必要があります。現時点では、認証されたユーザーのみにデータベース全体の読み取りと書き込みを許可します。これは実稼働アプリにとっては少し寛容すぎますが、アプリの構築プロセスでは、実験中に認証の問題が常に発生しないように、十分に緩和されたものが必要です。このコードラボの最後では、セキュリティ ルールを強化し、意図しない読み取りと書き込みの可能性を制限する方法について説明します。

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

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

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

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

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

スクリーン ショット 2017 年 7 月 6 日午後 12 時 45 分 38 秒.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)

この Populate メソッドは、テーブル ビュー データ ソースの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)
}

上記のスニペットでは、複数のwhereFieldorder句を追加して、ユーザー入力に基づいて単一の複合クエリを構築します。これで、クエリはユーザーの要件に一致するレストランのみを返すようになります。

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

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 を大規模に高速に保つことができます。エラー メッセージからリンクを開くと、正しいパラメータが入力されたインデックス作成 UI が Firebase コンソールで自動的に開きます。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 セキュリティ ルールを使用してデータを保護する方法を学びます。

まず、コードラボの開始時に作成したセキュリティ ルールを詳しく見てみましょう。 Firebase コンソールを開き、 [Firestore] タブの [データベース] > [ルール]に移動します。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

上記のルールのrequest変数は、すべてのルールで使用できるグローバル変数であり、追加した条件により、ユーザーに何かを許可する前にリクエストが認証されることが保証されます。これにより、認証されていないユーザーが Firestore API を使用してデータに不正な変更を加えることができなくなります。これは良いスタートですが、Firestore ルールを使用すると、さらに強力なことを行うことができます。

レビューのユーザー ID が認証されたユーザーの ID と一致するように、レビューの書き込みを制限しましょう。これにより、ユーザーが互いになりすまして不正なレビューを残すことができなくなります。セキュリティ ルールを次のものに置き換えます。

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;
    }
  }
}

最初の 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;
    }
  }
}

ここでは、どの操作を許可するかをより具体的にできるように、書き込み権限を作成と更新に分割しました。コードラボの開始時に作成した Populate ボタンの機能を維持しながら、どのユーザーもデータベースにレストランを書き込むことができますが、レストランを書き込むと、その名前、場所、価格、カテゴリを変更することはできません。より具体的には、最後のルールでは、レストランの更新操作で、データベース内の既存のフィールドと同じ名前、都市、価格、およびカテゴリを維持する必要があります。

セキュリティ ルールで何ができるかについて詳しくは、ドキュメントを参照してください。

9. 結論

このコードラボでは、Firestore を使用した基本的および高度な読み取りと書き込みの方法、およびセキュリティ ルールを使用してデータ アクセスを保護する方法を学びました。完全なソリューションはcodelab-completeブランチで見つけることができます。

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