Agregaciones en tiempo de escritura

Las consultas en Cloud Firestore te permiten buscar documentos en grandes colecciones. Para obtener información sobre las propiedades de la colección en su conjunto, puede agregar datos sobre una colección.

Puede agregar datos en tiempo de lectura o en tiempo de escritura:

  • Las agregaciones en tiempo de lectura calculan un resultado en el momento de la solicitud. Cloud Firestore admite consultas de agregación count() , sum() y average() en tiempo de lectura. Las consultas de agregación en tiempo de lectura son más fáciles de agregar a su aplicación que las agregaciones en tiempo de escritura. Para obtener más información sobre consultas de agregación, consulte Resumir datos con consultas de agregación .

  • Las agregaciones de tiempo de escritura calculan un resultado cada vez que la aplicación realiza una operación de escritura relevante. Las agregaciones en tiempo de escritura requieren más trabajo de implementar, pero puede usarlas en lugar de agregaciones en tiempo de lectura por una de las siguientes razones:

    • Quiere escuchar el resultado de la agregación para obtener actualizaciones en tiempo real. Las consultas de agregación count() , sum() y average() no admiten actualizaciones en tiempo real.
    • Quiere almacenar el resultado de la agregación en una caché del lado del cliente. Las consultas de agregación count() , sum() y average() no admiten el almacenamiento en caché.
    • Está agregando datos de decenas de miles de documentos para cada uno de sus usuarios y considerando los costos. Con una cantidad menor de documentos, las agregaciones en tiempo de lectura cuestan menos. Para una gran cantidad de documentos en una agregación, las agregaciones en tiempo de escritura pueden costar menos.

Puede implementar una agregación en tiempo de escritura mediante una transacción del lado del cliente o con Cloud Functions. Las siguientes secciones describen cómo implementar agregaciones en tiempo de escritura.

Solución: agregación en tiempo de escritura con una transacción del lado del cliente

Considere una aplicación de recomendaciones locales que ayude a los usuarios a encontrar excelentes restaurantes. La siguiente consulta recupera todas las calificaciones de un restaurante determinado:

Web

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

Rápido

Nota: Este producto no está disponible en destinos watchOS y App Clip.
do {
  let snapshot = try await db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .getDocuments()
  print(snapshot)
} catch {
  print(error)
}

C objetivo

Nota: Este producto no está disponible en destinos watchOS y 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();

En lugar de obtener todas las calificaciones y luego calcular la información agregada, podemos almacenar esta información en el documento del restaurante:

Web

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

Rápido

Nota: Este producto no está disponible en destinos watchOS y App Clip.
struct Restaurant {

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

}

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

C objetivo

Nota: Este producto no está disponible en destinos watchOS y 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);

Para mantener la coherencia de estas agregaciones, se deben actualizar cada vez que se agrega una nueva calificación a la subcolección. Una forma de lograr coherencia es realizar la adición y la actualización en una sola transacción:

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

Rápido

Nota: Este producto no está disponible en destinos watchOS y 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 {
    // ...
  }
}

C objetivo

Nota: Este producto no está disponible en destinos watchOS y 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;
        }
    });
}

El uso de una transacción mantiene sus datos agregados consistentes con la colección subyacente. Para leer más sobre las transacciones en Cloud Firestore, consulte Transacciones y escrituras por lotes .

Limitaciones

La solución que se muestra arriba demuestra cómo agregar datos mediante la biblioteca cliente de Cloud Firestore, pero debes tener en cuenta las siguientes limitaciones:

  • Seguridad : las transacciones del lado del cliente requieren otorgar permiso a los clientes para actualizar los datos agregados en su base de datos. Si bien se pueden reducir los riesgos de este enfoque escribiendo reglas de seguridad avanzadas, es posible que esto no sea apropiado en todas las situaciones.
  • Soporte sin conexión : las transacciones del lado del cliente fallarán cuando el dispositivo del usuario esté desconectado, lo que significa que debe manejar este caso en su aplicación y volver a intentarlo en el momento apropiado.
  • Rendimiento : si su transacción contiene múltiples operaciones de lectura, escritura y actualización, podría requerir múltiples solicitudes al backend de Cloud Firestore. En un dispositivo móvil, esto podría llevar mucho tiempo.
  • Tasas de escritura : es posible que esta solución no funcione para agregaciones que se actualizan con frecuencia porque los documentos de Cloud Firestore solo se pueden actualizar como máximo una vez por segundo. Además, si una transacción lee un documento que se modificó fuera de la transacción, lo vuelve a intentar un número finito de veces y luego falla. Consulte los contadores distribuidos para encontrar una solución alternativa relevante para las agregaciones que necesitan actualizaciones más frecuentes.

Solución: agregación en tiempo de escritura con Cloud Functions

Si las transacciones del lado del cliente no son adecuadas para su aplicación, puede utilizar una función en la nube para actualizar la información agregada cada vez que se agrega una nueva calificación a un restaurante:

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

Esta solución descarga el trabajo del cliente a una función alojada, lo que significa que su aplicación móvil puede agregar calificaciones sin esperar a que se complete una transacción. El código ejecutado en una función de nube no está sujeto a reglas de seguridad, lo que significa que ya no es necesario otorgar a los clientes acceso de escritura a los datos agregados.

Limitaciones

El uso de una función en la nube para agregaciones evita algunos de los problemas con las transacciones del lado del cliente, pero conlleva un conjunto diferente de limitaciones:

  • Costo : cada calificación agregada provocará una invocación de la función de nube, lo que puede aumentar sus costos. Para obtener más información, consulte la página de precios de Cloud Functions.
  • Latencia : al descargar el trabajo de agregación a una función de nube, su aplicación no verá datos actualizados hasta que la función de nube haya terminado de ejecutarse y se haya notificado al cliente sobre los nuevos datos. Dependiendo de la velocidad de su función en la nube, esto podría llevar más tiempo que ejecutar la transacción localmente.
  • Tasas de escritura : es posible que esta solución no funcione para agregaciones que se actualizan con frecuencia porque los documentos de Cloud Firestore solo se pueden actualizar como máximo una vez por segundo. Además, si una transacción lee un documento que se modificó fuera de la transacción, lo vuelve a intentar un número finito de veces y luego falla. Consulte los contadores distribuidos para encontrar una solución alternativa relevante para las agregaciones que necesitan actualizaciones más frecuentes.