https://flic.kr/p/qakxAH
聚合(aggregation)是一種比查詢更強大的操作。它能夠對查詢到的資料先進行一些處理,才輸出結果,例如新增欄位、分組、統計、跨 collection 查詢等。換句話說,如同關聯式資料庫的 JOIN、GROUP、SUM 等操作,都能透過 MongoDB 的聚合來實現。
本文會介紹聚合的使用方式,並透過各個小節的示範,慢慢將 document 處理成更為詳細的結果。藉此讓讀者體會簡單的聚合操作,並在過程中學習使用到的符號。
一、聚合(Aggegation)的介紹
(一)基本概念
在 MongoDB 資料庫進行查詢後(使用 find 方法),我們會得到符合條件的 document。但查詢只能找出單一 collection 中的原始 document,可能看不出足夠詳細的資料。聚合(aggregation)的出現,為我們提供一種途徑來對資料進行「加工」,處理成想要的結果。
舉例來說,下面的例子是學生 collection 的 document。其中 courseIds 欄位是存放課程 collection 的 document id。
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": ["C1", "C2"] } ]
藉由 aggregation,我們能查詢學生 collection,並經由數個階段的加工後,輸出如下的範例結果:
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseInfoList": [ { "_id": "C1", "name": "程式設計", "point": 5 }, { "_id": "C2", "name": "英文", "point": 3 } ], "totalPoint": 8, "amountOfCourse": 2 } ]
上面的例子是找出課程資料,加總其中的學分數,並取得課程數量。如此便得到更詳細的資料,最後的結果再行輸出。這就是本文後續小節要達成的目標。
(二)程式撰寫方式
還記得執行一般的查詢,是呼叫 find 方法,並傳入一個代表查詢條件的物件。而進行聚合時,則是呼叫 aggregate 方法,並傳入物件陣列,其中的元素代表要進行的操作。
這些操作的正式名稱叫做「stage」,讀者可以想成 document 會經過多個階段的處理,才被加工成想要的結果。而這些 stage 依照順序集合起來而成的陣列,正式名稱叫做「pipeline」(管道)。
所以在程式碼寫法上,就是將 pipeline 當做參數傳入到 aggregate 方法,示範如下:
db.collection.aggregate( [ { "階段符號1": { } }, { "階段符號2": { } } ] );
在每個 stage 都包含一個階段符號,代表該階段要對 document 進行何種操作。而不同的階段符號,所要定義的操作方式大有不同。因此筆者就不特別在上面的範例寫出操作的定義。
二、範例資料介紹
在學習使用階段符號前,筆者先介紹本文使用的範例資料。延續前例,我們有個 collection 叫做「student」,儲存以下的學生資料。
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": ["C1", "C2"] }, { "_id": "S2", "name": "Dora", "grade": 3, "courseIds": ["C2", "C3"] } ]
還有一個 collection 叫做「course」,儲存以下的課程資料。
[ { "_id": "C1", "name": "程式設計", "point": 5 }, { "_id": "C2", "name": "英文", "point": 3 }, { "_id": "C3", "name": "計算機概論", "point": 4 } ]
從下一節開始,筆者會介紹一些階段符號與操作符號,並應用在範例資料上。
三、$match 符號
$match 符號的用途是篩選 document,它會接收一個代表查詢條件的物件參數,其寫法正好與一般查詢相同。由於查詢條件的寫法不是本文的重點,想了解的讀者,可另外參考筆者的文章【MongoDB】如何撰寫查詢語法。
以下的 stage 範例,是從學生 collection 中篩選出 4 年級的學生。
{ "$match": { "grade": 4 } }
聚合結果如下:
{ "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": ["C1", "C2"] }
四、$unwind 符號
「unwind」的意思是「解開」。而 $unwind 符號的用途,是將指定的陣列欄位給「展開」。此時會產生數個內容相同的 document,但陣列元素已經打散到這些 document 身上。
以下的 stage 範例,是展開 document 的 courseIds 陣列欄位。
{ "$unwind": { "path": "$courseIds", "preserveNullAndEmptyArrays": true } }
這個符號接收兩個參數。
- path:要展開的陣列欄位之名稱,需以「$」符號開頭。
- preserveNullAndEmptyArrays:當陣列為 null 值或無元素時,可控制是否繼續保留該 document。
延續上一節,經過 $unwind 符號處理後,聚合結果如下:
[ { "_id" : "S1", "name" : "Vincent", "grade" : 4, "courseIds" : "C1" }, { "_id" : "S1", "name" : "Vincent", "grade" : 4, "courseIds" : "C2" } ]
讀者可以看到,原先 courseIds 陣列有 2 個元素,於是 document 也變成 2 個。且原先的陣列元素打散到這 2 個 document 上。
五、$lookup 符號
(一)使用方式
$lookup 符號的用途,是藉由某個欄位值,從指定的 collection 找出具有相同欄位值的資料。並將找到的資料,以陣列的形式做為自身的新欄位。達到類似 SQL 的 LEFT JOIN 的效果。
延續上一節的聚合結果,以下的 stage 範例,是藉由 courseIds 欄位值,從 course 這個 collection 找出 course document 的 _id 欄位值與 courseIds 相同的資料。並將其添加到自身,欄位名為 courseInfo。
{ "$lookup": { "localField": "courseIds", "from": "course", "foreignField": "_id", "as": "courseInfo" } }
這個符號接收四個參數。
- localField:自身欄位中,要用來向指定 collection 尋找資料的欄位名稱。
- from:要尋找資料的 collection 之名稱。
- foreignField:該 collection 的 document 欄位名稱。用來與 localField 比對欄位值。
- as:將找到的 document 添加到自身時,要存放的欄位之名稱。
經過 $lookup 符號處理後,聚合結果如下。可看到 course 的 document 完整地被放到自身 document 的新欄位。
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": "C1", "courseInfo": [ { "_id": "C1", "name": "程式設計", "point": 5 } ] }, { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": "C2", "courseInfo": [ { "_id": "C2", "name": "英文", "point": 3 } ] } ]
(二)lookup 的後續處理
由於上例的 foreignField 參數所指定的欄位,是 course collection 的主鍵,因此 courseIds 的值理所當然只會對應到一個 document。使得 courseInfo 陣列欄位只有一個元素。
此處為了讓 document 的結構「單純」一點,方便後續處理,讓我們對該陣列欄位做一次 unwind。聚合結果如下:
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": "C1", "courseInfo": { "_id": "C1", "name": "程式設計", "point": 5 } }, { "_id": "S1", "name": "Vincent", "grade": 4, "courseIds": "C2", "courseInfo": { "_id": "C2", "name": "英文", "point": 3 } } ]
六、$group 符號
$group 符號的用途,是將所有 document 進行分組。經由指定一個欄位,則該欄位具有相同值的 document 就會被歸類為一組。
操作時,我們會重新定義分組後的結果要包含哪些欄位。若是想順道進行一些統計、資料整理,也可在分組時進行。
延續上一節的聚合結果,以下的 stage 範例是進行分組。
{ "$group": { "_id": "$_id", "name": { "$first": "$name" }, "grade": { "$first": "$grade" }, "courseInfoList": { "$push": "$courseInfo" } } }
此例中,筆者做了以下操作:
- 依據 _id 欄位進行分組。
- 定義分組結果要包含原有的 name 與 grade 欄位。
- 將同組的 courseInfo 欄位值集中成一個物件陣列,欄位名為 courseInfoList。
上面的例子使用了數個符號,讓我們來一一認識。
- $id:定義要使用 document 的哪一個欄位進行分組。
- $first:同一組可能會包含多個 document。使用此符號,可選擇要採用第一個 document 做為分組結果的欄位值。若想採用最後一個,請使用 $last 符號。
- $push:將同組 document 的指定欄位值彙整成一個陣列。若希望陣列元素不重複,請使用 $addToSet 符號。
經過 $group 符號做分組處理後,聚合結果如下:
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseInfoList": [ { "_id": "C1", "name": "程式設計", "point": 5 }, { "_id": "C2", "name": "英文", "point": 3 } ] } ]
七、$addFields 符號
$addFields 符號的用途,顧名思義就是在 document 新增欄位。通常會搭配「聚合管道符號」(aggregation pipeline operatior),取用現有的欄位值進行運算後,成為新的欄位。
延續上一節的聚合結果,以下的 stage 範例是在 document 新增 totalPoint(總學分數)與 amountOfCourse(課程數量)兩個欄位。
{ "$addFields": { "totalPoint": { "$sum": "$courseInfoList.point" }, "amountOfCourse": { "$size": "$courseInfoList" } } }
透過 $addFields 符號,我們可一次新增數個欄位。而每個欄位使用了不同的聚合符號,來定義各自的運算方式。
- $sum 符號:用途為加總。此例中將 courseInfoList 陣列的元素的 point 欄位值進行加總。
- $size 符號:用途為取得陣列長度。此例中取得 courseInfoList 陣列的元素數量。
新增完欄位後,聚合結果如下:
[ { "_id": "S1", "name": "Vincent", "grade": 4, "courseInfoList": [ { "_id": "C1", "name": "程式設計", "point": 5 }, { "_id": "C2", "name": "英文", "point": 3 } ], "totalPoint": 8, "amountOfCourse": 2 } ]
到此為止,已經達成第一節所期望的結果。另外,本文自第四節開始,都只有對一個學生資料做操作。讀者可將第三節介紹的 $match 符號拿掉(不做篩選),以便更好地觀察 document 在每個階段的聚合結果。
MongoDB 提供了非常多種聚合符號,有興趣的讀者可參考官方文件 Aggregation Pipeline Operators。附帶一提,我們可以使用 $toObjectId 符號,將字串轉為 ObjectId。這麼做是為了在使用 $lookup 階段符號時,能將 ObjectId 做為 localField,用來比對另一個 collection 中的欄位值。
以下是本文的完整程式碼與範例資料:
https://gist.github.com/ntub46010/0bcd5ab0b49e37cd2e175a5d4ef74d9e
留言
張貼留言