如果你有数据存储在 MongoDB 中,你想做的可能就不仅仅是将数据提取出来那么简单了;你可能希望对数据进行分析并加以利用。
5.1 聚合框架
使用聚合框架可以对集合中的文档进行变换和组合。基本上,可以用多个构件创建一个管道(pipeline),用于对一连串的文档进行处理。这些构件包括筛选(filtering)、投射(projecting)、分组(grouping)、排序(sorting)、限制(limiting)和跳过(skipping)。
例如,有一个保存着杂志文章的集合,你可能希望找出发表文章最多的5个作者。
假设每篇文章被保存为 MongoDB 中的一个文档,可以按照如下步骤创建管道。
(1) 将每个文章文档中的作者投射出来。
(2) 将作者按照名字排序,统计每个名字出现的次数。
(3) 将作者按照名字出现次数降序排列。
(4) 将返回结果限制为前 5 个。
这里面的每一步都对应聚合框架中的一个操作符:
(1) {"$project" : {"author" : 1}}
这样可以将 "author" 从每个文档中投射出来。
这个语法与查询中的字段选择器比较像:可以通过指定 "fieldname" : 1 选择需要投射的字段,或者通过指定 "fieldname" : 0 排除不需要的字段。执行完这个 "$project" 操作之后,结果集中的每个文档都会以 {"_id" : id, "author" : "authorName"} 这样的形式表示。这些结果只会在内存中存在,不会被写入磁盘。
(2) {"$group" : {"_id" : "$author", "count" : {"$sum" : 1}}}
这样就会将作者按照名字排序,某个作者的名字每出现一次,就会对这个作者的"count" 加 1。
这里首先指定了需要进行分组的字段 "author"。这是由 "_id" : "$author"指定的。可以将这个操作想象为:这个操作执行完后,每个作者只对应一个结果文档,所以 "author" 就成了文档的唯一标识符("_id")。
第二个字段的意思是为分组内每个文档的 "count" 字段加 1。注意,新加入的文档中并不会有 "count" 字段;这 "$group" 创建的一个新字段。
执行完这一步之后,结果集中的每个文档会是这样的结构:{"_id" : "authorName", "count" : articleCount}。
(3){"\$sort" : {"count" : -1}}
这个操作会对结果集中的文档根据 "count" 字段进行降序排列。
(4) {"$limit" : 5}
这个操作将最终的返回结果限制为当前结果中的前 5 个文档。
在 MongoDB 中实际运行时,要将这些操作分别传给 aggregate() 函数:
> db.articles.aggregate({"$project" : {"author" : 1}},
... {"$group" : {"_id" : "$author", "count" : {"$sum" : 1}}},
... {"$sort" : {"count" : -1}},
... {"$limit" : 5})
{
"result" : [
{
"_id" : "R. L. Stine",
"count" : 430
},
{
"_id" : "Edgar Wallace",
"count" : 175
},
{
"_id" : "Nora Roberts",
"count" : 145
},
{
"_id" : "Erle Stanley Gardner",
"count" : 140
},
{
"_id" : "Agatha Christie",
"count" : 85
}
],
"ok" : 1
}
aggregate() 会返回一个文档数组,其中的内容是发表文章最多的 5 个作者。
5.2 管道操作符
每个操作符都会接受一连串的文档,对这些文档做一些类型转换,最后将转换后的文档作为结果传递给下一个操作符(对于最后一个管道操作符,是将结果返回给客户端)。
不同的管道操作符可以按任意顺序组合在一起使用,而且可以被重复任意多次。
5.2.1 $match
$match
用于对文档集合进行筛选,之后就可以在筛选得到的文档子集上做聚合。
例 如, 如果想对 Oregon(俄勒冈州,简写为 OR)的用户做统计,就可以使用{$match : {"state" : "OR"}}
。"$match" 可以使用所有常规的查询操作符("$gt"、"$lt"、"$in" 等)。有一个例外需要注意:不能在 "$match" 中使用地理空间操作符。
通常,在实际使用中应该尽可能将 "$match" 放在管道的前面位置。这样做有两个好处:一是可以快速将不需要的文档过滤掉,以减少管道的工作量;二是如果在投射和分组之前执行 "$match",查询可以使用索引。
5.2.2 $project
相对于“普通”的查询而言,管道中的投射操作更加强大。使用 "$project" 可以从子文档中提取字段,可以重命名字段,还可以在这些字段上进行一些有意思的操作。
最简单的一个 "$project" 操作是从文档中选择想要的字段。可以指定包含或者不包含一个字段,它的语法与查询中的第二个参数类似。
也可以将投射过的字段进行重命名。例如,可以将每个用户文档的 "_id" 在返回结果中重命名为 "userId":
> db.users.aggregate({"$project" : {"userId" : "$_id", "_id" : 0}})
{
"result" : [
{
"userId" : ObjectId("50e4b32427b160e099ddbee7")
},
{
"userId" : ObjectId("50e4b32527b160e099ddbee8")
}
...
],
"ok" : 1
}
这里的 "$fieldname" 语法是为了在聚合框架中引用 fieldname 字段(上面的例子中是 "_id")的值。例如,"$age" 会被替换为 "age" 字段的内容(可能是数值,也可能是字符串),"$tags.3" 会被替换为 tags 数组中的第 4 个元素。所以,上面例子中的 "$_id" 会被替换为进入管道的每个文档的 "_id" 字段的值。
1. 管道表达式
最简单的 "$project" 表达式是包含和排除字段,以及字段名称("$fieldname")。但是,还有一些更强大的选项。也可以使用表达式(expression)将多个字面量和变量组合在一个值中使用。
在聚合框架中有几个表达式可用来组合或者进行任意深度的嵌套,以便创建复杂的
表达式。
2. 数学表达式(mathematical expression)
算术表达式可用于操作数值。指定一组数值,就可以使用这个表达式进行操作了。
例如,下面的表达式会将 "salary" 和 "bonus" 字段的值相加。
> db.employees.aggregate(
... {
... "$project" : {
... "totalPay" : {
... "$add" : ["$salary", "$bonus"]
... }
... }
... })
可以将多个表达式嵌套在一起组成更复杂的表达式。
假设我们想要从总金额中扣除为 401(k)1 缴纳的金额。可以使用 "$subtract" 表达式:
> db.employees.aggregate(
... {
... "$project" : {
... "totalPay" : {
... "$subtract" : [{"$add" : ["$salary", "$bonus"]}, "$401k"]
... }
... }
... })
下面是每个操作符的语法:
- "$add" : [expr1[, expr2, ..., exprN]]
这个操作符接受一个或多个表达式作为参数,将这些表达式相加。 - "$subtract" : [expr1, expr2]
接受两个表达式作为参数,用第一个表达式减去第二个表达式作为结果。 - "$multiply" : [expr1[, expr2, ..., exprN]]
接受一个或者多个表达式,并且将它们相乘。 - "$divide" : [expr1, expr2]
接受两个表达式,用第一个表达式除以第二个表达式的商作为结果。 - "$mod" : [expr1, expr2]
接受两个表达式,将第一个表达式除以第二个表达式得到的余数作为结果。
3. 日期表达式(date expression)
聚合框架中包含了一些用于提取日期信息的表达式:"$year"、"$month"、"$week"、"$dayOfMonth"、"$dayOfWeek"、"$dayOfYear"、"$hour"、"$minute" 和 "$second"。只能对日期类型的字段进行日期操作,不能对数值类型字段做日期操作。
每种日期类型的操作都是类似的:接受一个日期表达式,返回一个数值。
下面的代码会返回每个雇员入职的月份:
> db.employees.aggregate(
... {
... "$project" : {
... "hiredIn" : {"$month" : "$hireDate"}
... }
... })
也可以使用字面量日期。下面的代码会计算出每个雇员在公司内的工作时间:
> db.employees.aggregate(
... {
... "$project" : {
... "tenure" : {
... "$subtract" : [{"$year" : new Date()}, {"$year" : "$hireDate"}]
... }
... }
... })
4. 字符串表达式(string expression)
也有一些基本的字符串操作可以使用,它们的签名如下所示。
- "$substr" : [expr, startOffset, numToReturn]
其中第一个参数 expr 必须是个字符串,这个操作会截取这个字符串的子串(从第 startOffset 字节开始的 numToReturn 字节,注意,是字节,不是字符。在多字节编码中尤其要注意这一点)expr 必须是字符串。 - "$concat" : [expr1[, expr2, ..., exprN]]
将给定的表达式(或者字符串)连接在一起作为返回结果。 - "$toLower" : expr
参数 expr 必须是个字符串值,这个操作返回 expr 的小写形式。 - "$toUpper" : expr
参数 expr 必须是个字符串值,这个操作返回 expr 的大写形式。
下 面 是 一 个 生 成 [email protected] 格 式 的 email 地 址 的 例 子。 它 提 取"$firstname" 的第一个字符,将其与多个常量字符串和 "$lastname" 连接成一个字符串:
> db.employees.aggregate(
... {
... "$project" : {
... "email" : {
... "$concat" : [
... {"$substr" : ["$firstName", 0, 1]},
... ".",
... "$lastName",
... "@example.com"
... ]
... }
... }
... })
5. 逻辑表达式(logical expression)
有一些逻辑表达式可以用于控制语句。
下面是几个比较表达式。
- "$cmp" : [expr1, expr2]
比较 expr1 和 expr2。如果 expr1 等于 expr2,返回 0;如果 expr1 < expr2,返回一个负数;如果 expr1 >expr2,返回一个正数。 - "$strcasecmp" : [string1, string2]
比较 string1 和 string2,区分大小写。只对罗马字符组成的字符串有效。 - "$eq"/"$ne"/"$gt"/"$gte"/"$lt"/"$lte" : [expr1, expr2]
对 expr1 和 expr2 执行相应的比较操作,返回比较的结果(true 或 false)。
下面是几个布尔表达式。
- "$and" : [expr1[, expr2, ..., exprN]]
如果所有表达式的值都是 true,那就返回 true,否则返回 false。
• "$or" : [expr1[, expr2, ..., exprN]]
只要有任意表达式的值为 true,就返回 true,否则返回 false。 - "$not" : expr
对 expr 取反。
还有两个控制语句。 - "$cond" : [booleanExpr, trueExpr, falseExpr]
如果 booleanExpr 的值是 true,那就返回 trueExpr,否则返回 falseExpr。 - "$ifNull" : [expr, replacementExpr]
如果 expr 是 null,返回 replacementExpr,否则返回 expr。
通过这些操作符,就可以在聚合中使用更复杂的逻辑,可以对不同数据执行不同的代码,得到不同的结果。
假如有个教授想通过某种比较复杂的计算为学生打分:出勤率占 10%,日常测验成绩占 30%,期末考试占 60%(如果是老师最宠爱的学生,那么分数就是 100)。可以使用如下代码:
> db.students.aggregate(
... {
... "$project" : {
... "grade" : {
... "$cond" : [
... "$teachersPet",
... 100, // if
... { // else
... "$add" : [
... {"$multiply" : [.1, "$attendanceAvg"]},
... {"$multiply" : [.3, "$quizzAvg"]},
... {"$multiply" : [.6, "$testAvg"]}
... ]
... }
... ]
... }
... }
... })
5.2.3 $group
$group 操作可以将文档依据特定字段的不同值进行分组。
如果选定了需要进行分组的字段,就可以将选定的字段传递给 "$group" 函数的
"_id" 字段。
如果我们以分钟作为计量单位,希望找出每天的平均湿度,就可以根据 "day" 字段进行分组。
{"$group" : {"_id" : "$day"}}
1. 分组操作符
这些分组操作符允许对每个分组进行计算,得到相应的结果。"$sum"分组操作符的作用:分组中每出现一个文档,它就对计算结果加 1,这样便可以得到每个分组中的文档数量。
2. 算术操作符
有两个操作符可以用于对数值类型字段的值进行计算:"$sum" 和 "$average"。
- "$sum" : value
对于分组中的每一个文档,将 value 与计算结果相加。注意,上面的例子中使用了一个字面量数字 1,但是这里也可以使用比较复杂的值。
例如,如果有一个集合,其中的内容是各个国家的销售数据,使用下面的代码就可以得到每个国家的总收入:
> db.sales.aggregate(
... {
... "$group" : {
... "_id" : "$country",
... "totalRevenue" : {"$sum" : "$revenue"}
... }
... })
- "$avg" : value
返回每个分组的平均值。
例如,下面的代码会返回每个国家的平均收入,以及每个国家的销量:
> db.sales.aggregate(
... {
... "$group" : {
... "_id" : "$country",
... "totalRevenue" : {"$avg" : "$revenue"},
... "numSales" : {"$sum" : 1}
... }
... })
3. 极值操作符(extreme operator)
下面的四个操作符可用于得到数据集合中的“边缘”值。
- "$max" : expr
返回分组内的最大值。 - "$min" : expr
返回分组内的最小值。 - "$first" : expr
返回分组的第一个值,忽略后面所有值。只有排序之后,明确知道数据顺序时这个操作才有意义。 - "$last" : expr
与 "$first" 相反,返回分组的最后一个值。
假设有一个存有学生考试成绩的数据集,需要找到其中的最高分与最低分:
> db.scores.aggregate(
... {
... "$group" : {
... "_id" : "$grade",
... "lowestScore" : {"$min" : "$score"},
... "highestScore" : {"$max" : "$score"}
... }
... })
4. 数组操作符
有两个操作符可以进行数组操作。
- "$addToSet" : expr
如果当前数组中不包含 expr ,那就将它添加到数组中。在返回结果集中,每个元素最多只出现一次,而且元素的顺序是不确定的。 - "$push" : expr
不管 expr 是什么值,都将它添加到数组中。返回包含所有值的数组。
5.2.4 $unwind
拆分(unwind)可以将数组中的每一个值拆分为单独的文档。
例如,如果有一篇拥有多条评论的博客文章,可以使用 $unwind 将每条评论拆分为一个独立的文档:
> db.blog.findOne()
{
"_id" : ObjectId("50eeffc4c82a5271290530be"),
"author" : "k",
"post" : "Hello, world!",
"comments" : [
{
"author" : "mark",
"date" : ISODate("2013-01-10T17:52:04.148Z"),
"text" : "Nice post"
},
{
"author" : "bill",
"date" : ISODate("2013-01-10T17:52:04.148Z"),
"text" : "I agree"
}
]
}
> db.blog.aggregate({"$unwind" : "$comments"})
{
"results" :
{
"_id" : ObjectId("50eeffc4c82a5271290530be"),
"author" : "k",
"post" : "Hello, world!",
"comments" : {
"author" : "mark",
"date" : ISODate("2013-01-10T17:52:04.148Z"),
"text" : "Nice post"
}
},
{
"_id" : ObjectId("50eeffc4c82a5271290530be"),
"author" : "k",
"post" : "Hello, world!",
"comments" : {
"author" : "bill",
"date" : ISODate("2013-01-10T17:52:04.148Z"),
"text" : "I agree"
}
}
],
"ok" : 1
}
5.2.5 $sort
可以根据任何字段(或者多个字段)进行排序,与在普通查询中的语法相同。
如果要对大量的文档进行排序,强烈建议在管道的第一阶段进行排序,这时的排序操作可以使用索引。否则,排序过程就会比较慢,而且会占用大量内存。
可以在排序中使用文档中实际存在的字段,也可以使用在投射时重命名的字段:
> db.employees.aggregate(
... {
... "$project" : {
... "compensation" : {
... "$add" : ["$salary", "$bonus"]
... },
... "name" : 1
... }
... },
... {
... "$sort" : {"compensation" : -1, "name" : 1}
... })
这个例子会对员工排序,最终的结果是按照报酬从高到低,姓名从 A 到 Z 的顺序排列。
排序方向可以是 1(升序)和 -1(降序)。
大部分操作符的工作方式都是流式的,只要有新文档进入,就可以对新文档进行处理,但是 "$group" 必须要等收到所有的文档之后,才能对文档进行分组,然后才能将各个分组发送给管道中的下一个操作符。这意味着,在分片的情况下,"$group" 会先在每个分片上执行,然后各个分片上的分组结果会被发送到 mongos 再进行最后的统一分组,剩余的管道工作也都是在 mongos(而不是在分片)上运行的。"$sort" 也是一个无法使用流式工作方式的操作符。"$sort" 也必须要接收到所有文档之后才能进行排序。在分片环境下,先在各个分片上进行排序,然后将各个分片的排序结果发送到 mongos 做进一步处理。
5.2.6 $limit
$limit 会接受一个数字 n,返回结果集中的前 n 个文档。
5.2.7 $skip
$skip 也是接受一个数字 n,丢弃结果集中的前 n 个文档,将剩余文档作为结果返回。