Agrégations au moment de l'écriture

Les requêtes dans Cloud Firestore vous permettent de rechercher des documents dans de grandes collections. Pour obtenir un aperçu des propriétés de la collection dans son ensemble, vous pouvez regrouper les données sur une collection.

Vous pouvez agréger les données soit au moment de la lecture, soit au moment de l'écriture :

  • Les agrégations de temps de lecture calculent un résultat au moment de la requête. Cloud Firestore prend en charge les requêtes d'agrégation count() , sum() et average() au moment de la lecture. Les requêtes d'agrégation en temps de lecture sont plus faciles à ajouter à votre application que les agrégations en temps d'écriture. Pour en savoir plus sur les requêtes d'agrégation, consultez Résumer les données avec des requêtes d'agrégation .

  • Les agrégations de temps 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 nécessitent plus de travail à mettre en œuvre, 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 des mises à jour en temps réel. Les requêtes d'agrégation count() , sum() et average() ne prennent pas en charge 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() et average() ne prennent pas en charge la mise en cache.
    • Vous regroupez les données de dizaines de milliers de documents pour chacun de vos utilisateurs et tenez compte des coûts. Avec un nombre de documents inférieur, les agrégations de temps de lecture coûtent moins cher. Pour un grand nombre de documents dans une agrégation, les agrégations au moment de l'écriture peuvent coûter moins cher.

Vous pouvez implémenter une agrégation au moment de l'écriture à l'aide d'une transaction côté client ou avec Cloud Functions. Les sections suivantes décrivent 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

Envisagez une application de recommandations locales qui aide les utilisateurs à trouver d'excellents restaurants. La requête suivante récupère toutes les notes d'un restaurant donné :

la toile

db.collection("restaurants")
  .doc("arinell-pizza")
  .collection("ratings")
  .get();

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
do {
  let snapshot = try await db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .getDocuments()
  print(snapshot)
} catch {
  print(error)
}

Objectif c

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
FIRQuery *query = [[[self.db collectionWithPath:@"restaurants"]
    documentWithPath:@"arinell-pizza"] collectionWithPath:@"ratings"];
[query getDocumentsWithCompletion:^(FIRQuerySnapshot * _Nullable snapshot,
                                    NSError * _Nullable error) {
  // ...
}];

Kotlin+KTX

db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .get()

Java

db.collection("restaurants")
        .document("arinell-pizza")
        .collection("ratings")
        .get();

Plutôt que de récupérer toutes les notes puis de calculer des informations globales, nous pouvons stocker ces informations dans le document du restaurant lui-même :

la toile

var arinellDoc = {
  name: 'Arinell Pizza',
  avgRating: 4.65,
  numRatings: 683
};

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
struct Restaurant {

  let name: String
  let avgRating: Float
  let numRatings: Int

}

let arinell = Restaurant(name: "Arinell Pizza", avgRating: 4.65, numRatings: 683)

Objectif c

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
@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

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

Afin de maintenir la cohérence de ces agrégations, elles doivent être mises à jour chaque fois qu'une nouvelle note est ajoutée à la sous-collection. Une façon d'assurer la cohérence consiste à effectuer l'ajout et la mise à jour en une seule transaction :

la toile

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

Rapide

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
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 {
    // ...
  }
}

Objectif c

Remarque : ce produit n'est pas disponible sur les cibles watchOS et App Clip.
- (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

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

L’utilisation d’une transaction maintient vos données globales cohérentes avec la collection sous-jacente. Pour en savoir plus sur les transactions dans Cloud Firestore, consultez Transactions et écritures par lots .

Limites

La solution présentée ci-dessus illustre l'agrégation de données à l'aide de la bibliothèque client Cloud Firestore, mais vous devez être conscient des limitations suivantes :

  • Sécurité - Les transactions côté client nécessitent d'autoriser les clients à mettre à jour les données agrégées de votre base de données. Bien que vous puissiez réduire les risques de cette approche en écrivant des règles de sécurité avancées, cela peut ne pas être approprié dans toutes les situations.
  • Prise en charge hors ligne : les transactions côté client échoueront lorsque l'appareil de l'utilisateur est hors ligne, ce qui signifie que vous devez gérer ce cas dans votre application et réessayer au moment approprié.
  • Performances : si votre transaction contient plusieurs opérations de lecture, d'écriture et de mise à jour, elle peut nécessiter plusieurs requêtes auprès du backend Cloud Firestore. Sur un appareil mobile, cela peut prendre beaucoup de 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éessaye un nombre fini de fois , puis échoue. Consultez les compteurs distribués pour une solution de contournement pertinente pour les agrégations qui nécessitent des mises à jour plus fréquentes.

Solution : Agrégation au moment de l'écriture avec Cloud Functions

Si les transactions côté client ne conviennent pas à 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 :

Noeud.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 la fin d'une transaction. Le code exécuté dans une fonction Cloud n'est pas lié par des règles de sécurité, ce qui signifie que vous n'avez plus besoin de donner aux clients un accès en écriture aux données agrégées.

Limites

L'utilisation d'une fonction Cloud pour les agrégations évite certains problèmes liés aux transactions côté client, mais s'accompagne d'un ensemble de limitations différentes :

  • Coût : chaque note ajoutée entraînera un appel de fonction Cloud, ce qui peut augmenter vos coûts. Pour plus d'informations, consultez la page de tarification de Cloud Functions.
  • Latence - En déchargeant le travail d'agrégation vers une fonction Cloud, votre application ne verra pas les données mises à jour tant que la fonction Cloud n'aura pas terminé son exécution et que le client n'aura pas été informé des nouvelles données. En fonction de la vitesse de votre fonction Cloud, cela peut prendre plus de temps que l'exécution de la transaction localement.
  • 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éessaye un nombre fini de fois , puis échoue. Consultez les compteurs distribués pour une solution de contournement pertinente pour les agrégations qui nécessitent des mises à jour plus fréquentes.