MongoDB 之 aggregate 介绍

2,046 阅读5分钟

后续会更新出更多关于 aggregate 的内容。基于 mongodb 4.2 版本。

区分我的术语

  1. 属性值为数组内嵌文档即案例中的 "races" 对应的属性值
  2. 属性值为内嵌文档即案例中的 "raletion" 对应的属性值
{ 
    "_id" : ObjectId("5f92760d1e17044b8df04955"), 
    "countryName" : "艾欧尼亚", 
    "introduction" : "初生之土", 
    "description" : "初生之土,艾欧尼亚。这片岛屿大陆上充盈着自然之美和原生魔法。居民散布于各个行省,崇尚灵性,追求和谐的存世之道。艾欧尼亚悠久的中立姿态随着诺克萨斯的入侵而被打破——野蛮的侵占迫使艾欧尼亚重新审视自己在这个世界上的位置,未来的道路也变得扑朔迷离。", 
    "area" : NumberDecimal("200001"), 
    "createAt" : ISODate("2020-10-23T06:19:57.386+0000"), 
    "races" : [
        {
            "_id" : ObjectId("5f92760d1e17044b8df0494c"), 
            "name" : "均衡寺院", 
            "description" : "圣所", 
            "talent" : "均衡教派的大本营。他们立誓守护艾欧尼亚的平衡。", 
            "createAt" : ISODate("2020-10-23T06:19:57.385+0000"), 
            "peoples" : [
                {
                    "_id" : ObjectId("5f92760d1e17044b8df04948"), 
                    "status" : "壮年", 
                    "name" : "离群之刺", 
                    "nickname" : "阿卡丽", 
                    "age" : NumberInt(38), 
                    "createAt" : ISODate("2020-10-23T06:19:57.385+0000")
                }, 
                {
                    "_id" : ObjectId("5f92760d1e17044b8df04949"), 
                    "status" : "壮年", 
                    "name" : "狂暴之心", 
                    "nickname" : "凯南", 
                    "age" : NumberInt(38), 
                    "createAt" : ISODate("2020-10-23T06:19:57.385+0000")
                }
            ]
        }, 
        {
            "_id" : ObjectId("5f92760d1e17044b8df0494e"), 
            "name" : "帕拉斯神庙", 
            "description" : "圣所", 
            "talent" : "暗裔韦鲁斯在此被监禁了数百年,但封印已经破除……", 
            "createAt" : ISODate("2020-10-23T06:19:57.386+0000"), 
            "peoples" : [
                {
                    "_id" : ObjectId("5f92760d1e17044b8df0494d"), 
                    "status" : "壮年", 
                    "name" : "惩戒之剑", 
                    "nickname" : "维鲁斯", 
                    "age" : NumberInt(38), 
                    "createAt" : ISODate("2020-10-23T06:19:57.385+0000")
                }
            ]
        }
    ],
    "raletion": {
        "description": "lol世界观",
        "age": NumberInt(11)
    }
}

aggregate

聚合函数提供了多个管道(Pipeline)操作,我常用的具体如下:

阶段(Stage)描述
$match将符合查询条件的文档传递到下一个管道
$project可以重塑管道中每个文档的每个属性(只要你愿意,所以这个阶段非常的强大)
$lookup类似与 mysql 中 lefe join(尽量不要这样做,好的文档设计会为你减少很多表关联。同时会被动要用更多的复杂mongo函数去维护)
$group对所有文档进行分组,类似 mysql 中的 group by ,相同的 groupId 应该怎么取唯一的一个 name
$unwind因为主张非范式化的设计,所以一个文档会被设计的非常的复杂。看案例。
$count返回聚合管道此阶段的文档的总数。即过了此阶段,管道中只剩下了数量
$sort按照指定字段的升序降序排序
$limit返回前n条文档到下一个管道中
$skip跳过前n条文档
所有的管道操作能重复使用。仅限一些较实用且生僻的用法。属性名即文档的 key、键名。

$match

  • 文档中属性值和值的比较
// 1. $eq, $ne, $in 等于不等于
db.test_collection.aggregate([
    {$match: {"countryName" : "艾欧尼亚"}},
    {$match: {"countryName" : {$eq: "艾欧尼亚"}}},
    {$match: {"countryName" : {$ne: "abc"}}}, // 不等于
    {$match: {"countryName" : {$in: ["abc", "def"]}}}
])
// 2. $gt, $gte, $lt, $lte 大于小于
db.test_collection.aggregate([
    {$match: {"countryName" : {$gt: "abc", $lt: "def"}}} // 1 < a < 2 形式,也可以只要一个即 1 < a
])
// 3. $and, $or
db.test_collection.aggregate([
    {$match: {$and: [{"countryName" : "艾欧尼亚"}, {"introduction" : "初生之土"}]}} // $or 与之相同用法
])
  • 文档中属性值和属性值之间的比较 有些情况是我们需要和同一文档的其他字段进行比较 $expr, $ifNull
// 4. $expr, $ifNull
db.test_collection.aggregate([
    {$match: {$expr: {$eq: ['$countryName', '$introduction']}}}, // 这里也可以用上 $gt, $lt, $ne
    {$match: {$expr: {$eq: [{$ifNull: ['$countryName', '我是默认值1']}, {$ifNull: ['$introduction', '我是默认值2']}]}}}
])

$ 符号后接字符 $countryName 表示引用函数或者属性名所对应的值。

$expr:可以用到上面的$eq, $ne, $gt, $lt, $and, $or等操作符,表示用于同一文档的不同属性值进行比较。仅用$eq操作符时可以用上索引。

$ifNull:如果某个值不存在或者为null则输出默认值。 mongodb 是松散结构非常的自由,不用$ifNull限制会导致某些属性值不存在输出null导致和预期结果不一致。

  • 内嵌文档作为查询条件
// 内嵌文档的比较,是从属性名,属性名顺序,属性名的值进行比较。
let obj = {
   "name": "abc",
   "age": NumberInt(1)
}
db.test_collection.aggregate([
   {$match: {"races": obj}}
])
// 或者用其属性名.属性名
db.test_collection.aggregate([
   {$match: {"races.b.c.d": "被匹配的值"}}
])
  • 内嵌数组文档的二级文档作为查询条件
 // 直接进行匹配,忽略数组下标。能够匹配到有这个属性值的文档。
 // 但是有时候我们只想要子文档 name == '均衡寺院' 的子文档,不需要其他的子文档。 
 // 就需要在 $project 管道中 $elemMatch。即在 $match 和 $project 都能用 $elemMatch,且效果不一样
db.test_collection.aggregate([
    {$match: {"races.name": "均衡寺院"}}
])
// $elemMatch 操作符
db.test_collection.aggregate([
    {$match: {"races": {$elemMatch: {"name": "均衡寺院"}}}}
])
// 或者直接使用 数组下标匹配
db.test_collection.aggregate([
    {$match: {"races.0.name": "均衡寺院"}}
])

$group

mysql 的 group by

$group, $first, $last, $sum, $max, $min, $avg, $push, $addToSet

  • $group
db.test_collection.aggregate([
    {
        $group: {
	// 这里的写法有很多。单个属性分组 "_id": "$rank"
	// 多个:"_id": ["$rank", "$age"] 
	// 或者 "_id": {"rank": "$rank", "age": "$age"}
            "_id": ["$rank", "$age"], 
	// 对于相同的分组去唯一的一个值可以用 $first, $last, $max, $mix,看心情
            "name": {$first: '$name'}, 
            "rank": {$first: "$rank"},
            "age": {$first: "$age"},
            count: {$sum: 1} // 统计每个分组的数量
        }
    }
])

_id必须存在的即:如何多个属性分组都必须在_id后面。单个属性分组"_id": "$rank", 多个属性分组"_id": ["$rank", "$age"]_id属性值变成一个数组,多个:或者 "_id": {"rank": "$rank", "age": "$age"}_id属性值变成一个内嵌文档。

  • push

    指定分组后唯一的属性值可以用$first, $last, $sum, $max, $min, $avg, $push, $addToSet现在你也可以选择把分组后重复的值放在一个数组里(既可以是数组也可以是数组内嵌文档对象)

db.test_collection.aggregate([
    {
        $group: {
            "_id": ["$rank"],
            "name": {$first: '$name'},
            "rank": {
                $push: { // 可以用 $addToSet 代替,保证插入元素的不重复
                    "rank": "$rank",
                    "age": "$age"
                }
            },
            "age": {$first: "$age"}
        }
    }
])

$project

个人认为因为 mongodb 在推荐好的非范式数据库设计能带来更优的性能,mongodb 也提供了非常好的性能,但是额外带来的是非范式文档结构维护难度的增加。毕竟mysql不用担心有一个属性值居然是一个数组内嵌文档。

  • 那些显示那些不显示
// mongodb 默认显示所有字段,当你显式的声明某个字段应该显示即 "age": 1 抛弃了其他未声明为 'true' 的字段(但是_id)需要显示的声明不显示
// 反之显式的声明某个字段不显示不影响其他字段显示不显示。
db.test_collection.aggregate([
    {
        $project: {
            "_id": 0, 
            "age": 1,
            "newAge": "$age", // 重命名
            "newField": "我是手动新增字段" // 手动增加一个字段
        }
    }
])
// if else 支持
db.inventory.aggregate([
    {
       $project:
         {
           item: 1,
           discount:
             {
               $cond: { if: { $gte: [ "$qty", 250 ] }, then: 30, else: 20 }
             }
         }
    }
])
// case when 支持
db.grades.aggregate([
    {
        $project: {
            "name": 1,
            "summary": {
                $switch: {
                    branches: [
                    {
                        case: {$gte: [{$avg: "$scores"}, 90]},
                        then: "Doing great!"
                    },
                    {
                        case: {$and : [{$gte: [{$avg: "$scores"}, 80]}, {$lt: [{$avg: "$scores"}, 90]}]},
                        then: "Doing pretty well."
                    },
                    {
                        case: {$lt: [{$avg : "$scores"}, 80]},
                        then: "Needs improvement."
                    }
                    ],
                    default: "No scores found."
                }
            }
        }
    }
])

$project 阶段对数组的操作

mysql 中的 group_concat

  • mysql 中的 group_concat 应该怎么实现,包括 distinct orderBy?

    $group阶段用$push/$addToSet可以把用一个组某个属性值都存在一个数组中。然后 $reduce 会应用于数组中每个元素并压缩成一个值。

    $indexOfArray:判断内嵌文档对象在数组中的下标位置。

    $reverseArray:反转数组中的顺序。如果只是单纯的想对数组内嵌文档排序,推荐用updateOne()方法,$push 一个[]空数组并运用其中的$sort方法即可对数组内嵌文档排序。实现查询出的数组内嵌文档按指定排序输出。即查询前先执行一条 push 空数组命令对数组内容排序,然后执行 find 语句

    $map:和$reduce对应的就是$map,并不会压缩对象,而是会遍历每个数组内嵌文档。对内嵌文档做一些增减操作。

{
    $reduce: {
        input: <array>, // [1, 2, 3] 或者数组属性值 "$var"
        initialValue: <expression>, // 这个初始值只能被应用到一次
        in: <expression> // 做一些操作
    }
}
// 完成 group_concat 中字符串拼接并且 orderBy
db.test_collection.aggregate([
    {$match: {"countryName" : "艾欧尼亚"}},
    {
    	$sort: {
        	"orderBy": 1 // 实现 group_concat 中 orderBy 的功能
        }
    },
    {
        $project: {
            "racesList": {
                $reduce: {
                    input: {$reverseArray: "$races"}, // 反转数组顺序,至于为什么这里需要反转顺序,可以实测一番mongodb读取数组内容下标从 0 开始还是 length -1 开始
                    initialValue: "",
                    in: {
                        $cond: { // if else 支持
                            if: {$eq: [{$indexOfArray: ["$races", "$$this"]}, NumberInt(0)]},
                            then: {$concat: ["", "$$this.name", "$$value"]}, // 不知道为啥,这个 "$$value" 好像必须要出现。不然达不到效果
                            else: {$concat: [",", "$$this.name", "$$value"]}
                        }
                    }
                }
            }    
        }
    }
])
// 结果如下
//{ 
//    "_id" : ObjectId("5f92760d1e17044b8df04955"), 
//    "racesList" : "均衡寺院,帕拉斯神庙,希拉娜修道院"
//}

滤掉部分数组内嵌文档

当你想改这个东西的时候即你显示的声明了想要显示这个字段,你还需要显示的声明其他本不需要显示声明的字段。例如:你必须强制的把所有想要显示的字段全部显示一遍,对字段很多的 vo 简直是噩梦。 这时可以试试这个管道操作:$addFields,只单纯的增加字段,并不会影响原先的映射字段。

  • 我查出来了一条文档,有一个属性是数组内嵌文档,怎么过滤掉部分数组内嵌文档即{"race.name": "均衡寺院"}

    1. 有且仅返回一条符合匹配条件的数组内嵌文档 $elemMatch。在 findOne(query, project) 中的 project
    // findOne(query, project) 中 仅返回被匹配的第一条。其属性还是数组内嵌文档
    db.test_collection.findOne(
        {"countryName" : "艾欧尼亚"}, 
        {
            "countryName": 1,
            "introduction": 1, 
            "description": 1, 
            "area": 1, 
            "createAt": 1,
            "races": {$elemMatch: {"name": "均衡寺院"}} // 就是因为这个,导致上面的都必须显示的声明出来。字段一多就特别麻烦
        }
    )
    
    1. 按照匹配条件匹配一条至多条 $filter
    // 按照匹配条件匹配一条至多条。其属性还是数组内嵌文档
    db.test_collection.aggregate([
        {
            $match: {"countryName": "艾欧尼亚"}
        },
        {
            "countryName": 1,
            "introduction": 1, 
            "description": 1, 
            "area": 1, 
            "createAt": 1,
            $project: {
                "races": {
                    $filter: {
                        input: "$races",
                        as: "item",
                        cond: {
                            $eq: ["$$item.name", "均衡寺院"] // 注意这里不是大括号而是中括号
                        }
                    }
                }
            }
        }
    ])
    
    1. 将属性值为数组内嵌文档变成内嵌文档 $unwind
    db.test_collection.aggregate([
        {
            $match: {"countryName": "艾欧尼亚"}
        },
        {$unwind: "$races"},
        {
            $match: {"races.name": "均衡寺院"}
        }
    ])
    

    结果如下:

  • 更多的数组操作