【MongoDB】使用聚合(aggregation)進行資料處理


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

此例中,筆者做了以下操作:

  1. 依據 _id 欄位進行分組。
  2. 定義分組結果要包含原有的 name 與 grade 欄位。
  3. 將同組的 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

上一篇:【MongoDB】使用操作符號來更新資料

下一篇:【MongoDB】索引的用途與操作方式

留言