この Codelab について
1. 概要
目標
この Codelab では、Swift で iOS 向けの Firestore をベースにしたレストランおすすめアプリを作成します。ここでは以下について学びます。
- iOS アプリから Firestore へのデータの読み取りと書き込みを行う
- Firestore データの変更をリアルタイムでリッスンする
- Firebase Authentication とセキュリティ ルールを使用して Firestore データを保護する
- 複雑な Firestore クエリを作成する
前提条件
この Codelab を開始する前に、次がインストールされていることを確認してください。
- Xcode バージョン 14.0(以降)
- CocoaPods 1.12.0(以降)
2. Firebase コンソール プロジェクトを作成する
プロジェクトに Firebase を追加する
- Firebase コンソールに移動します。
- [Create New Project] を選択し、プロジェクトに「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 プロジェクトを作成します。プロジェクトを作成したら、Firebase コンソールからプロジェクトの GoogleService-Info.plist
ファイルをダウンロードし、Xcode プロジェクトのルートにドラッグします。プロジェクトを再度実行して、アプリが正しく構成され、起動時にクラッシュしなくなったことを確認します。ログインすると、以下の例のような空白の画面が表示されます。ログインできない場合は、Firebase コンソールの [認証] でメールアドレス/パスワードのログイン方法が有効になっていることを確認してください。
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 のセキュリティ ルールを開き、データベースのどの部分をどのユーザーが書き込めるようにするかを指定する必要があります。現時点では、認証されたユーザーのみがデータベース全体に対して読み取りと書き込みを行うことができます。これは本番環境のアプリには少し緩すぎますが、アプリの作成プロセスでは、テスト中に認証の問題に絶えず直面しないように、十分に緩和されたものが必要です。この 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 データ] タブに移動します。レストラン コレクションに新しいエントリが表示されます。
おつかれさまです。これで、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(_:)
呼び出しは、サーバーでデータが変更されるたびにビュー コントローラを更新するスナップショット リスナーをクエリに追加します。更新は自動的に取得されるため、変更を手動で push する必要はありません。このスナップショット リスナーは、サーバーサイドの変更の結果としていつでも呼び出されるため、アプリが変更を処理できるようにすることが重要です。
辞書を構造体にマッピングしたら(Restaurant.swift
を参照)、データを表示するには、いくつかのビュー プロパティを割り当てるだけです。RestaurantsTableViewController.swift
の RestaurantTableViewCell.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 でデータの読み書きを行います。
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)
}
}
}
update ブロック内では、トランザクション オブジェクトを使用して実行するすべてのオペレーションは、Firestore によって単一のアトミック更新として扱われます。サーバーで更新に失敗した場合、Firestore は自動的に数回再試行します。つまり、エラー状態は、デバイスが完全にオフラインである場合や、ユーザーが書き込みしようとしているパスへの書き込み権限がないなど、1 つのエラーが繰り返し発生している可能性が高いことを意味します。
8. セキュリティ ルール
アプリのユーザーがデータベース内のすべてのデータを読み書きできるようにすることはできません。たとえば、レストランの評価は誰でも確認できるようにする必要がありますが、評価を投稿できるのは認証済みユーザーのみにする必要があります。クライアントで優れたコードを記述するだけでは不十分です。完全に安全にするために、バックエンドでデータ セキュリティ モデルを指定する必要があります。このセクションでは、Firebase セキュリティ ルールを使用してデータを保護する方法について説明します。
まず、Codelab の冒頭で作成したセキュリティ ルールについて詳しく見てみましょう。Firebase コンソールを開き、[データベース] > [Firestore タブのルール] に移動します。
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;
}
}
}
ここでは、書き込み権限を作成と更新に分割して、許可するオペレーションをより具体的に指定しています。どのユーザーもレストランをデータベースに書き込むことができます。これにより、Codelab の開始時に作成した [Populate] ボタンの機能が維持されます。ただし、レストランを書き込んだ後は、名前、場所、価格、カテゴリを変更することはできません。具体的には、最後のルールでは、レストランの更新オペレーションで、データベース内の既存のフィールドの名前、都市、価格、カテゴリを維持することが義務付けられています。
セキュリティ ルールでできることについて詳しくは、ドキュメントをご覧ください。
9. まとめ
この Codelab では、Firestore での基本的な読み取りと高度な読み取りを行う方法と、セキュリティ ルールを使用してデータアクセスを保護する方法を学びました。完全な解答コードは codelab-complete
ブランチにあります。
Firestore について詳しくは、以下のリソースをご覧ください。