聚合查詢

Cloud Firestore 中的高級查詢可讓您快速查找大型集合中的文檔。如果您想深入了解整個集合的屬性,則需要對集合進行聚合。

Cloud Firestore 不支持原生聚合查詢。但是,您可以使用客戶端事務或 Cloud Functions 輕鬆維護有關數據的匯總信息。

在繼續之前,請確保您已閱讀有關查詢和 Cloud Firestore數據模型的信息。

解決方案:客戶端事務

考慮一個本地推薦應用程序,它可以幫助用戶找到很棒的餐館。以下查詢檢索給定餐廳的所有評分:

網絡

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

迅速

注意:此產品不適用於 watchOS 和 App Clip 目標。
db.collection("restaurants")
    .document("arinell-pizza")
    .collection("ratings")
    .getDocuments() { (querySnapshot, err) in

        // ...

}

Objective-C

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

Java

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

Kotlin+KTX

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

我們可以將這些信息存儲在餐廳文檔本身上,而不是獲取所有評分然後計算聚合信息:

網絡

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

迅速

注意:此產品不適用於 watchOS 和 App Clip 目標。
struct Restaurant {

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

    init(name: String, avgRating: Float, numRatings: Int) {
        self.name = name
        self.avgRating = avgRating
        self.numRatings = numRatings
    }

}

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

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

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)

為了使這些聚合保持一致,每次將新評級添加到子集合時都必須更新它們。實現一致性的一種方法是在單個事務中執行添加和更新:

網絡

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

迅速

注意:此產品不適用於 watchOS 和 App Clip 目標。
func addRatingTransaction(restaurantRef: DocumentReference, rating: Float) {
    let ratingRef: DocumentReference = restaurantRef.collection("ratings").document()

    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
    }) { (object, err) in
        // ...
    }
}

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

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

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

使用事務可以使您的聚合數據與基礎集合保持一致。要詳細了解 Cloud Firestore 中的事務,請參閱事務和批量寫入

限制

上面顯示的解決方案演示了使用 Cloud Firestore 客戶端庫聚合數據,但您應該注意以下限制:

  • 安全性 - 客戶端事務需要授予客戶端更新數據庫中聚合數據的權限。雖然您可以通過編寫高級安全規則來降低這種方法的風險,但這可能並不適用於所有情況。
  • 離線支持- 當用戶的設備離線時,客戶端事務將失敗,這意味著您需要在應用程序中處理這種情況並在適當的時間重試。
  • 性能- 如果您的事務包含多個讀取、寫入和更新操作,則可能需要向 Cloud Firestore 後端發出多個請求。在移動設備上,這可能需要大量時間。
  • 寫入速率- 此解決方案可能不適用於頻繁更新的聚合,因為 Cloud Firestore 文檔每秒最多只能更新一次。此外,如果事務讀取在事務之外修改的文檔,它會重試有限次然後失敗。查看分佈式計數器,了解需要更頻繁更新的聚合的相關解決方法。

解決方案:雲函數

如果客戶端事務不適合您的應用程序,您可以使用雲函數在每次向餐廳添加新評級時更新聚合信息:

節點.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 Functions定價頁面
  • 延遲- 通過將聚合工作卸載到雲函數,您的應用將不會看到更新的數據,直到雲函數完成執行並且客戶端已收到新數據的通知。根據您的雲函數的速度,這可能需要比在本地執行事務更長的時間。
  • 寫入速率- 此解決方案可能不適用於頻繁更新的聚合,因為 Cloud Firestore 文檔每秒最多只能更新一次。此外,如果事務讀取在事務之外修改的文檔,它會重試有限次然後失敗。查看分佈式計數器,了解需要更頻繁更新的聚合的相關解決方法。