本篇文章是系列文新手到進階學 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,裡頭裡存放了所有商品的資訊。當我們想要知道「最貴的商品是什麼」時,我們有兩種做法:
- 把所有的商品都查詢出來,再在應用程式中找出價錢最高的商品
- 透過撰寫 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
| db.products.aggregate([ 找出所有「科技類」商品, 找到其中「價錢最高」的商品 ])
|
再來我們會介紹幾個常見的 stage 以及應用方法。
常見的資料操作方法(ㄧ)
首先要介紹$match
、$sort
、$limit
這三個方法。
$match
用來找出符合需求條件的資料。用法就跟上一篇介紹的find
很像,是最常用到 Aggregation 方法之一。
假如有一個articles
collection 資料如下:
articles1 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-11 2 3 4 5
| db.articles.aggregate( [ { $match : { author : "dave" } } ] );
|
會得出 2 筆結果
example-1-result1 2
| { "author" : "dave", "score" : 80, "views" : 100 , "_id" : ObjectId("512bc95fe835e68f199c8686") } { "author" : "dave", "score" : 85, "views" : 521 , "_id" : ObjectId("512bc962e835e68f199c8687") }
|
搭配邏輯符號
在使用$match
時也常搭配上邏輯符號,如$or
、$gt
、$lt
,能夠更精準的描述想要的資料。
下面的範例在articles
collection 中找出「分數大於 80、小於 95」或「觀看次數大於(等於) 1000」的文章:
example-21 2 3
| db.articles.aggregate( [ { $match: { $or: [ { score: { $gt: 80, $lt: 95 } }, { views: { $gte: 1000 } } ] } }, ] );
|
會得出 5 筆結果
example-2-result1 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 常常搭配在一起使用。
同樣以上面articles
collection 為例子,我們可以用$sort
與$limit
找出「分數最高的三篇文章」:
example-31 2 3 4 5 6 7 8
| db.articles.aggregate( [ {"$sort": { "score": -1, }}, {"$limit": 3} ] );
|
會得出 3 筆結果
example-3-result1 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 已經存在的欄位、也可以用來創造出新的欄位。
假如有一個books
collection 資料如下:
books1 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-41 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| db.books.aggregate( [ { $project: { title: true, lastModified: true, authorName: { $concat: [ "$author.first", ' ', "$author.last" ] } } } ] )
|
產出結果如下:
example-4-result1 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 中的陣列資料「攤平」。聽起來好像很抽象,不如直接來看個例子。
假如有一個紀錄產品庫存的inventory
collection 資料如下:
inventory1 2
| { "_id" : 1, "item" : "shirt", "sizes": [ "S", "M", "L"] } { "_id" : 2, "item" : "shoes", "sizes": ["M"] }
|
我們嘗試針對 sizes 這個陣列欄位使用$unwind
:
example-51
| db.inventory.aggregate( [ { $unwind: "$sizes" } ] )
|
因為第一筆 document 中 sizes 陣列有三個值,所以攤平後會得到三筆資料。結果如下:
example-5-result1 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 「分組」,還可以根據分組結果做數學運算。是非常好用的工具之一。
假如有一個記錄銷售的sales
collection 資料如下:
sales1 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-61 2 3 4 5 6 7 8
| db.sales.aggregate([ { $group : { _id : "$item", totalSaleQuantity: { $sum: "$quantity" } } } ])
|
得出 4 種不同 item 以及各別賣出的總數量:
example-6-result1 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 的綜合練習。
假如有一個訂單的order
collection 如下。每筆 order 都包含顧客 id
、訂單日期
跟金額
三個欄位。
我們的目標是找出在 2020 年間,每位顧客的:
- 第一筆訂單時間
- 訂單的總數
- 訂單的總金額
order1 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 :
match1 2 3 4 5 6 7 8
| { "$match": { "orderdate": { "$gte": ISODate("2020-01-01T00:00:00Z"), "$lt": ISODate("2021-01-01T00:00:00Z"), }, } },
|
再來,由於我們要找出「每個使用者」的「第一筆訂單時間」,可以先使用$sort
把所有訂單依照日期「由先而後」排序:
sort1 2 3 4 5
| { "$sort": { "orderdate": 1, } }
|
最後,我們使用$group
把訂單依照顧客 id
做分組,並搭配:
$first
:找出每個顧客的第一筆訂單。因為先前已經 sort 過,所以第一筆訂單就是「時間最早」的訂單
$sum
:計算出訂單總數、訂單總金額
group1 2 3 4 5 6 7 8
| { "$group": { "_id": "$customer_id", "first_purchase_date": {"$first": "$orderdate"}, "total_value": {"$sum": "$value"}, "total_orders": {"$sum": 1}, } }
|
我們把上述三個 stage 組裝成 pipeline,對order
collection 進行 aggregate 操作:
example-71 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| db.orders.aggregate([ {"$match": { "orderdate": { "$gte": ISODate("2020-01-01T00:00:00Z"), "$lt": ISODate("2021-01-01T00:00:00Z"), }, }}, {"$sort": { "orderdate": 1, }}, {"$group": { "_id": "$customer_id", "first_purchase_date": {"$first": "$orderdate"}, "total_value": {"$sum": "$value"}, "total_orders": {"$sum": 1}, }}, ]);
|
得出結果:
example-7-result1 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
。
評論