Agrégations au moment de l'écriture
Les requêtes dans Firestore vous permettent de trouver des documents dans de grandes collections. Pour mieux comprendre les propriétés collection dans son ensemble, vous pouvez agréger les données d'une collection.
Vous pouvez agréger des données au moment de la lecture ou de l'écriture :
Les agrégations de temps de lecture calculent un résultat au moment de la requête. Firestore accepte les requêtes d'agrégation
count()
,sum()
etaverage()
au moment de la lecture. Les requêtes d'agrégation en lecture sont plus simples à ajouter à votre application que les agrégations en temps d'écriture. Pour en savoir plus sur les requêtes d'agrégation, consultez Récapituler les données à l'aide de requêtes d'agrégation.Les agrégations au moment d'écriture calculent un résultat chaque fois que l'application effectue une opération d'écriture pertinente. Les agrégations au moment de l'écriture sont plus difficiles à implémenter, mais vous pouvez les utiliser à la place des agrégations au moment de la lecture pour l'une des raisons suivantes :
- Vous souhaitez écouter le résultat de l'agrégation pour obtenir des mises à jour en temps réel.
Les requêtes d'agrégation
count()
,sum()
etaverage()
ne sont pas compatibles avec les mises à jour en temps réel. - Vous souhaitez stocker le résultat de l'agrégation dans un cache côté client.
Les requêtes d'agrégation
count()
,sum()
etaverage()
ne sont pas compatibles avec le cache. - Vous agrégez les données de dizaines de milliers de documents pour chacun de vos utilisateurs et vous tenez compte des coûts. À un nombre inférieur de documents, les agrégations au moment de la lecture sont moins coûteuses. Pour un grand nombre de documents dans une agrégation, les agrégations au moment de l'écriture peuvent être moins coûteuses.
- Vous souhaitez écouter le résultat de l'agrégation pour obtenir des mises à jour en temps réel.
Les requêtes d'agrégation
Vous pouvez implémenter une agrégation de temps d'écriture à l'aide d'une méthode transactionnel ou avec des fonctions Cloud Run. Les sections suivantes expliquent comment implémenter des agrégations au moment de l'écriture.
Solution : Agrégation au moment de l'écriture avec une transaction côté client
Prenons le cas d'une application de recommandations locales qui aide les utilisateurs à trouver des restaurants de qualité. La requête suivante récupère toutes les notes d'un restaurant donné :
Web
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 + KTX
Android
db.collection("restaurants") .document("arinell-pizza") .collection("ratings") .get()
Java
Android
db.collection("restaurants") .document("arinell-pizza") .collection("ratings") .get();
Plutôt que d'extraire toutes les notes et de calculer les informations globales, nous pouvons stocker ces informations dans le document du restaurant lui-même :
Web
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 + KTX
Android
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
Android
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);
Pour que ces agrégations restent cohérentes, elles doivent être mises à jour chaque fois qu'une nouvelle note est ajoutée à la sous-collection. L'une des méthodes permettant d'obtenir une cohérence consiste à effectuer l'ajout et la mise à jour dans une transaction unique :
Web
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 + KTX
Android
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
Android
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; } }); }
L'utilisation d'une transaction conserve la cohérence de vos données globales avec la collection sous-jacente. Pour en savoir plus sur les transactions dans Firestore, consultez la page Transactions et écritures par lot.
Limites
La solution présentée ci-dessus illustre l'agrégation de données à l'aide de la bibliothèque cliente Firestore, mais tenez compte des limites suivantes :
- Sécurité : les transactions côté client nécessitent d'autoriser les clients à mettre à jour les données globales de votre base de données. Bien que vous puissiez réduire les risques de cette approche en rédigeant des règles de sécurité avancées, cela peut ne pas être approprié dans toutes les situations.
- Fonctionnement hors connexion : les transactions côté client échouent lorsque l'appareil de l'utilisateur est hors connexion. Vous devez donc gérer cette situation dans votre application et réessayer au moment opportun.
- Performances : si votre transaction contient plusieurs opérations de lecture, d'écriture et de mise à jour, elle peut nécessiter plusieurs requêtes au backend Firestore. Sur un appareil mobile, cette opération peut prendre un certain temps.
- Taux d'écriture : cette solution peut ne pas fonctionner pour les agrégations fréquemment mises à jour, car les documents Cloud Firestore ne peuvent être mis à jour qu'une fois par seconde au maximum. De plus, si une transaction lit un document qui a été modifié en dehors de la transaction, elle réessaie un nombre limité de fois, puis échoue. Consultez les compteurs distribués pour identifier une solution de contournement pertinente pour les agrégations nécessitant des mises à jour plus fréquentes.
Solution: agrégation au moment d'écriture avec Cloud Functions
Si les transactions côté client ne sont pas adaptées à votre application, vous pouvez utiliser une fonction Cloud pour mettre à jour les informations globales chaque fois qu'une nouvelle note est ajoutée à un restaurant :
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 }); }); });
Cette solution décharge le travail du client vers une fonction hébergée, ce qui signifie que votre application mobile peut ajouter des notes sans attendre qu'une transaction soit effectuée. Le code exécuté dans une fonction Cloud n'est pas encadré par des règles de sécurité, ce qui signifie que vous ne devez plus accorder aux clients un accès en écriture aux données globales.
Limites
L'utilisation d'une fonction Cloud pour les agrégations permet d'éviter certains problèmes liés aux transactions côté client, mais implique d'autres limites :
- Coût : chaque note ajoutée provoque une invocation de la fonction Cloud, ce qui peut augmenter vos coûts. Pour en savoir plus, consultez la page Tarifs de Cloud Functions.
- Latence : en déchargeant le travail d'agrégation vers une fonction Cloud, les données mises à jour n'apparaîtront pas dans votre application tant que la fonction Cloud n'aura pas été exécutée et que le client n'aura pas été informé de la nouvelle donnée. En fonction de la vitesse de votre fonction Cloud, cette opération peut prendre plus de temps que l'exécution locale de la transaction.
- Taux d'écriture : cette solution peut ne pas fonctionner pour les agrégations fréquemment mises à jour, car les documents Cloud Firestore ne peuvent être mis à jour qu'une fois par seconde au maximum. De plus, si une transaction lit un document qui a été modifié en dehors de la transaction, elle réessaie un nombre limité de fois, puis échoue. Consultez les compteurs distribués pour identifier une solution de contournement pertinente pour les agrégations nécessitant des mises à jour plus fréquentes.