我們已經看過MongoDB的聚合命令count
的例子了(count
被用於分頁)。大多數數據庫都提供了count
和其他很多內置的聚合函數,用於計算總和、平均數、方差等。這些特性都在MongoDB的規劃之中,但在實現前,我們可以使用group
與map-reduce
編寫腳本實現各種聚合函數,從簡單的求和到計算標準差。
5.3.1 根據用戶對評論進行分組
通常人們都想知道哪些用戶提供了最有價值的評論。既然應用程序允許用戶為評論投票,那麼從技術上講,就能計算出某個用戶所有評論的總得票數,以及該用戶每篇評論的平均得票數。雖然可以查詢所有評論並執行一些基本的客戶端處理來獲取這些統計信息,但還可以使用MonogoDB的group命令從服務器獲取結果。
group
最少需要三個參數。第一個參數是key
,定義如何對數據進行分組。本例中,我們希望結果根據用戶分組,因此分組的鍵是user_id
。第二個參數是一個對結果集做聚合的JavaScript函數,叫reduce
函數。第三個分組參數是reduce
函數的初始文檔。
實際並沒有聽上去那麼複雜。讓我們仔細看看將用到的初始文檔,以及相應的reduce
函數:
initial = {review: 0, votes: 0}; reduce = function(doc, aggregator) { aggregator.reviews += 1.0; aggregator.votes += doc.votes; }
我們看到初始化文檔為每個分組鍵定義了一些值,換言之,每次運行group
,我們都希望針對每個user_id
得到一個結果集,其中包含寫過的評論總數,以及所有那些評論的總得票數。生成這些總和的工作是由reduce
函數完成的。假設我寫了五條評論,也就是說有五個評論文檔中標記有我的用戶ID,這五個文檔都會被分別傳遞給reduce
函數,作為doc
參數。一開始aggregator
的值是initial
文檔,後續每處理一個文檔就會往aggregator
裡添加值。
下面展示如何在JavaScript Shell中執行group
命令。
代碼清單5-1 使用MongoDB的
group
命令
results = db.reviews.group({ key: {user_id: true}, initial: {reviews: 0, votes: 0.0}, reduce: function(doc, aggregator) { aggregator.reviews += 1; aggregator.votes += doc.votes; } finalize: function(doc) { doc.average_votes = doc.votes / doc.reviews; } })
請注意,此處向group
傳遞了一個額外的參數。我們希望獲得每篇評論的平均得票數,但在計算出總的評論得票數和評論總數之前,無法得到該值。這就是使用終結器(finalizer)的原因,它是一個JavaScript函數,在group
命令返回前應用於每個分組結果上。本例中,我們使用終結器計算每篇評論的平均得票數。
下面是針對示例數據集運行以上聚合的結果。
代碼清單5-2
group
命令的結果
[ {user_id: ObjectId(\"4d00065860c53a481aeab608\"), votes: 25.0, reviews: 7, average: 3.57 }, {user_id: ObjectId(\"4d00065860c53a481aeab608\"), votes: 25.0, reviews: 7, average: 3.57 } ]
本章結尾處我們還會談到group
命令,包括它所有的選項和特質。
5.3.2 根據地域對訂單應用MapReduce
我們可以把MongoDB的map-reduce
當做更靈活的group
。有了map-reduce
,可以更細粒度地控制分組鍵,還有大量輸出選項可用,包括將結果存儲在新的集合裡,以便後續能夠更方便地獲取那些數據。讓我們通過一個例子來瞭解兩者在實踐中的不同。
我們有時希望生成一些銷售匯總,可以以此為例。每個月銷售量有多少?過去一年裡每個月的銷售額有多少?通過map-reduce
可以很方便地回答這些問題。正如map-reduce
的名字所暗示的,第一步就是編寫一個映射函數,應用於集合裡的每個文檔,在此過程中實現兩個目的:定義分組所用的鍵,整理計算所需的所有數據。要實際瞭解這個過程,可以仔細查看以下函數:
map = function { var shipping_month = this.purchase_date.getMonth + \'-\' + this.purchase_data.getFullYear; var items = 0; this.line_items.forEach(function(item) { tmpItems += item.quantity; }); emit(shipping_month, {order_total: this.sub_total, items_total: 0}); }
首先,需要知道變量this
總是指向正在迭代的文檔。在函數的第一行裡,我們獲取了一個表示訂單創建月份的整數1。隨後調用了 emit
,這是每個映射函數必須要調用的特殊方法。emit
的第一個參數是分組依據的鍵,第二個參數通常是包含要執行reduce
的值的文檔。本例中,我們要根據月份分組,對每個訂單的小計和明細項數量做統計。看了與之對應的reduce
函數之後,一切就再明白不過了:
reduce = function(key, values) { var tmpTotal = 0; var tmpItems = 0; tmpTotal += doc.order_total; tmpItems += doc.items_total; return ( {total: tmpTotal, items: tmpItems} ); }
1. 因為JavaScript的月份是從0開始的,所有該值的範圍是0~11。我們需要在此基礎上加1,這樣的月份表述更加直觀。後面加了-和年份,因此整個鍵看起來是這樣的:1-2011、2-2011,以此類推。
reduce
函數接受一個鍵和一個包含一個或多個值的數組。編寫reduce
函數時要確保那些值按照既定的方式進行聚合,並且能返回單個值。因為map-reduce
的迭代本質,reduce
可能被執行多次,而編寫代碼時必須把這種情況也考慮在內。在實踐中,這就意味著對於一個映射函數給出的值而言,多次執行reduce
函數的返回值必須保證是相同的。仔細想想,你會發現情況就是這樣的。
Shell的map-reduce
方法要求提供一個映射函數和一個reduce
函數作為參數。本例中還增加了另外兩個參數。第一個參數是查詢過濾器,將聚合操作所涉及的文檔限制在2010年之後創建的訂單。第二個參數是輸出集合的名稱。
filter = {purchase_date: {$gte: new Date(2010, 0, 1)}} db.orders.mapReduce(map, reduce, {query: filter, out: \'totals\'})
該操作的結果保存在名為totals
的集合之中,我們可以像查詢其他集合一樣對它進行查詢。下面的代碼顯示了對totals
集合的查詢結果。_id
字段是分組鍵,其中的內容是年和月;value
字段是統計出的匯總信息。
代碼清單5-3 查詢map-reduce的輸出集合
> db.totals.find { _id: \"1-2011\", value: { total: 32002300, items: 59 }} { _id: \"2-2011\", value: { total: 45439500, items: 71 }} { _id: \"3-2011\", value: { total: 54322300, items: 98 }} { _id: \"4-2011\", value: { total: 75534200, items: 115 }} { _id: \"5-2011\", value: { total: 81232100, items: 121 }}
本節的示例從實踐出發讓我們對MongoDB的聚合能力有了感性的瞭解,下一節的內容會涵蓋它的大部分細節內容。