1。概要
目標
このコードラボでは、SwiftのiOSでFirestoreが支援するレストランおすすめアプリを作成します。次の方法を学習します:
- iOSアプリからFirestoreへのデータの読み取りと書き込み
- Firestoreデータの変更をリアルタイムで聞く
- Firebase認証とセキュリティルールを使用してFirestoreデータを保護する
- 複雑なFirestoreクエリを作成する
前提条件
このコードラボを開始する前に、次のものがインストールされていることを確認してください。
- Xcodeバージョン13.0(またはそれ以降)
- CocoaPods 1.11.0(またはそれ以降)
2.Firebaseコンソールプロジェクトを作成します
プロジェクトにFirebaseを追加する
- Firebaseコンソールに移動します。
- [新しいプロジェクトの作成]を選択し、プロジェクトに「FirestoreiOSCodelab」という名前を付けます。
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)
上記のコードは、レストランコレクションに新しいドキュメントを追加します。ドキュメントデータは、Restaurant構造体から取得した辞書から取得されます。
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データ]タブに移動します。これで、restaurantsコレクションに新しいエントリが表示されます。
おめでとうございます。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(_:)
呼び出しは、サーバー上のデータが変更されるたびにViewControllerを更新するスナップショットリスナーをクエリに追加します。更新は自動的に取得され、手動で変更をプッシュする必要はありません。このスナップショットリスナーは、サーバー側の変更の結果としていつでも呼び出すことができるため、アプリが変更を処理できることが重要です。
辞書を構造体にマッピングした後( Restaurant.swift
を参照)、データを表示するには、いくつかのビュープロパティを割り当てるだけです。 RestaurantsTableViewController.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)
このpopulateメソッドは、テーブルビューデータソースのtableView(_:cellForRowAtIndexPath:)
メソッドから呼び出されます。このメソッドは、以前の値型のコレクションを個々のテーブルビューセルにマッピングします。
アプリを再度実行し、コンソールで以前に見たレストランがシミュレーターまたはデバイスに表示されることを確認します。このセクションを正常に完了すると、アプリはCloudFirestoreを使用してデータの読み取りと書き込みを行うようになります。
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/testapp-5d356/database/firestore/indexes?create_index=..." UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_index=...}
これは、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セキュリティルールを使用してデータを保護する方法を学習します。
まず、コードラボの冒頭で作成したセキュリティルールを詳しく見ていきましょう。 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
変数は、すべてのルールで使用できるグローバル変数であり、追加した条件は、ユーザーが何かを実行できるようにする前に、リクエストが認証されることを保証します。これにより、認証されていないユーザーがFirestoreAPIを使用してデータに不正な変更を加えることを防ぎます。これは良いスタートですが、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
conditionは、レビューのユーザー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;
}
}
}
ここでは、書き込み権限を作成と更新に分割して、許可する操作をより具体的にできるようにしました。コードラボの開始時に作成した[入力]ボタンの機能を維持したまま、すべてのユーザーがデータベースにレストランを書き込むことができますが、レストランが書き込まれると、その名前、場所、価格、およびカテゴリを変更することはできません。より具体的には、最後のルールでは、データベース内の既存のフィールドと同じ名前、都市、価格、およびカテゴリを維持するために、レストランの更新操作が必要です。
セキュリティルールで何ができるかについて詳しくは、ドキュメントをご覧ください。
9.結論
このコードラボでは、Firestoreを使用した基本的および高度な読み取りと書き込みの方法、およびセキュリティルールを使用してデータアクセスを保護する方法を学習しました。完全なソリューションは、 codelab-complete
ブランチにあります。
Firestoreの詳細については、次のリソースにアクセスしてください。