前言

在看完mongodb的官方文档之后,还是有点一头雾水,对于一些相关的查询以及聚合管道很多不能够信手拈来,而且查询文档的时候也发现难以入手,本文主要针对相关的查询选择器以及聚合管道操作进行分类筛选,让自己对mongodb所提供的操作 🈶 一定的概念,然后通过这个分类来进行查询使用,加深理解印象!

mongodb中的查询语句分类

一切以db.collections.find()方法入手,mongodb给我们抽象出来了这个统一的查询入口
mongodb中,我们可以通过mongosh程序,通过db.collections.method不带括号的方式来查看一个方法的描述,如下图所示;
db.collections.find方法查看
🌟 通过这种方式,可以查看到该方法的定义与返回值等相关信息

在开始详细介绍这个查询过滤器分类之前,先看一下 👇 的一个分类结构图:
Mongodb中的查询一览
👽 从上图可以看出,针对不同的查询筛选操作,进行了以下对应类目的分类:

  1. 比较/范围筛选
  2. 逻辑操作
  3. 属性匹配
  4. 数组匹配
  5. 地理位置运算匹配
  6. 数学运算匹配
  7. 投影相关
  8. 位运算匹配
  9. 其他匹配操作

比较/范围筛选

主要用来筛选某个字段是否满足大于小于相等不等于大于等于小于等于等条件
🌠 语法规则(以$gt为例)

1
db.users.find({ <field>: { $eq: <value> } })

👉 查询users表中的field属性值等于value值的数据集合

所有的比较/范围筛选一览:

操作符号 描述
$eq 筛选出等于某个值的数据集合
$gt 筛选出大于某个值的数据集合
$gte 筛选出大于等于某个值的数据集合
$lt 筛选出小于某个值的数据集合
$lte 筛选出小于等于某个值的数据集合
$in 筛选出在某个数组集合中出现过的数据集合
$ne 筛选出不等于某个值的数据集合
$nin 晒选出不在某个数组集合中出现过的数据集合

逻辑操作

主要用来**”连接”**多个不同的筛选条件或者单独取反操作,可支持逻辑与逻辑或逻辑非逻辑不操作
🌠 语法规则(以$and为例)

1
db.users.find({ $and: [ {<expression1>}, {<expression2>}, ..., {<expressionN>} ] })

👉 上述语法中的<expression1>代表一个boolean执行结果的表达式,也就是同时满足多个不同的条件的表达式,这里的表达式可以是其他的筛选条件结果,比如有以下的使用情况:

1
2
3
4
5
6
db.users.find({
$and: [
{ userName: { $eq: 'koby' } },
{ age: { $gt: 18 } }
]
})

👉 用来查询users表中userName=koby并且age大于18岁的用户集合!

所有的逻辑操作一览

操作符号 描述
$and 同时满足多个条件的数据集合
$not 不满足某个条件的数据集合
$nor 同时不满足多个条件的数据集合
$or 只要有一个条件满足的数据集合

属性匹配

主要基于数据表字段或者类型的筛选条件,比如存在某个属性或者某个字段的类型判断逻辑
🌠 语法规则

1
2
db.users.find({<field>: {$exists: <boolean>}});
db.users.find({<field>: {$type: <BSON type>}})

👉 上述语句一主要查询users表中是否存在某个字段field,语句二则是查询user表中某个字段field的类型是否为某个BSON类型的数据

所有的属性匹配一览

操作符号 描述
$exists 存在某个属性的数据集合
$type 存在某个BSON类型的字段值所组成的数据集合

数组匹配

主要基于数据表中的数组类型字段进行匹配查询操作
🌠 语法规则

1
2
3
db.users.find({<field>: {$all: [<value1>, <value2>, ... <valueN>]}})
db.users.find({<field>: {$elemMatch: {<query1>, {query2}, ..., {queryN}}}})
db.users.find({<field>: {$size: 3}})

👉 上述语句一主要查询数组类型属性field的值是否都在value1value2valueN中出现到,如果是的话,则将其所在的记录添加到查询结果集合中。
语句二则是针对数组属性field中的元素进行query过滤筛选操作,也就是将数组属性中集合中的对象进行query匹配操作。
语句三则是针对数组属性field中元素长度等于3的记录,加入到集合中。

所有的数组匹配一览

操作符号 描述
$all 针对某个数组类型的字段进行全亮匹配筛选,全部包含才将所在记录加入到结果集中
$elemMatch 针对数组属性中的元素进行query匹配操作,符合条件所在记录加入到结果集合中
$size 针对数组属性长度为size的记录,加入到结果集合中

地理位置运算筛选

数学运算匹配

通过数学运算求值操作,符合条件的情况,加入到查询结果集中!
🌠 语法规则

1
2
3
4
5
6
db.users.find({$expr: {<expresstion>]}})
db.users.find({$jsonSchema: {JSON Schema Object}})
db.users.find({$mod: {divisor, remainder}})
db.users.find({$regex: /pattern/})
db.users.find({$where: <string|jscode>})
db.users.find({$text: {$search:<string>}})

👉 语句一($expr)用于执行部分聚合管道操作。
语句二($jsonSchema)用于验证待查询的数据是否满足某个jsonSchema说明对象。
语句三($mod)用于过滤某个值是否能够被divisor求余,且余数为remainder。
语句四($regex)用于正则匹配某个字段是否满足某个正则表达式。
语句五($where)用户执行字符串表达式或者一个js函数,用于动态执行结果的集合判断。

投影相关

投影运算符将指定操作返回的字段。
🌠 语法规则如下

1
db.collection.find(query, projection)

🌟 这里query为查询条件,用于过滤文档,而projection是投影操作符,用于指定要返回的字段,通过在projection对象中指定某些属性是否为1来控制是否查询返回结果中是否包含对应的字段!

🌠 <array>.$语法规则如下

1
db.collection.find(query, {"<field>.$": 1})

👉 代表查询每个文档中的field数组中的第一个元素!

🌠 $elemMatch语法规则如下

1
db.collection.find({<field>: {$elemMatch: {condition1, condition2, ...}}})

👉 可以在查询中用于指定对数组中的元素应用多个条件,从而筛选出对应的结果集合,可以理解为对记录中的数组字段进行瘦身,使得查询结果中仅返回符合搜索结果的结果!

比如有以下的一个文档:

1
2
3
4
5
6
7
8
9
{
"_id": 1,
"scores": [
{"type": "exam", "score": 80},
{"type": "quiz", "score": 85},
{"type": "homework", "score": 88},
{"type": "homework", "score": 92},
]
}

👉 如果我们想查找scores数组中type为”homework”并且score大于等于90的元素,可以使用elemMatch对数组进行投影瘦身操作:

1
2
3
4
5
6
db.collection.find({
$elemMatch: {
type: "homework",
score: {$gte: 90}
}
})

👉 将最终返回以下的结果:

1
2
3
4
5
6
{
"_id": 1,
"scores": [
{"type": "homework", "score": 92}
]
}

😕 这里我们可以看到scores属性被瘦身了,仅返回符合条件的查询结果!

🌠 $slice语法规则如下

1
2
db.collection.find(<query>, {<arrayField>: {$slice: <number>}})
db.collection.find(<query>, {<arrayField>: {$slice: [<numer to skip>, <number to return>]}})

👉 对查询结果中的arrayField属性进行裁剪,仅保留前number个元素集合

位运算匹配

其他匹配操作

  1. $comment: 添加注释到查询的位次,使得配置文件数据更加容易解释和跟踪;
    其语法规则如下:

    1
    db.collection.find({<query>, $comment: <comment>})

    🌠 这里将用comment字符串来说明这个查询的过滤动作。

  2. $rand: 生成一个0到1之间的随机小数数字;
    其语法规则如下:

    1
    db.collection.find({$expr: {$lt: [0.5, {$rand: {}}]}})

    🌠 这里将生成一个0到1的小数,使得聚合管道能够正常的运行!

mongodb中的聚合管道分类

相关概念

在开始整理关于这个mongoose的聚合管道之前,我们先来了解一下什么是聚合管道:聚合管道是对db查询的一个补充,也可以通过聚合管道来转换或者合并操作来生成新的文档属性,并提供对基础数据的查询与筛选操作,
聚合管道一般有多个stage组成,每个stage之间通过pipe连接而成,而且上一个stage的输出结果将作为下一个stage的输入,最终输出结果!
最终实现将多个文档的值分组在一起,也可以对分组数据执行操作以返回单个结果(比如总计、平均值、最大值和最小值等等),还可以分析数据随时间的变化情况!
其执行过程如下图所示:
聚合管道的执行过程

正常情况下,使用了聚合管道将不会改变到原始的document,除非使用了$merge或者$out

使用方式

聚合管道以aggregate()方法开始,通过接收一数组作为参数,来实现多个不同的stage之间的连接

1
2
3
4
5
db.collections.aggregate([
{ $match: ... },
{ $group: ... },
{ $sort: ... }
], options)

🌟 这里的数组中的每一对象都代表一个个的stage操作,一般情况下,都会针对文档中的某些属性进行相关的逻辑操作,采用$属性名的方式进行属性值的直接访问,采用$$ROOT代表对整个文档记录的访问,采用$$属性名则代表在聚合管道过程中使用了变量,对变量的值的访问!

聚合管道的分类

mongodb中提供了不同类型的管道来对文档进行操作,基本上体现为对文档的重塑操作
mongodb聚合管道一览

从上图我们可以看出mongodb给我们提供了不少的管道操作,这里我们可以认为它给我们提供了不同的函数API,我们不用去深入了解到每个API具体的内容,我们所需要做的是了解知道都有哪些API,可以通过这些API来达到哪些方向的实现即可!!!

👇 我们来进行其中一些常见的聚合管道进行分析

聚合表达式操作符

在开始分析这个聚合管道之前,先来了解一波关于这个聚合操作符👉 与其说是聚合操作符,不如说是聚合操作函数,通过提供类似于js函数调用的方式来提供属性的值,其语法规则如下:

1
2
3
4
5
6
7
8
// 接收单个参数
{
<operator>: <argument>
}
// 接收多个参数
{
<operator>: [<argument1>, <argument2>, ...]
}

🌟 关于mongodb中的聚合操作符, 👇 下面整理了不同类型的聚合操作清单列表:
聚合操作符一览
关于其中具体的操作符的使用,可通过官方文档进行检索使用!

部分聚合管道使用

由于mongodb中提供了太多的聚合管道(也就是一系列的函数),因此这边仅针对实际上可能经常使用到的聚合管道进行一个简单的说明。

$project

通过接收一个文档对象,指定传递给下一个stage所包含的字段,字段来源可以是原始字段,也可以是自定义计算出来的新字段

语法规则:

1
2
3
db.collections.aggregation([
{ $project: { <specifications(s)> } }
])

而关于其中的specifications可以有以下的参数形式:

表达式 描述
<field>: <1 or true> 指定原始文档的field将作为下一个管道的文档参数
<field>: <0 or false> 排除某个字段field
<field>: <expression> 添加/替换新的field,其值将由表达式expression来结果来提供
$group

根据标识符(后面我们称之为主键)将文档进行分组,主键通常是一个字段或者是一组字段的表示结果

语法规则:

1
2
3
4
5
6
7
8
9
db.collections.aggregation([
{
$group: {
_id: <expression>, //主键
<field1>: { <accumulator1>: <expression1> },
...
}
}
])

🌟 上述的_id是必须的主键,代表分组结果的唯一id,假如将这个_id赋值为null的话,则代表该阶段将返回一个聚合文档中的值的单个文档,然后这个field1代表为自定义的属性,其属性值可由聚合操作符以及对应的聚合操作表达式来配合提供!!
注意观察上述中的accumulator代表的是运算符的累加器,该累加器可以是其他的聚合操作符, 🈶 以下对应的聚合操作符允许使用:

聚合操作符 描述
$accumulator 用户自定义累加器函数
$addToSet 返回每个组的唯一表达式值的数组
$avg 返回某个数值字段的平均值
$bottom、$bottomN、$top、topN 根据排序顺序,返回指定组内底部/顶部元素
$bottomN $bottom,只不过返回其中的N个数据
$count 返回组中文档数
$first、$firstN、$last、$lastN 返回组内的第一/最后一个或N个元素的聚合
$max、$maxN、$min、$median 返回每个组中的最大、最大N个、最小、中位数元素或元素的集合
$mergeObjects 组合输入的多个文档来创建新的一个文档
$percentile 返回与指定百分位值相对应的数组集合
$push 返回每组文档的表达式值数组
$sum 字段值的累加结果
$stdDevPop、$stdDevSamp 总体标准差、样本标准差
$count

对文档进行计数操作,并返回对应的属性,有点类似于$group + $project两者的结合
语法规则:

1
2
3
db.collections.aggregation([
{ $count: <property> }
])

🌟 这里的property代表即将输出的结果为{ property: "数量" }

$match

过滤文档,将其指定匹配的条件的文档传递到下一个stage
语法规则:

1
2
3
db.collections.aggregation([
{ $match: { <query> } }
])

🌟 这里的query与普通的db查询过滤筛选动作一致!

$merge

将聚合管道的结果输出到集合中,可以来自同个数据的集合,也可以来自于不同数据库的集合中,因此此stage必须为聚合操作中的最后一个stage
语法规则:

1
2
3
4
5
6
7
8
9
10
11
db.collections.aggregation([
{
$merge: {
into: <collection> or { db: <db>, coll: <collection> },
on: <identifier field> or [<identifier field1>, ...],
let: <variables>,
whenMatched: <replace|keepExisting|merge|fail|pipeline>,
whenNotMatched: <insert|discard|fail>
}
}
])

😕 这里的语法规则比较复杂, 👇 将列举一个例子进行说明一下:
比如有一个存储了用户购买历史的集合historys:

1
2
3
4
5
[
{ "_id": 1, "user": "Alice", "totalPurchase": 100 },
{ "_id": 2, "user": "Bob", "totalPurchase": 150 },
{ "_id": 3, "user": "Alice", "totalPurchase": 50 }
]

接下来,我们将使用$group来计算每个用户的总购买额,然后使用$merge将结果存储到一个新的集合中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
db.historys.aggregation([
{
$group: {
_id: '$user',
totalPurchase: { $sum: '$totalPurchase' }
}
},
{
$merge: {
into: 'userPurchaseSummary',
whenMatched: 'merge',
whenNotMatched: 'insert'
}
}
])

👉 这里将结果存储至userPurchaseSummary表中,当该表中并没有一样的_id主键时,则直接执行whenNotMatched=insert操作,如果有匹配的一样的_id主键,则将两者进行值的合并,并替换到新的结果记录中!

在上述的语法规则中有另外的两个参数onleton参数用于指定在目标集合中匹配文档的条件,一般是一个表达式,用于指定源文档与目标文档对比条件,一般默认是_id, 代表将使用两个collection中的_id来进行做对比

$bucket

用于将文档根据指定的范围或条件分为多个桶(buckets),每个桶表示一个区间或满足一定条件的文档集合,只为包含至少一个输入文档的存储桶生成输出文档,这对于对数据进行分组并进行统计分析非常有用。
语法规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
db.collections.aggregation([
{
$bucket: {
groupBy: <expression>,
boundaries: [<lowerbound1>, <lowerbound2>, ...],
default: <literal>,
output: {
<output1>: { <$accumulator expression> },
...
<outputN>: { <$accumulator expression> }
}
}
}
])

🌟 上述语法规则中各参数说明:

  • groupBy: 指定用于分组的字段或表达式;
  • boundaries: 指定用于定义桶的范围的边界值数组,比如[0, 5, 10],代表将拆分为两个桶(分别是:[0, 5),[5, 10] );
  • default: 可选参数,当文档不匹配任何桶时将使用的默认值;
  • output: 指定每个桶的输出字段,可以包含聚合操作符来对桶内的文档进行进一步的计算

👇 有一个简单的例子,假设我们有一个存储了学生成绩的集合:

1
2
3
4
5
6
7
[
{ "_id": 1, "name": "Alice", "score": 85 },
{ "_id": 2, "name": "Bob", "score": 92 },
{ "_id": 3, "name": "Charlie", "score": 75 },
{ "_id": 4, "name": "David", "score": 88 },
{ "_id": 5, "name": "Eva", "score": 78 }
]

👉 这里我们将使用$bucket将成绩分成不同的桶,比如,根据分组所达到的分值,将其拆分为[0-60), [60-70), [70-80), [80-90), [90-100] 等范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
db.collections.agregation([
{
$bucket: {
groupBy: '$score',
boundaries: [0, 60, 70, 80, 90, 100],
default: 'Other',
output: {
count: { $sum: 1 },
students: { $push: '$name' }
}
}
}
])

👉 这里我们使用$bucket将按照学生成绩字段score进行分组,default是一个可选的参数,表示当文档不匹配任何的桶的时候,使用’Other’作为默认值,output则定义了每个桶的输出,包括数量和学生姓名数组

👇 输出的结果可能如下:

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
[
{
"_id": 0,
"count": 1,
"students": ["Charlie"],
"min": 75,
"max": 80
},
{
"_id": 1,
"count": 2,
"students": ["Eva", "Alice"],
"min": 80,
"max": 90
},
{
"_id": 2,
"count": 2,
"students": ["David", "Bob"],
"min": 90,
"max": 92
},
{
"_id": 3,
"count": 0,
"students": [],
"min": 92,
"max": 100
},
{
"_id": 4,
"count": 0,
"students": [],
"min": 100,
"max": Infinity
}
]
$lookup

用于在聚合管道中执行类似于关系型数据库中的左连接操作,这将允许我们从其他集合中获取相关联的文档,并将他们合并到当前文档中,这样子就可以在聚合操作中实现在多个集合之间的联结。
语法规则:

1
2
3
4
5
6
7
8
9
10
db.collections.aggregation([
{
$lookup: {
from: <collection>,
localField: <field>,
foreignField: <field>,
as: <outputArray>
}
}
])

相关参数的说明如下:

  • from: 指定要连接的目标集合名称;
  • localField: 在当前集合中的用于在目标集合中匹配的字段;
  • foreignField: 目标集合中的字段,用于与当前集合中的localField进行匹配;
  • as: 指定输出结果中的数组字段,用于存储匹配的文档。

👇 有一个简单的例子,假设有两个集合,一个存储了订单信息,另外一个存储了用户信息

1
2
3
4
5
6
7
8
9
10
11
// orders 集合
[
{ "_id": 1, "product": "A", "customerId": 101 },
{ "_id": 2, "product": "B", "customerId": 102 },
{ "_id": 3, "product": "C", "customerId": 101 }
]
// customers 集合
[
{ "_id": 101, "name": "Alice" },
{ "_id": 102, "name": "Bob" }
]

👉 这里我们可以使用$lookup来将订单信息中的customerId与用户信息中的_id进行关联:

1
2
3
4
5
6
7
8
9
10
db.orders.aggregation([
{
$lookup: {
from: 'customers',
localField: 'customerId',
foreignField: '_id',
as: 'customerArray'
}
}
])

👇 其输出结果:

1
2
3
4
5
[
{ "_id": 1, "product": "A", "customerId": 101, "customerArray": [{ "_id": 101, "name": "Alice" }] },
{ "_id": 2, "product": "B", "customerId": 102, "customerArray": [{ "_id": 102, "name": "Bob" }] },
{ "_id": 3, "product": "C", "customerId": 101, "customerArray": [{ "_id": 101, "name": "Alice" }] }
]

🌠 在上面这个例子中,输出的文档结果中customerArray数组包含了与订单关联的客户信息,如果没有匹配的文档,则customerArray将会是一个空的数组!

😕 这里假如我反过来的话,应该可以实现一个用户下了什么订单的目的:

1
2
3
4
5
6
7
8
9
10
db.customers.aggregation([
{
$lookup: {
from: 'orders',
localField: '_id',
foreignField: 'customerId',
as: 'orderArray'
}
}
])

👇 将会是这样子的输出结果:

1
2
3
4
5
6
7
8
9
[
{ "_id": 101, "name": "Alice", "orderArray": [
{ "_id": 1, "product": "A", "customerId": 101 },
{ "_id": 3, "product": "C", "customerId": 101 }
] },
{ "_id": 102, "name": "Bob", "orderArray": [
{ "_id": 2, "product": "B", "customerId": 102 },
] }
]
$unwind

用于将文档中的数组字段给”展开”,当某个文档中包含一个数组字段,而我们希望将这个数组中的每一个元素都给展开的话,就可以直接使用这个$unwind
语法规则:

1
2
3
4
5
6
7
8
9
db.collections.aggregation([
{
$unwind: {
path: <arrayField>,
includeArrayIndex: <string>,
preserveNullAndEmptyArrays: <boolean>
}
}
])

相关参数的说明如下:

  • path: 指定要展开的数组字段;
  • includeArrayIndex: 可选,指定一个新字段名,用于存储数组的索引位置,如果不需要索引,则可以直接忽略;
  • preserveNullAndEmptyArrays: 可选,当设置为true时,如果要展开的数组字段不存在或为空,则继续保留空数组字段

聚合管道使用思考

😕 既然聚合管道可以理解一个个串联起来的待执行函数,那么如果数据量一旦大的话,数据库的执行效率将有很大的限制因素,因此需要针根据实际情况,仅进行相关顺序的控制, 👇 整理了相关情况下的一个stage执行顺序的声明:

  1. 对于需要筛选过滤后再执行的管道,采用$match,且必须将这个$match给放置在第一的位置,因此可以筛掉很大一部分数据,为后续其他stage的执行获得了较大的性能提升空间;
  2. 对于需要将聚合结果怼到另外一个collection中的情况,需要将使用$merge,并且还需要将其作为最后的一个stage来使用;

查询之后的操作

😕 关于这个db查询以及管道聚合操作之后,返回的什么呢?
👉 两者函数执行成功后,都返回的一cursor对象,那么该对象可以用来做什么?也就是该对象都有哪些操作?方便我们对结果数据进行进一步处理!
👇 整理了关于cursor的相关操作,如下图所示:
mongodb中的cursor方法一览

👆 这里所列举的是在mongosh中针对游标直接调用的相关API,而在实际的项目中,则由各个开发语言驱动来提供对应的API来对游标进行访问!!