新手到進階學 MongoDB(3)- Aggregation 教學

本篇文章是系列文新手到進階學 MongoDB 的第三篇,要介紹 MongoDB 中較進階的 Aggregation 用法。

文章的架構如下:

  • Aggregation 基本概念:什麼是 Pipeline 與 Stage ?
  • 常見的資料操作方法(ㄧ):$match$sort$limit
  • 常見的資料操作方法(二)$project$group$unwind
  • Aggregation 綜合練習

你也可以在上一篇文章中複習 MongoDB 的 CRUD 操作

Aggregation 基本概念

MongoDB 的文件裡寫到:

Aggregation operations process data records and return computed results.

意思是:Aggregation是幫助我們在 MongoDB server 端進行「資料處理」的工具。

你可能會好奇在 MongoDB server 端進行資料處理的好處是什麼?為什麼不在應用程式端處理就好?
舉個例子,假如我們有一個名為 products的 collection,裡頭裡存放了所有商品的資訊。當我們想要知道「最貴的商品是什麼」時,我們有兩種做法:

  1. 把所有的商品都查詢出來,再在應用程式中找出價錢最高的商品
  2. 透過撰寫 Aggregation 指令,直接在 MongoDB server 端找出價錢最高的商品

可以看出資料量龐大時,比起把所有資料都拿回應用程式端做處理,使用 Aggregation 更有效率些~

什麼是 Pipeline 跟 Stage

如果我們把「資料處理」比喻成「罐頭加工」的過程,那麼:

  • 存在 mongoDB 中的原始資料就是「罐頭的原物料」
  • Pipeline是罐頭加工廠的「生產線」
  • Stage是生產線中的「一道手續」

我們透過描述一連串的 stages (手續)來組成 pipeline(生產線),並對原始資料(原物料)進行 aggregate(加工),最終變成我們想要的成果(罐頭)。如同生產線中手續的「優先順序」很重要一樣,pipeline 中 stages 的順序是很重要的,因為每一個 stage 的 input 都是上一個 stage 處理後的 output。

舉例來說,我們可以透過由兩個 stage 組成的 pipeline 找出資料庫中「最貴的科技商品」是什麼。要注意兩個 stage 的順序不能調換:

1
2
3
4
5
// 針對 products collection 使用 aggregate
db.products.aggregate([
找出所有「科技類」商品, // 第一個 stage
找到其中「價錢最高」的商品 // 第二個 stage
])

再來我們會介紹幾個常見的 stage 以及應用方法。

常見的資料操作方法(ㄧ)

首先要介紹$match$sort$limit這三個方法。

$match

用來找出符合需求條件的資料。用法就跟上一篇介紹的find很像,是最常用到 Aggregation 方法之一。

假如有一個articlescollection 資料如下:

articles
1
2
3
4
5
6
7
{ "author" : "dave", "score" : 80, "views" : 100 , "_id" : ObjectId("512bc95fe835e68f199c8686") }
{ "author" : "dave", "score" : 85, "views" : 521 , "_id" : ObjectId("512bc962e835e68f199c8687") }
{ "author" : "ahn", "score" : 60, "views" : 1000 , "_id" : ObjectId("55f5a192d4bede9ac365b257") }
{ "author" : "li", "score" : 55, "views" : 5000 , "_id" : ObjectId("55f5a192d4bede9ac365b258") }
{ "author" : "annT", "score" : 60, "views" : 50 , "_id" : ObjectId("55f5a1d3d4bede9ac365b259") }
{ "author" : "li", "score" : 94, "views" : 999 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25a") }
{ "author" : "ty", "score" : 95, "views" : 1000 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25b") }

我們可以用$match方法找出「作者是 dave」 的文章:

example-1
1
2
3
4
5
// 針對 articles collection 使用 aggregate
db.articles.aggregate(
// 只有一個 $match stage 的 pipeline
[ { $match : { author : "dave" } } ]
);

會得出 2 筆結果

example-1-result
1
2
{ "author" : "dave", "score" : 80, "views" : 100 , "_id" : ObjectId("512bc95fe835e68f199c8686") }
{ "author" : "dave", "score" : 85, "views" : 521 , "_id" : ObjectId("512bc962e835e68f199c8687") }

搭配邏輯符號

在使用$match時也常搭配上邏輯符號,如$or$gt$lt,能夠更精準的描述想要的資料。

下面的範例在articlescollection 中找出「分數大於 80、小於 95」或「觀看次數大於(等於) 1000」的文章:

example-2
1
2
3
db.articles.aggregate( [
{ $match: { $or: [ { score: { $gt: 80, $lt: 95 } }, { views: { $gte: 1000 } } ] } },
] );

會得出 5 筆結果

example-2-result
1
2
3
4
5
{ "author" : "dave", "score" : 85, "views" : 521 , "_id" : ObjectId("512bc962e835e68f199c8687") }
{ "author" : "ahn", "score" : 60, "views" : 1000 , "_id" : ObjectId("55f5a192d4bede9ac365b257") }
{ "author" : "li", "score" : 55, "views" : 5000 , "_id" : ObjectId("55f5a192d4bede9ac365b258") }
{ "author" : "li", "score" : 94, "views" : 999 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25a") }
{ "author" : "ty", "score" : 95, "views" : 1000 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25b") }

$sort$limit

$sort用來將 documents 依據指定欄位排序,$limit則是限定 documents 的數量。
這兩個 stage 常常搭配在一起使用。

同樣以上面articlescollection 為例子,我們可以用$sort$limit找出「分數最高的三篇文章」:

example-3
1
2
3
4
5
6
7
8
db.articles.aggregate( [
// 依照分數「由高而低」排序
{"$sort": {
"score": -1,
}},
// 只要分數「前三高」的 document
{"$limit": 3}
] );

會得出 3 筆結果

example-3-result
1
2
3
{ "author" : "ty", "score" : 95, "views" : 1000 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25b") }
{ "author" : "li", "score" : 94, "views" : 999 , "_id" : ObjectId("55f5a1d3d4bede9ac365b25a") }
{ "author" : "dave", "score" : 85, "views" : 521 , "_id" : ObjectId("512bc962e835e68f199c8687") }

常見的資料操作方法(二)

再來要介紹$project$unwind$group這三個方法。

$project

$project可以用來篩選或排除 document 已經存在的欄位、也可以用來創造出新的欄位。

假如有一個bookscollection 資料如下:

books
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"_id" : 1,
title: "abc123",
isbn: "0001122223334",
author: { last: "zzz", first: "aaa" },
copies: 5,
lastModified: "2016-07-28"
}
{
"_id" : 2,
title: "Baked Goods",
isbn: "9999999999999",
author: { last: "xyz", first: "abc", middle: "" },
copies: 2,
lastModified: "2017-07-21"
}
{
"_id" : 3,
title: "Ice Cream Cakes",
isbn: "8888888888888",
author: { last: "xyz", first: "abc", middle: "mmm" },
copies: 5,
lastModified: "2017-07-22"
}

我們可以用$project來篩選需要的兩個欄位 title、lastModified,並創造出一個新的欄位 authorName。其中的 authorName 用了字串的$concat方法,把 author 的 first name 跟 last name 串連起來:

example-4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.books.aggregate( [
{
$project: {
title: true, // true 表示要留下這個欄位
lastModified: true,
authorName: { // 產生一個新的欄位 authorName
$concat: [
"$author.first",
' ',
"$author.last"
]
}
}
}
] )

產出結果如下:

example-4-result
1
2
3
{ "_id" : 1, "title" : "abc123", lastModified: "2016-07-28", authorName: "aaa zzz"  }
{ "_id" : 2, "title" : "Baked Goods", lastModified: "2017-07-21", authorName: "abc xyz" }
{ "_id" : 3, "title" : "Ice Cream Cakes", lastModified: "2017-07-21", authorName: "abc xyz" }

值得一提的是_id這個欄位:由於 _id 是 document 的主鍵,除非「特別排除」這個欄位,否則使用$project時預設都會保留下來。如果想要排除 _id,只需要再加上一行_id: false就行~

$unwind

$unwind可以把 document 中的陣列資料「攤平」。聽起來好像很抽象,不如直接來看個例子。

假如有一個紀錄產品庫存的inventorycollection 資料如下:

inventory
1
2
{ "_id" : 1, "item" : "shirt", "sizes": [ "S", "M", "L"] }
{ "_id" : 2, "item" : "shoes", "sizes": ["M"] }

我們嘗試針對 sizes 這個陣列欄位使用$unwind

example-5
1
db.inventory.aggregate( [ { $unwind: "$sizes" } ] )

因為第一筆 document 中 sizes 陣列有三個值,所以攤平後會得到三筆資料。結果如下:

example-5-result
1
2
3
4
{ "_id" : 1, "item" : "shirt", "sizes" : "S" }
{ "_id" : 1, "item" : "shirt", "sizes" : "M" }
{ "_id" : 1, "item" : "shirt", "sizes" : "L" }
{ "_id" : 2, "item" : "shoes", "sizes" : "M" }

$unwind時常放在 pipeline 中間,幫助我們更直觀的處理陣列資料。

$group

$group可以把 document 「分組」,還可以根據分組結果做數學運算。是非常好用的工具之一。

假如有一個記錄銷售的salescollection 資料如下:

sales
1
2
3
4
5
6
7
8
{ "_id" : 1, "item" : "abc", "quantity" : "2", "date" : 2014-03-01}
{ "_id" : 2, "item" : "jkl", "quantity" : "1", "date" : 2014-03-01}
{ "_id" : 3, "item" : "xyz", "quantity" : "1", "date" : 2014-03-15}
{ "_id" : 4, "item" : "xyz", "quantity" : "20", "date" : 2014-04-04}
{ "_id" : 5, "item" : "abc", "quantity" : "10", "date" : 2014-04-04}
{ "_id" : 6, "item" : "def", "quantity": "5", "date" : 2015-06-04}
{ "_id" : 7, "item" : "def", "quantity": "10", "date" : 2015-09-10}
{ "_id" : 8, "item" : "abc", "quantity" : "5" , "date" : 2016-02-06}

我們使用$group方法,計算出每一個 item 各別賣出了多少數量:

  • _id:要做分組的欄位。範例中我們把相同 item 的資料 group 在一組。
  • totalSaleQuantity:我們新加上的欄位。透過$sum把相同 item 的 quantity 相加
example-6
1
2
3
4
5
6
7
8
db.sales.aggregate([
{
$group : {
_id : "$item", // 用 item 欄位做分組
totalSaleQuantity: { $sum: "$quantity" } // 使用 $sum 把同個 item 的 quantity 相加
}
}
])

得出 4 種不同 item 以及各別賣出的總數量:

example-6-result
1
2
3
4
{ "_id" : "abc", "totalSaleQuantity" : 17 }
{ "_id" : "jkl", "totalSaleQuantity" : 1 }
{ "_id" : "xyz", "totalSaleQuantity" : 21 }
{ "_id" : "def", "totalSaleQuantity" : 15 }

除了範例裡用的$sum之外,$group還時常搭配$count$avg$max等運算符使用。
可以在文件中看到完整的用法。

Aggregation 綜合練習

我們來試試把多個 stage 組成 pipeline 的綜合練習。

假如有一個訂單的ordercollection 如下。每筆 order 都包含顧客 id訂單日期金額三個欄位。

我們的目標是找出在 2020 年間,每位顧客的:

  1. 第一筆訂單時間
  2. 訂單的總數
  3. 訂單的總金額
order
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{
"customer_id": "elise_smith@myemail.com",
"orderdate": ISODate("2020-05-30T08:35:52Z"),
"value": NumberDecimal("231.43"),
},
{
"customer_id": "elise_smith@myemail.com",
"orderdate": ISODate("2020-01-13T09:32:07Z"),
"value": NumberDecimal("99.99"),
},
{
"customer_id": "oranieri@warmmail.com",
"orderdate": ISODate("2020-01-01T08:25:37Z"),
"value": NumberDecimal("63.13"),
},
{
"customer_id": "tj@wheresmyemail.com",
"orderdate": ISODate("2019-05-28T19:13:32Z"),
"value": NumberDecimal("2.01"),
},
{
"customer_id": "tj@wheresmyemail.com",
"orderdate": ISODate("2020-11-23T22:56:53Z"),
"value": NumberDecimal("187.99"),
},
{
"customer_id": "tj@wheresmyemail.com",
"orderdate": ISODate("2020-08-18T23:04:48Z"),
"value": NumberDecimal("4.59"),
},
{
"customer_id": "elise_smith@myemail.com",
"orderdate": ISODate("2020-12-26T08:55:46Z"),
"value": NumberDecimal("48.50"),
},
{
"customer_id": "tj@wheresmyemail.com",
"orderdate": ISODate("2021-02-29T07:49:32Z"),
"value": NumberDecimal("1024.89"),
},
{
"customer_id": "elise_smith@myemail.com",
"orderdate": ISODate("2020-10-03T13:49:44Z"),
"value": NumberDecimal("102.24"),
}

首先,我們只需要 2020 年間的訂單,所以可以用$match寫出第一個 stage :

match
1
2
3
4
5
6
7
8
{
"$match": {
"orderdate": {
"$gte": ISODate("2020-01-01T00:00:00Z"), // 時間大於等於 2020/1/1
"$lt": ISODate("2021-01-01T00:00:00Z"), // 時間小於 2021/1/1
},
}
},

再來,由於我們要找出「每個使用者」的「第一筆訂單時間」,可以先使用$sort把所有訂單依照日期「由先而後」排序:

sort
1
2
3
4
5
{
"$sort": {
"orderdate": 1, // 依照 orderdate 將時間由小而大排序
}
}

最後,我們使用$group把訂單依照顧客 id做分組,並搭配:

  • $first:找出每個顧客的第一筆訂單。因為先前已經 sort 過,所以第一筆訂單就是「時間最早」的訂單
  • $sum:計算出訂單總數、訂單總金額
group
1
2
3
4
5
6
7
8
{
"$group": {
"_id": "$customer_id", // 依照 customer_id 做分組
"first_purchase_date": {"$first": "$orderdate"}, // 找出第一筆(也是最早的) orderdate
"total_value": {"$sum": "$value"}, // 使用 sum 將每筆 order 的金額加總
"total_orders": {"$sum": 1}, // 使用 sum 計算總共有幾筆 order
}
}

我們把上述三個 stage 組裝成 pipeline,對ordercollection 進行 aggregate 操作:

example-7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
db.orders.aggregate([
// Match only orders made in 2020
{"$match": {
"orderdate": {
"$gte": ISODate("2020-01-01T00:00:00Z"),
"$lt": ISODate("2021-01-01T00:00:00Z"),
},
}},
// Sort by order date ascending
{"$sort": {
"orderdate": 1,
}},
// Group by customer
{"$group": {
"_id": "$customer_id",
"first_purchase_date": {"$first": "$orderdate"},
"total_value": {"$sum": "$value"},
"total_orders": {"$sum": 1},
}},
]);

得出結果:

example-7-result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
customer_id: 'elise_smith@myemail.com',
first_purchase_date: ISODate('2020-01-13T09:32:07.000Z'),
total_value: NumberDecimal('482.16'),
total_orders: 4
},
{
customer_id: 'oranieri@warmmail.com',
first_purchase_date: ISODate('2020-01-01T08:25:37.000Z'),
total_value: NumberDecimal('63.13'),
total_orders: 1
},
{
customer_id: 'tj@wheresmyemail.com',
first_purchase_date: ISODate('2020-08-18T23:04:48.000Z'),
total_value: NumberDecimal('192.58'),
total_orders: 2
}

總結

如果把CRUD比喻成格鬥遊戲裡的基本攻防招數,那Aggregation就是格鬥遊戲裡的連續技,若能好好善用會是很強大的武器~
關於 Aggregation 更詳細的用法可以參考官方文件Practical MongoDB Aggregations 電子書。

下篇文章我們要介紹能夠加速 mongoDB 效能的 Indexes

新手到進階學 MongoDB(2)- CRUD 教學

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×