Cloud Firestore のクエリを使用すると、大規模なコレクション内のドキュメントを見つけることができます。コレクションのプロパティ全体を確認するには、コレクションのデータを集計します。
データは読み取り時または書き込み時に集計できます。
読み取り時の集計では、リクエスト時に結果が計算されます。Cloud Firestore は、読み取り時に
count()
、sum()
、average()
の集計クエリをサポートしています。読み取り時の集計クエリは、書き込み時の集計よりもアプリへの追加が簡単です。集計クエリの詳細については、集計クエリでデータを要約するをご覧ください。書き込み時の集計は、関連する書き込みオペレーションをアプリが実行するたびに結果を計算します。書き込み時の集計は実装がより複雑ですが、次のいずれかの理由で、読み取り時の集計の代わりに使用できます。
- 集計結果をリッスンして、リアルタイムで更新する必要がある。
count()
、sum()
、average()
の各集計クエリが、リアルタイム更新をサポートしていない。 - 集計結果をクライアントサイドのキャッシュに保存する必要がある。
count()
、sum()
、average()
集約クエリは、キャッシュ保存をサポートしていません。 - ユーザーごとに数万のドキュメントからデータを集約し、費用を考慮している。ドキュメント数が少ないほど、読み取り時の集計の費用は低くなります。集計に含まれるドキュメント数が多い場合は、書き込み時の集計のほうが費用が低くなる可能性があります。
- 集計結果をリッスンして、リアルタイムで更新する必要がある。
書き込み時の集計は、クライアントサイドのトランザクションまたは Cloud Functions を使用して実装できます。次のセクションでは、書き込み時の集計を実装する方法について説明します。
解決方法: クライアントサイドのトランザクションを使用した書き込み時の集計
人気のレストランを検索できるローカルのおすすめアプリについて考えてみましょう。次のクエリは、特定のレストランの評価をすべて取得します。
ウェブ
db.collection("restaurants") .doc("arinell-pizza") .collection("ratings") .get();
Swift
do { let snapshot = try await db.collection("restaurants") .document("arinell-pizza") .collection("ratings") .getDocuments() print(snapshot) } catch { print(error) }
Objective-C
FIRQuery *query = [[[self.db collectionWithPath:@"restaurants"] documentWithPath:@"arinell-pizza"] collectionWithPath:@"ratings"]; [query getDocumentsWithCompletion:^(FIRQuerySnapshot * _Nullable snapshot, NSError * _Nullable error) { // ... }];
Kotlin
db.collection("restaurants") .document("arinell-pizza") .collection("ratings") .get()
Java
db.collection("restaurants") .document("arinell-pizza") .collection("ratings") .get();
すべての評価を取得して集約するのではなく、この情報をレストランのドキュメントに保存します。
ウェブ
var arinellDoc = { name: 'Arinell Pizza', avgRating: 4.65, numRatings: 683 };
Swift
struct Restaurant { let name: String let avgRating: Float let numRatings: Int } let arinell = Restaurant(name: "Arinell Pizza", avgRating: 4.65, numRatings: 683)
Objective-C
@interface FIRRestaurant : NSObject @property (nonatomic, readonly) NSString *name; @property (nonatomic, readonly) float averageRating; @property (nonatomic, readonly) NSInteger ratingCount; - (instancetype)initWithName:(NSString *)name averageRating:(float)averageRating ratingCount:(NSInteger)ratingCount; @end @implementation FIRRestaurant - (instancetype)initWithName:(NSString *)name averageRating:(float)averageRating ratingCount:(NSInteger)ratingCount { self = [super init]; if (self != nil) { _name = name; _averageRating = averageRating; _ratingCount = ratingCount; } return self; } @end
Kotlin
data class Restaurant( // default values required for use with "toObject" internal var name: String = "", internal var avgRating: Double = 0.0, internal var numRatings: Int = 0, )
val arinell = Restaurant("Arinell Pizza", 4.65, 683)
Java
public class Restaurant { String name; double avgRating; int numRatings; public Restaurant(String name, double avgRating, int numRatings) { this.name = name; this.avgRating = avgRating; this.numRatings = numRatings; } }
Restaurant arinell = new Restaurant("Arinell Pizza", 4.65, 683);
集約の一貫性を保つため、新しい評価がサブコレクションに追加されるたびに更新を行う必要があります。整合性を保つ方法の一つは、単一のトランザクションで追加と更新の両方を行うことです。
ウェブ
function addRating(restaurantRef, rating) { // Create a reference for a new rating, for use inside the transaction var ratingRef = restaurantRef.collection('ratings').doc(); // In a transaction, add the new rating and update the aggregate totals return db.runTransaction((transaction) => { return transaction.get(restaurantRef).then((res) => { if (!res.exists) { throw "Document does not exist!"; } // Compute new number of ratings var newNumRatings = res.data().numRatings + 1; // Compute new average rating var oldRatingTotal = res.data().avgRating * res.data().numRatings; var newAvgRating = (oldRatingTotal + rating) / newNumRatings; // Commit to Firestore transaction.update(restaurantRef, { numRatings: newNumRatings, avgRating: newAvgRating }); transaction.set(ratingRef, { rating: rating }); }); }); }
Swift
func addRatingTransaction(restaurantRef: DocumentReference, rating: Float) async { let ratingRef: DocumentReference = restaurantRef.collection("ratings").document() do { let _ = try await db.runTransaction({ (transaction, errorPointer) -> Any? in do { let restaurantDocument = try transaction.getDocument(restaurantRef).data() guard var restaurantData = restaurantDocument else { return nil } // Compute new number of ratings let numRatings = restaurantData["numRatings"] as! Int let newNumRatings = numRatings + 1 // Compute new average rating let avgRating = restaurantData["avgRating"] as! Float let oldRatingTotal = avgRating * Float(numRatings) let newAvgRating = (oldRatingTotal + rating) / Float(newNumRatings) // Set new restaurant info restaurantData["numRatings"] = newNumRatings restaurantData["avgRating"] = newAvgRating // Commit to Firestore transaction.setData(restaurantData, forDocument: restaurantRef) transaction.setData(["rating": rating], forDocument: ratingRef) } catch { // Error getting restaurant data // ... } return nil }) } catch { // ... } }
Objective-C
- (void)addRatingTransactionWithRestaurantReference:(FIRDocumentReference *)restaurant rating:(float)rating { FIRDocumentReference *ratingReference = [[restaurant collectionWithPath:@"ratings"] documentWithAutoID]; [self.db runTransactionWithBlock:^id (FIRTransaction *transaction, NSError **errorPointer) { FIRDocumentSnapshot *restaurantSnapshot = [transaction getDocument:restaurant error:errorPointer]; if (restaurantSnapshot == nil) { return nil; } NSMutableDictionary *restaurantData = [restaurantSnapshot.data mutableCopy]; if (restaurantData == nil) { return nil; } // Compute new number of ratings NSInteger ratingCount = [restaurantData[@"numRatings"] integerValue]; NSInteger newRatingCount = ratingCount + 1; // Compute new average rating float averageRating = [restaurantData[@"avgRating"] floatValue]; float newAverageRating = (averageRating * ratingCount + rating) / newRatingCount; // Set new restaurant info restaurantData[@"numRatings"] = @(newRatingCount); restaurantData[@"avgRating"] = @(newAverageRating); // Commit to Firestore [transaction setData:restaurantData forDocument:restaurant]; [transaction setData:@{@"rating": @(rating)} forDocument:ratingReference]; return nil; } completion:^(id _Nullable result, NSError * _Nullable error) { // ... }]; }
Kotlin
private fun addRating(restaurantRef: DocumentReference, rating: Float): Task<Void> { // Create reference for new rating, for use inside the transaction val ratingRef = restaurantRef.collection("ratings").document() // In a transaction, add the new rating and update the aggregate totals return db.runTransaction { transaction -> val restaurant = transaction.get(restaurantRef).toObject<Restaurant>()!! // Compute new number of ratings val newNumRatings = restaurant.numRatings + 1 // Compute new average rating val oldRatingTotal = restaurant.avgRating * restaurant.numRatings val newAvgRating = (oldRatingTotal + rating) / newNumRatings // Set new restaurant info restaurant.numRatings = newNumRatings restaurant.avgRating = newAvgRating // Update restaurant transaction.set(restaurantRef, restaurant) // Update rating val data = hashMapOf<String, Any>( "rating" to rating, ) transaction.set(ratingRef, data, SetOptions.merge()) null } }
Java
private Task<Void> addRating(final DocumentReference restaurantRef, final float rating) { // Create reference for new rating, for use inside the transaction final DocumentReference ratingRef = restaurantRef.collection("ratings").document(); // In a transaction, add the new rating and update the aggregate totals return db.runTransaction(new Transaction.Function<Void>() { @Override public Void apply(@NonNull Transaction transaction) throws FirebaseFirestoreException { Restaurant restaurant = transaction.get(restaurantRef).toObject(Restaurant.class); // Compute new number of ratings int newNumRatings = restaurant.numRatings + 1; // Compute new average rating double oldRatingTotal = restaurant.avgRating * restaurant.numRatings; double newAvgRating = (oldRatingTotal + rating) / newNumRatings; // Set new restaurant info restaurant.numRatings = newNumRatings; restaurant.avgRating = newAvgRating; // Update restaurant transaction.set(restaurantRef, restaurant); // Update rating Map<String, Object> data = new HashMap<>(); data.put("rating", rating); transaction.set(ratingRef, data, SetOptions.merge()); return null; } }); }
トランザクションを使用すると、集約データとコレクションの整合性が維持されます。Cloud Firestore でのトランザクションの詳細については、トランザクションとバッチ書き込みをご覧ください。
制限事項
上記の解決方法では、Cloud Firestore クライアント ライブラリを使用してデータを集約していますが、次の制限事項があることに注意してください。
- セキュリティ - クライアントサイドでトランザクションを実行するには、データベースで集約データを更新する権限をクライアントに付与する必要があります。高度なセキュリティ ルールを作成することでリスクを軽減できますが、この方法が適切でない場合もあります。
- オフライン サポート - ユーザーのデバイスがオフラインの場合、クライアントサイドのトランザクションは失敗します。アプリでこのケースに対応し、適切なタイミングで再試行を行う必要があります。
- パフォーマンス - トランザクションで複数の読み取り、書き込み、更新オペレーションが実行される場合、Cloud Firestore バックエンドに対して複数のリクエストが必要になる可能性があります。モバイル デバイスの場合、処理に時間がかかることがあります。
- 書き込み速度 - Cloud Firestore ドキュメントは 1 秒間に 1 回しか更新できないため、頻繁に更新される集計では、この解決方法が機能しない場合があります。また、トランザクション外部で変更されたドキュメントをトランザクションが読み取ると、一定の回数で再試行して失敗します。高頻度の更新が必要な集計に対する回避策については、分散カウンタをご覧ください。
解決方法: Cloud Functions を使用した書き込み時の集計
クライアントサイドのトランザクションがアプリケーションに適していない場合、Cloud Functions を利用することでレストランに新しい評価が追加されるたびに集約情報を更新できます。
Node.js
exports.aggregateRatings = functions.firestore .document('restaurants/{restId}/ratings/{ratingId}') .onWrite(async (change, context) => { // Get value of the newly added rating const ratingVal = change.after.data().rating; // Get a reference to the restaurant const restRef = db.collection('restaurants').doc(context.params.restId); // Update aggregations in a transaction await db.runTransaction(async (transaction) => { const restDoc = await transaction.get(restRef); // Compute new number of ratings const newNumRatings = restDoc.data().numRatings + 1; // Compute new average rating const oldRatingTotal = restDoc.data().avgRating * restDoc.data().numRatings; const newAvgRating = (oldRatingTotal + ratingVal) / newNumRatings; // Update restaurant info transaction.update(restRef, { avgRating: newAvgRating, numRatings: newNumRatings }); }); });
この解決方法の場合、ホスト側の関数に作業がオフロードされます。つまり、モバイルアプリが評価を追加する際にトランザクションの完了を待つ必要がなくなります。Cloud Functions の関数で実行されるコードにはセキュリティ ルールが適用されないため、集約データへの書き込みアクセス権をクライアントに与える必要はありません。
制限事項
集約に Cloud Functions の関数を使用すると、クライアントサイドでトランザクションを実行する場合の問題がいくつか解消されますが、いくつかの制限事項が存在します。
- コスト - 評価が追加されるたびに Cloud Functions の関数が呼び出されるため、コストが増加する可能性があります。詳細については、Cloud Functions の料金体系をご覧ください。
- レイテンシ - 集約作業を Cloud Functions の関数にオフロードするため、Cloud Functions の関数の実行が完了し、クライアントに新しいデータが通知されるまで、更新されたデータは表示されません。Cloud Functions の処理速度によっては、トランザクションをローカルで実行する場合よりも時間がかかることがあります。
- 書き込み速度 - Cloud Firestore ドキュメントは 1 秒間に 1 回しか更新できないため、頻繁に更新される集計では、この解決方法が機能しない場合があります。また、トランザクション外部で変更されたドキュメントをトランザクションが読み取ると、一定の回数で再試行して失敗します。高頻度の更新が必要な集計に対する回避策については、分散カウンタをご覧ください。