Agrégations au moment de l'écriture

Les requêtes dans Firestore permettent de trouver des documents dans de grandes collections. Pour mieux comprendre les propriétés de la collection dans son ensemble, vous pouvez agréger les données d'une collection.

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

  • Les agrégations en lecture seule calculent un résultat au moment de la requête. Firestore accepte les requêtes d'agrégation count(), sum() et average() au moment de la lecture. Les requêtes d'agrégation en lecture seule 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 au moment de l'écriture calculent un résultat chaque fois que l'application effectue une opération d'écriture pertinente. Les agrégations en écriture nécessitent davantage de travail, mais vous pouvez les utiliser à la place des agrégations en temps de lecture pour l'une des raisons suivantes:

    • Vous souhaitez écouter le résultat de l'agrégation pour obtenir des informations en temps réel. Les requêtes d'agrégation count(), sum() et average() 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() et average() ne sont pas compatibles avec la mise en cache.
    • Vous agrégez des données provenant de dizaines de milliers de documents pour chacun de vos utilisateurs et vous tenez compte des coûts. Pour un nombre de documents inférieur, les agrégations en 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 mettre en œuvre une agrégation de temps d'é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

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

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

Objective-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
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

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)

Objective-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
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

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 {
    // ...
  }
}

Objective-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
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 en temps 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.