Agregasi waktu tulis

Kueri di Cloud Firestore memungkinkan Anda menemukan dokumen dalam koleksi besar. Untuk mempelajari properti koleksi secara keseluruhan, Anda dapat menggabungkan data dari koleksi.

Anda dapat menggabungkan data pada waktu baca atau pada waktu tulis:

  • Agregasi waktu baca menghitung hasil pada saat permintaan. Cloud Firestore mendukung kueri agregasi count(), sum(), dan average() pada waktu baca. Kueri agregasi waktu baca lebih mudah ditambahkan ke aplikasi Anda daripada agregasi waktu tulis. Untuk mengetahui informasi selengkapnya tentang kueri agregasi, lihat Meringkas data dengan kueri agregasi.

  • Agregasi waktu tulis menghitung hasil setiap kali aplikasi menjalankan operasi tulis yang relevan. Agregasi waktu tulis lebih sulit untuk diterapkan, tetapi Anda dapat menggunakannya bukan agregasi waktu baca untuk salah satu alasan berikut:

    • Anda ingin mengetahui hasil agregasi untuk pembaruan real-time. Kueri agregasi count(), sum(), dan average() tidak mendukung pembaruan real-time.
    • Anda ingin menyimpan hasil agregasi dalam cache sisi klien. Kueri agregasi count(), sum(), dan average() tidak mendukung cache.
    • Anda menggabungkan data dari puluhan ribu dokumen untuk setiap pengguna dan mempertimbangkan biaya. Dengan jumlah dokumen yang lebih sedikit, agregasi waktu baca lebih hemat. Untuk jumlah dokumen yang besar dalam agregasi, agregasi waktu tulis mungkin lebih hemat.

Anda dapat menerapkan agregasi waktu tulis menggunakan transaksi sisi klien atau dengan Cloud Functions. Bagian berikut menjelaskan cara menerapkan agregasi waktu tulis.

Solusi: Agregasi waktu tulis dengan transaksi sisi klien

Bayangkan sebuah aplikasi rekomendasi lokal yang dapat membantu pengguna menemukan restoran bagus. Kueri berikut mengambil semua rating untuk suatu restoran:

Web

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

Swift

Catatan: Produk ini tidak tersedia di target watchOS dan App Clip.
do {
  let snapshot = try await db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .getDocuments()
  print(snapshot)
} catch {
  print(error)
}

Objective-C

Catatan: Produk ini tidak tersedia di target watchOS dan 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();

Daripada mengambil semua rating lalu mengolah informasi agregat, kita dapat menyimpan informasi ini di dokumen restoran itu sendiri:

Web

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

Swift

Catatan: Produk ini tidak tersedia di target watchOS dan 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

Catatan: Produk ini tidak tersedia di target watchOS dan 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);

Agar konsisten, agregasi ini harus diperbarui setiap kali rating baru ditambahkan ke subkoleksi. Salah satu cara untuk mencapai konsistensi adalah dengan melakukan penambahan dan pembaruan dalam satu transaksi:

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

Catatan: Produk ini tidak tersedia di target watchOS dan 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

Catatan: Produk ini tidak tersedia di target watchOS dan 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;
        }
    });
}

Penggunaan transaksi membuat data gabungan selalu konsisten dengan koleksi yang mendasarinya. Untuk membaca informasi lebih lanjut tentang transaksi di Cloud Firestore, lihat Transaksi dan Batch Operasi Tulis.

Keterbatasan

Solusi yang ditunjukkan di atas mendemonstrasikan agregasi data menggunakan library klien Cloud Firestore, tetapi Anda harus mengetahui keterbatasan berikut:

  • Keamanan - Transaksi sisi klien memerlukan pemberian izin kepada klien untuk memperbarui data gabungan di database Anda. Meskipun Anda dapat mengurangi risiko pendekatan ini dengan menulis aturan keamanan lanjutan, cara ini mungkin tidak sesuai dengan segala situasi.
  • Dukungan offline - Transaksi sisi klien akan gagal jika perangkat pengguna sedang offline. Artinya, Anda perlu menangani kasus ini di aplikasi dan mencoba lagi pada waktu yang tepat.
  • Performa - Jika transaksi Anda berisi beberapa operasi baca, tulis, dan pembaruan, mungkin diperlukan beberapa permintaan ke backend Cloud Firestore. Pada perangkat seluler, proses ini dapat memakan waktu lama.
  • Kecepatan penulisan - Solusi ini mungkin tidak dapat diterapkan untuk agregasi yang sering diperbarui karena dokumen Cloud Firestore hanya dapat diperbarui maksimal sekali per detik. Selain itu, jika transaksi membaca dokumen yang dimodifikasi di luar transaksi itu sendiri, transaksi tersebut akan mencoba ulang beberapa kali, lalu gagal. Lihat penghitung terdistribusi untuk menemukan solusi relevan terkait agregasi yang memerlukan pembaruan lebih sering.

Solusi: Agregasi waktu tulis dengan Cloud Functions

Jika transaksi sisi klien tidak cocok untuk aplikasi, Anda dapat menggunakan Cloud Function untuk memperbarui informasi gabungan setiap kali rating baru ditambahkan ke restoran:

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

Solusi ini mengalihkan pekerjaan dari klien ke fungsi yang dihosting, sehingga aplikasi seluler Anda dapat menambahkan rating tanpa menunggu transaksi selesai. Kode yang dijalankan di Cloud Function tidak terikat oleh aturan keamanan, sehingga Anda tidak perlu lagi memberikan akses tulis ke data gabungan kepada klien.

Keterbatasan

Penggunaan Cloud Function untuk agregasi menghindari beberapa masalah terkait transaksi sisi klien, tetapi memiliki sejumlah keterbatasan lain:

  • Biaya - Setiap rating yang ditambahkan akan menyebabkan pemanggilan Cloud Function, dan ini dapat meningkatkan biaya. Untuk informasi lebih lanjut, lihat halaman harga Cloud Functions.
  • Latensi - Dengan memindahkan pekerjaan agregasi ke Cloud Function, aplikasi Anda tidak akan melihat data yang telah diperbarui hingga Cloud Function selesai dijalankan dan klien diberi tahu tentang data baru tersebut. Tergantung kecepatan Cloud Function Anda, proses ini bisa memakan waktu lebih lama daripada menjalankan transaksi secara lokal.
  • Kecepatan penulisan - Solusi ini mungkin tidak dapat diterapkan untuk agregasi yang sering diperbarui karena dokumen Cloud Firestore hanya dapat diperbarui maksimal sekali per detik. Selain itu, jika transaksi membaca dokumen yang dimodifikasi di luar transaksi itu sendiri, transaksi tersebut akan mencoba ulang beberapa kali, lalu gagal. Lihat penghitung terdistribusi untuk menemukan solusi relevan terkait agregasi yang memerlukan pembaruan lebih sering.