写入时聚合

Cloud Firestore 中的查询可让您查找大型集合中的文档。如需从整体上深入了解相关集合的属性,您可以对集合进行数据聚合。

您可以在读取或写入时聚合数据:

  • 读取时聚合在请求时计算结果。Cloud Firestore 支持在读取时运行 count()sum()average() 聚合查询。读取时聚合查询比写入时聚合查询更容易添加到应用中。如需详细了解聚合查询,请参阅使用聚合查询聚合数据

  • 写入时聚合在应用每次执行相关写入操作时计算结果。写入时聚合需要执行更多工作,但您可能会出于以下某个原因而需要使用写入时聚合,而不是读取时聚合:

    • 您想要监听聚合结果以获取实时更新。count()sum()average() 聚合查询不支持实时更新。
    • 您希望将聚合结果存储在客户端缓存中。count()sum()average() 聚合查询不支持缓存。
    • 您要聚合每个用户的数以万计的文档中的数据,并且要考虑费用。文档数量较少时,读取时聚合的费用较低。聚合涉及大量文档时,写入时聚合的费用可能较低。

您可以使用客户端事务或 Cloud Functions 实现写入时聚合。以下部分介绍了如何实现写入时聚合。

解决方案:使用客户端事务实现写入时聚合

假设有一个帮助用户寻找人气餐厅的本地推荐类应用。以下查询会检索指定餐厅的所有评分:

Web

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

Swift

注意:此产品不适用于 watchOS 和 App Clip 目标。
do {  let snapshot = try await db.collection("restaurants")  .document("arinell-pizza")  .collection("ratings")  .getDocuments()  print(snapshot) } catch {  print(error) }

Objective-C

注意:此产品不适用于 watchOS 和 App Clip 目标。
FIRQuery *query = [[[self.db collectionWithPath:@"restaurants"]  documentWithPath:@"arinell-pizza"] collectionWithPath:@"ratings"]; [query getDocumentsWithCompletion:^(FIRQuerySnapshot * _Nullable snapshot,  NSError * _Nullable error) {  // ... }];

Kotlin

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

Java

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

我们无需提取所有评分再计算聚合信息,而是可以将这些信息存储在这家餐厅的文档中:

Web

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

Swift

注意:此产品不适用于 watchOS 和 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

注意:此产品不适用于 watchOS 和 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

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

为了保持这些聚合数据的一致性,每当有新的评分添加到子集合时,都必须对数据进行更新。实现一致性的一种方法是在单个事务中执行添加和更新操作:

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

注意:此产品不适用于 watchOS 和 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

注意:此产品不适用于 watchOS 和 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

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

利用事务让您的聚合数据与底层集合保持一致。如需详细了解 Cloud Firestore 中的事务,请参阅事务和批量写入

限制

上述解决方案演示了如何使用 Cloud Firestore 客户端库聚合数据,但请注意以下限制:

  • 安全性 - 客户端事务要求授予客户端更新数据库中聚合数据的权限。虽然您可以通过编写高级安全规则来降低此方法的风险,但安全规则并非在所有情况下都适用。
  • 离线支持 - 如果用户的设备离线,客户端事务将失败,这意味着您需要在自己的应用中处理这种情况,并在适当的时候重试。
  • 性能 - 如果事务包含多个读取、写入和更新操作,则可能需要多次对 Cloud Firestore 后端提出请求。在移动设备上,可能需要很长时间才能完成事务。
  • 写入速率 - 此解决方案可能不适用于频繁更新的聚合,因为 Cloud Firestore 文档每秒最多只能更新一次。此外,如果某个事务读取在该事务之外修改的文档,则它会重试一定次数,然后失败。对于需要更频繁地更新的聚合,请参阅分布式计数器了解相关的临时解决方法。

解决方案:使用 Cloud Functions 实现写入时聚合

如果客户端事务不适合您的应用,您可以使用 Cloud Functions 函数,在每次有新的评分添加到餐厅时更新聚合信息:

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

该解决方案将客户端的工作分流到一个托管函数中,这意味着您的移动应用无需等待事务完成即可添加评分。在 Cloud Function 中执行的代码不受安全规则约束,这意味着您无需再为客户端提供写入聚合数据的权限。

限制

使用 Cloud Functions 函数进行聚合可以避免客户端事务存在的一些问题,但也有其他限制:

  • 费用 - 添加的每个评分都将引发一次 Cloud Functions 函数调用,这可能会增加费用。有关详细信息,请参阅 Cloud Functions 的价格页面
  • 延迟 - 如果将聚合工作分流到某个 Cloud Functions 函数,则直到该 Cloud Functions 函数执行完毕并且客户端收到有关新数据的通知后,您的应用才会看到更新后的数据。这一过程可能比在本地执行事务所需的时间更长,具体取决于您的 Cloud Functions 函数的执行速度。
  • 写入速率 - 此解决方案可能不适用于频繁更新的聚合,因为 Cloud Firestore 文档每秒最多只能更新一次。此外,如果某个事务读取在该事务之外修改的文档,则它会重试一定次数,然后失败。对于需要更频繁地更新的聚合,请参阅分布式计数器了解相关的临时解决方法。