MongoDB指南---18、聚合命令

上一篇文章:MongoDB指南---17、MapReduce
下一篇文章:

MongoDB爲在集合上執行基本的聚合任務提供了一些命令。這些命令在聚合框架出現之前就已經存在了,現在(大多數情況下)已經被聚合框架取代。然而,複雜的group操作可能仍然需要使用JavaScript,count和distinct操作可以被簡化爲普通命令,不需要使用聚合框架。

 count

count是最簡單的聚合工具,用於返回集合中的文檔數量:

> db.foo.count()
0
> db.foo.insert({"x" : 1})
> db.foo.count()
1

不論集合有多大,count都會很快返回總的文檔數量。
也可以給count傳遞一個查詢文檔,Mongo會計算查詢結果的數量:

> db.foo.insert({"x" : 2})
> db.foo.count()
2
> db.foo.count({"x" : 1})
1

對分頁顯示來說總數非常必要:“共439個,目前顯示0~10個”。但是,增加查詢條件會使count變慢。count可以使用索引,但是索引並沒有足夠的元數據供count使用,所以不如直接使用查詢來得快。

 distinct

distinct用來找出給定鍵的所有不同值。使用時必須指定集合和鍵。

> db.runCommand({"distinct" : "people", "key" : "age"})

假設集合中有如下文檔:

{"name" : "Ada", "age" : 20}
{"name" : "Fred", "age" : 35}
{"name" : "Susan", "age" : 60}
{"name" : "Andy", "age" : 35}

如果對"age"鍵使用distinct,會得到所有不同的年齡:

> db.runCommand({"distinct" : "people", "key" : "age"})
{"values" : [20, 35, 60], "ok" : 1}

這裏還有一個常見問題:有沒有辦法獲得集合裏面所有不同的鍵呢?MongoDB並沒有直接提供這樣的功能,但是可以用MapReduce(詳見7.3節)自己寫一個。

group

使用group可以執行更復雜的聚合。先選定分組所依據的鍵,而後MongoDB就會將集合依據選定鍵的不同值分成若干組。然後可以對每一個分組內的文檔進行聚合,得到一個結果文檔。
如果你熟悉SQL,那麼這個group和SQL中的GROUP BY差不多。
假設現在有個跟蹤股票價格的站點。從上午10點到下午4點每隔幾分鐘就會更新某隻股票的價格,並保存在MongoDB中。現在報表程序要獲得近30天的收盤價。用group就可以輕鬆辦到。
股價集合中包含數以千計如下形式的文檔:

{"day" : "2010/10/03", "time" : "10/3/2010 03:57:01 GMT-400", "price" : 4.23}
{"day" : "2010/10/04", "time" : "10/4/2010 11:28:39 GMT-400", "price" : 4.27}
{"day" : "2010/10/03", "time" : "10/3/2010 05:00:23 GMT-400", "price" : 4.10}
{"day" : "2010/10/06", "time" : "10/6/2010 05:27:58 GMT-400", "price" : 4.30}
{"day" : "2010/10/04", "time" : "10/4/2010 08:34:50 GMT-400", "price" : 4.01}

注意,由於精度的問題,實際使用中不要將金額以浮點數的方式存儲,這個例子只是爲了簡便才這麼做。
我們需要的結果列表中應該包含每天的最後交易時間和價格,就像下面這樣:

[
    {"time" : "10/3/2010 05:00:23 GMT-400", "price" : 4.10},
    {"time" : "10/4/2010 11:28:39 GMT-400", "price" : 4.27},
    {"time" : "10/6/2010 05:27:58 GMT-400", "price" : 4.30}
]

先把集合按照"day"字段進行分組,然後在每個分組中查找"time"值最大的文檔,將其添加到結果集中就完成了。整個過程如下所示:

> db.runCommand({"group" : {
... "ns" : "stocks",
... "key" : "day",
... "initial" : {"time" : 0},
... "$reduce" : function(doc, prev) {
...     if (doc.time > prev.time) {
...         prev.price = doc.price;
...         prev.time = doc.time;
...     }
... }}})

把這個命令分解開看看。

  • "ns" : "stocks"

指定要進行分組的集合。

  • "key" : "day"

指定文檔分組依據的鍵。這裏就是"day"鍵。所有"day"值相同的文檔被分到一組。

  • "initial" : {"time" : 0}

每一組reduce函數調用中的初始"time"值,會作爲初始文檔傳遞給後續過程。每一組的所有成員都會使用這個累加器,所以它的任何變化都可以保存下來。

  • "$reduce" : function(doc, prev) { ... }

這個函數會在集合內的每個文檔上執行。系統會傳遞兩個參數:當前文檔和累加器文檔(本組當前的結果)。本例中,想讓reduce函數比較當前文檔的時間和累加器的時間。如果當前文檔的時間更晚一些,則將累加器的日期和價格替換爲當前文檔的值。別忘了,每一組都有一個獨立的累加器,所以不必擔心不同日期的命令會使用同一個累加器。

在問題一開始的描述中,就提到只要最近30天的股價。然而,我們在這裏迭代了整個集合。這就是要添加"condition"的原因,因爲這樣就可以只對必要的文檔進行處理。

> db.runCommand({"group" : {
... "ns" : "stocks",
... "key" : "day",
... "initial" : {"time" : 0},
... "$reduce" : function(doc, prev) {
...     if (doc.time > prev.time) {
...            prev.price = doc.price;
...         prev.time = doc.time;
...     }},
... "condition" : {"day" : {"$gt" : "2010/09/30"}}
... }})

有些參考資料提及"cond"鍵或者"q"鍵,其實和"condition"鍵是完全一樣的(就是表達力不如"condition"好)。
最後就會返回一個包含30個文檔的數組,其實每個文檔都是一個分組。每組都包含分組依據的鍵(這裏就是"day" : string)以及這組最終的prev值。如果有的文檔不存在指定用於分組的鍵,這些文檔會被單獨分爲一組,缺失的鍵會使用"day : null"這樣的形式。在"condition"中加入"day" : {"$exists" : true}就可以排除不包含指定用於分組的鍵的文檔。group命令同時返回了用到的文檔總數和"key"的不同值數量:

> db.runCommand({"group" : {...}})
{
    "retval" :
        [
            {
                "day" : "2010/10/04",
                "time" : "Mon Oct 04 2010 11:28:39 GMT-0400 (EST)"
                "price" : 4.27
            },
            ...
        ],
    "count" : 734,
    "keys" : 30,
    "ok" : 1
}

這裏每組的"price"都是顯式設置的,"time"先由初始化器設置,然後在迭代中進行更新。"day"是默認被加進去的,因爲用於分組的鍵會默認加入到每個"retval"內嵌文檔中。要是不想在結果集中看到這個鍵,可以用完成器將累加器文檔變爲任何想要的形態,甚至變換成非文檔(例如數字或字符串)。

1. 使用完成器

完成器(finalizer)用於精簡從數據庫傳到用戶的數據,這個步驟非常重要,因爲group命令的輸出結果需要能夠通過單次數據庫響應返回給用戶。爲進一步說明,這裏舉個博客的例子,其中每篇文章都有多個標籤(tag)。現在要找出每天最熱門的標籤。可以(再一次)按天分組,得到每一個標籤的計數。就像下面這樣:

> db.posts.group({
... "key" : {"day" : true},
... "initial" : {"tags" : {}},
... "$reduce" : function(doc, prev) {
...     for (i in doc.tags) {
...         if (doc.tags[i] in prev.tags) {
...             prev.tags[doc.tags[i]]++;
...         } else {
...             prev.tags[doc.tags[i]] = 1;
...         }
...     }
... }})

得到的結果如下所示:

[
    {"day" : "2010/01/12", "tags" : {"nosql" : 4, "winter" : 10, "sledding" : 2}},
    {"day" : "2010/01/13", "tags" : {"soda" : 5, "php" : 2}},
    {"day" : "2010/01/14", "tags" : {"python" : 6, "winter" : 4, "nosql": 15}}
]

接着可以在客戶端找出"tags"文檔中出現次數最多的標籤。然而,向客戶端發送每天所有的標籤文檔需要許多額外的開銷——每天所有的鍵/值對都被傳送給用戶,而我們需要的僅僅是一個字符串。這也就是group有一個可選的"finalize"鍵的原因。"finalize"可以包含一個函數,在每組結果傳遞到客戶端之前調用一次。可以使用"finalize"函數將不需要的內容從結果集中移除:

> db.runCommand({"group" : {
... "ns" : "posts",
... "key" : {"day" : true},
... "initial" : {"tags" : {}},
... "$reduce" : function(doc, prev) {
...     for (i in doc.tags) {
...         if (doc.tags[i] in prev.tags) {
...             prev.tags[doc.tags[i]]++;
...         } else {
...             prev.tags[doc.tags[i]] = 1;
...         }
...     },
... "finalize" : function(prev) {
...     var mostPopular = 0;
...     for (i in prev.tags) {
...         if (prev.tags[i] > mostPopular) {
...             prev.tag = i;
...             mostPopular = prev.tags[i];
...         }
...     }
...     delete prev.tags
... }}})

現在,我們就得到了想要的信息,服務器返回的內容可能如下:

[
    {"day" : "2010/01/12", "tag" : "winter"},
    {"day" : "2010/01/13", "tag" : "soda"},
    {"day" : "2010/01/14", "tag" : "nosql"}
]

finalize可以對傳遞進來的參數進行修改,也可以返回一個新值。

2. 將函數作爲鍵使用

有時分組所依據的條件可能會非常複雜,而不是單個鍵。比如要使用group計算每個類別有多少篇博客文章(每篇文章只屬於一個類別)。由於不同作者的風格不同,填寫分類名稱時可能有人使用大寫也有人使用小寫。所以,如果要是按類別名來分組,最後“MongoDB”和“mongodb”就是兩個完全不同的組。爲了消除這種大小寫的影響,就要定義一個函數來決定文檔分組所依據的鍵。
定義分組函數就要用到$keyf鍵(注意不是"key"),使用"$keyf"的group命令如下所示:

> db.posts.group({"ns" : "posts",
... "$keyf" : function(x) { return x.category.toLowerCase(); },
... "initializer" : ... })

有了"$keyf",就能依據各種複雜的條件進行分組了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章