美文网首页
MongoDB第六讲聚合

MongoDB第六讲聚合

作者: 孔浩 | 来源:发表于2017-12-16 15:50 被阅读0次

    下面将会讨论MongoDB中的聚合查询,聚合查询是以管道的方式来完成的,这和Java8中的Stream非常类似,首先聚合查询好比下图流程所示

    聚合查询流程

    )

    管道概念

    具体的思路是把Collection加载到聚合框架中,之后经过第一个条件限制(如:math)之后会把筛选出来的数据传给第二个条件,这其实就和管道的概念一样。常用的聚合管道有如下一些部分:

    聚合管理 说明
    $project 输出文档的内容,其实就等于sql中的投影操作,也类似find查询中的第二个条件
    $match 查询条件
    $limit 取多少条数据传递给下一个管道
    $skip 跳过一定数量的文档
    $unwind 扩展数组
    $group 分组,和sql中的group类似
    $sort 文档排序
    $out 把管道的结果写到一个集合中

    聚合管道的写法如下所示

    db.collection.aggregate([{$project},{$match},....{$sort}])
    

    注意写法,首先aggregate是聚合函数,和find一样使用()来调用,第一个参数就是管道,以数组的方式来定义多个管道所以要使用中括号[ ]来定义,数组中的每一个{}就表示一个管道,下面我们会根据上一讲的例子来详细分析管道的各个操作。由于原来的数据库被不小心删除了,下图是最新版本的数据对象模型图,数据结构没有任何的改变,无非一些名称有些改变而已,org变成了dep之类

    聚合查询数据模型

    )

    先来查询一个可以使用find就完成的操作,我们查询年龄大于23的所有User并且仅仅显示name和age,此时需要通过两个管道来完成,match和project,match用来进行条件匹配,project用来确定查询出来的文档

    db.user.aggregate([
            {$match:{age:{$gt:23}}},
            {$project:{_id:0,name:1,age:1}}
    ])
    

    第一个管道完成了条件的查询,第二个管道确定了输出内容。很明显这个操作使用find更为直观一些,接下来将会介绍一些常用了聚合操作,通过聚合操作大家会感受到MongoDB的强大之处。

    project和match管道

    match用来进行条件筛选,但是注意,该管道放置的位置不一样,筛选所面对的集合是不一样的,我们先来看下面一种查询需求,在设计时,在dep中加入了用户的信息,如果我们希望查询所有的用户数量,使用find就有些力不从心了,需要使用到javascript的shell

    var d = db.dep.findOne()
    d.users.length
    

    这个还没有办法把所有的信息列表出来,但是使用聚合查询可以很容易实现这个需求

    db.dep.aggregate([{$project:{name:1,usize:{$size:"$users"}}}])
    {
        "_id" : ObjectId("5a2947c367732445e8adfff0"),
        "name" : "教务处",
        "usize" : 3
    }
    {
        "_id" : ObjectId("5a2947c367732445e8adfff1"),
        "name" : "财务处",
        "usize" : 3
    }
    {
        "_id" : ObjectId("5a2947c367732445e8adfff2"),
        "name" : "计算机学院",
        "usize" : 5
    }
    

    改查询使用了一个管道,$project,用来投影具体需要的值,这里仅仅显示了name,usize这个可以自己定义,通过 $size 来统计,统计的地方,如果是文档中的key,需要使用$ 符号来引用。

    下面加入match,首先通过第一个管道来筛选部门类型为行政部门的信息,看看查询代码

    db.dep.aggregate([
        {$match:{type:"行政部门"}},
        {$project:{name:1,unum:{$size:"$users"}}}
    ])
    {
        "_id" : ObjectId("5a2947c367732445e8adfff0"),
        "name" : "教务处",
        "unum" : 3
    }
    {
        "_id" : ObjectId("5a2947c367732445e8adfff1"),
        "name" : "财务处",
        "unum" : 3
    }
    

    第一个管道是基于原来的dep文档来进行筛选的,筛选了type为行政部门的所有信息,接下来我们在project管道之后再使用match来进行筛选,此时就是针对以上文档的,我们需要根据上面文档的值进行查询

    db.dep.aggregate([
        {$match:{type:"行政部门"}},
        {$project:{name:1,unum:{$size:"$users"}}},
        {$match:{unum:{$gt:3}}}
    ])##查询人数大于3的用户
    Fetched 0 record(s) in 0ms ##由于没有这个数据,所以显示没有查询到元素
    

    这种查询方式对于关系数据库而言,还是不太容易实现的,需要用到子查询,对于project而言,还可以对文档进行重塑,先来看一个基本的重塑。

    db.dep.aggregate([
        {$project:{_id:0,dep:{name:"$name",type:"$type"},unum:{$size:"$users"}}}
    ])
    {
        "dep" : {
            "name" : "教务处",
            "type" : "行政部门"
        },
        "unum" : 3
    }
    

    我们把dep中的id去掉,并且把name和type加入了一个子文档dep中,在这个例子中,这种方法似乎比较多余,但是随着我们应用的深入,你会喜欢上这样的操作的,另外MongoDB提供了一组函数来帮助我们做各种重塑,会在后面专门讲解。

    group管道

    group对于关系型数据库而言,是非常重要的,是统计查询中的基础,MongoDB和关系数据库类似,都是通过group来进行分组查询,group的写法是固定的,{$group:{_id:'分组的对象',多个自定义投影}} 。_id这个是必须填写的,就等于sql中group by 后的这个值,而要统计哪些值,在后面通过写出来,这其实就等于select的投影

    下面我们来使用一下group管道,虽然我们设计了在dep下添加users的array,但是我们也在user中添加了dep的映射,我们通过dep来分组统计一下部门下面来看使用group进行统计,这个需要在user这个document中查询

    db.user.aggregate([{$group:{_id:'$dep.name',count:{$sum:1}}}])
    {  "_id" : "教务处","count" : 3.0}
    {"_id" : "财务处","count" : 3.0}
    {"_id" : "计算机学院","count" : 5.0}
    

    查询出来的结果是id和数量,使用$group,必须第一个值是_id,说明根据dep的 name进行分组,第二个参数的投影名称是count然后使用$sum ,sum表示求和,等于sql的count(*)。

    下面我们希望查询人数大于3的学院,此时在sql中需要使用having来查询,但是对于MongoDB而言,只要在后面增加一个match的管道即可。

    db.user.aggregate([
        {$group:{_id:'$dep.name',count:{$sum:1}}},
        {$match:{count:{$gt:3}}}
    ])
    {
        "_id" : "计算机学院",
        "count" : 5.0
    }
    

    同样如果希望筛选group前的数据,只用在group管道前增加match即可

    db.user.aggregate([
        {$match:{"dep.name":"财务处"}},
        {$group:{_id:'$dep.name',count:{$sum:1}}}
    ])
    {
        "_id" : "财务处",
        "count" : 3.0
    }
    

    对于group而言,处理$sum 函数外还有如下几个函数:

    group函数 说明
    $addToSet 添加组里的一个元素到数组,数组里的元素唯一
    $first 组里的第一个值。
    $last 组里的最后一个值。
    $max 组里某个字段的最大值
    $min 组里某个字段的最小值
    $avg 组里某个字段平均值
    $push 添加组里的一个元素到数组,数组里的元素不唯一
    $sum 组里某个字段的和

    以上这些操作很多从字面意思就能知道怎么使用,但是first,last,push和addToSet稍微有些不好理解,我们先通过一个实例把几个统计函数学会,为了方便统计,首先对user加入salary这个数据,并且随机生成薪水的数量,薪水的数量随机4000-10000

    Random.setRandomSeed()
    db.user.find({}).forEach(function(u){
        db.user.updateOne({_id:u._id},{$set:{salary:(Random.randInt(6000)+4000)}})
    })
    

    首先统计每个部门的薪水之和平均薪水,最高薪水和最低薪水

    db.user.aggregate([
        {$group:{_id:"$dep.id",
            salays:{$sum:"$salary"},
            avgs:{$avg:"$salary"},
            max:{$max:"$salary"},
            min:{$min:"$salary"}}}
    ])
    {
        "_id" : ObjectId("5a2947c367732445e8adfff0"),
        "salays" : 19394.0,
        "avgs" : 6464.66666666667,
        "max" : 8926.0,
        "min" : 4927.0
    }
    {
        "_id" : ObjectId("5a2947c367732445e8adfff1"),
        "salays" : 27166.0,
        "avgs" : 9055.33333333333,
        "max" : 9664.0,
        "min" : 8591.0
    }
    

    上面这个例子中,根据部门统计了薪水的信息,此时我们由于使用了dep.id进行统计,所以仅仅显示了dep的id,但是我们已经冗余了dep.name,所以可以通过$first 或者$last 函数,由于dep的name是唯一的,所以使用first或者last都一样

    db.user.aggregate([
        {$group:{_id:"$dep.id",
            depname:{$first:"$dep.name"},
            salays:{$sum:"$salary"},
            avgs:{$avg:"$salary"},
            max:{$max:"$salary"},
            min:{$min:"$salary"}}}
    ])
    {
        "_id" : ObjectId("5a2947c367732445e8adfff0"),
        "depname" : "教务处",
        "salays" : 19394.0,
        "avgs" : 6464.66666666667,
        "max" : 8926.0,
        "min" : 4927.0
    }
    

    最后就是addToSet和push了,这个可以把用户的基本信息添加到一个数组中,使用addToSet添加的信息不会有重复,而使用push的信息会存在重复,看如下的例子

    db.user.aggregate([
        {$group:{_id:"$dep.name",
            count:{$sum:1},
            usernames:{$push:'$name'}, #把用户名添加进行
            users:{$addToSet:{phone:'$phone',age:'$age'}}##使用重塑设置两个值
        }}
    ])
    

    以上这个查询由于没有重复的用户,所以push和addToSet基本一致,但是addToSet中进行了重塑,看看下面的结果

    {
        "_id" : "教务处",
        "count" : 3.0,
        "usernames" : [ 
            "b1", 
            "b2", 
            "b3"
        ],
        "users" : [ 
            {
                "phone" : "119",
                "age" : 26
            }, 
            {
                "phone" : "119",
                "age" : 24
            }, 
            {
                "phone" : "119",
                "age" : 23
            }
        ]
    }
    

    有了两个数组,一个是usernames,所有用户的名称,users里面是一个对象数组集合。这个数据是非常有必要的,因为可以通过这个信息再次进行二次查询,到此为止group的知识就算讲完了,group是聚合中最为重要的一部分内容,务必掌握。

    unwind和out管道

    第一个查询需求是获取用户对私人信息的统计,统计每个用户该查询的私人信息数量,看过的数量和没有看过的数量,这使用project就可以解决,先看看结果

    db.user.aggregate({$project:
        {
            username:1,
            msgs:{$size:'$msgs.all'},
            noVisited:{$size:'$msgs.noVisited'},
            visited:{$size:'$msgs.visited'}
         }})
    

    但是执行之后会发现报错了,错误提示The argument to $size must be an array, but was of type: missing 提示$size 应该是针对所有的数组,我们的noVisited或者visited可能会存在不存在的情况,这个时候需要通过$ifNull来解决

    db.user.aggregate({$project:
       {
           username:1,
           msgs:{$size:'$msgs.all'},
           noVisited:{$size:{$ifNull:['$msgs.noVisited',[]]}},
           visited:{$size:{$ifNull:['$msgs.visited',[]]}}
        }})
    

    注意$ifNull,有两个值,使用的[]而不是{},第一个是条件,第二个是如果不存在的替换值。

    看看结果

    /* 1 */
    {
        "_id" : ObjectId("5a29467b67732445e8adffe5"),
        "username" : "foo1",
        "msgs" : 2,
        "noVisited" : 1,
        "visited" : 1
    }
    
    /* 2 */
    {
        "_id" : ObjectId("5a2946a567732445e8adffe6"),
        "username" : "bar1",
        "msgs" : 2,
        "noVisited" : 2,
        "visited" : 0
    }
    

    下面我们要更进一步,我们希望统计每条私人信息中没有访问的数量,由于访问的信息是存储在user中,并且是以数组的方式来存储,这样很难进行统计,但是MongoDB提供了unwind来解决此类数组查询的问题,使用unwind会把数组中的每个文档都提出来作为一个单独的文档,注意unwind一般和project配合使用,否则会比较占用内存。先看unwind的结果

    db.user.aggregate([
        {$project:{name:1,"msgs.noVisited":1}},
        {$unwind:"$msgs.noVisited"}
    ])
    {
        "_id" : ObjectId("5a29467b67732445e8adffe5"),
        "name" : "foo",
        "msgs" : {
            "noVisited" : ObjectId("5a294ee567732445e8adfff6")
        }
    }
    {
        "_id" : ObjectId("5a2946a567732445e8adffe6"),
        "name" : "bar",
        "msgs" : {
            "noVisited" : ObjectId("5a294ee567732445e8adfff3")
        }
    }
    

    大家发现没有,通过unwind,数组中的每一个元素都变成了一个独立的文档,之后通过_id进行一个group即可获取数量

    db.user.aggregate([
        {$project:{name:1,"msgs.noVisited":1}},
        {$unwind:"$msgs.noVisited"},
        {$group:{_id:"$msgs.noVisited",count:{$sum:1}}}
    ])
    {
        "_id" : ObjectId("5a29470667732445e8adffef"),
        "count" : 4.0
    }
    {
        "_id" : ObjectId("5a2946fc67732445e8adffed"),
        "count" : 4.0
    }
    

    此时如果希望把标题查询出来,对于MongoDB而言,由于没有join,所以需要对这个数组进行二次查询,使用out可以将查询出来的结果添加到一个文档中

    db.user.aggregate([
        {$project:{name:1,"msgs.noVisited":1}},
        {$unwind:"$msgs.noVisited"},
        {$group:{_id:"$msgs.noVisited",count:{$sum:1}}},
        {$out:"msgsNoVisited"}
    ])
    

    此时可以通过msgsNoVisited来获取message的title,由于MongoDB没有join,所以第一种方法使用游标,利用forEach处理

    db.noVisitedMsgs.remove({})
    db.msgsNoVisited.find().forEach(function(e) {
        var m = db.message.findOne({_id:e._id})
        if(m!=null) {
            e.title = m.title
        } else {
            e.title = "not found"
        }
        db.noVisitedMsgs.insertOne(e)
    })
    db.noVisitedMsgs.find()
    

    通过以上shell将查询的结果存储到一个noVisitedMsgs的集合中,第一条remove是先把这个临时数据删除,看一下结果

    {
        "_id" : ObjectId("5a294ee567732445e8adfff5"),
        "count" : 9.0,
        "title" : "msg3"
    }
    {
        "_id" : ObjectId("5a294ee567732445e8adfff4"),
        "count" : 10.0,
        "title" : "msg2"
    }
    

    这种方案是基于伪查询的方式,由于每个游标都是通过findOne来进行二次查询的,所以如果数据量非常大的话,效率会很低,在MongoDB3.2版本之后提供了新的函数lookup来完成两个集合的连接

    db.message.aggregate([
        {$lookup:{
             from:"msgsNoVisited",
             localField:"_id",
             foreignField:"_id",
             as:"noVisited"
        }}
    ])
    

    此时会内嵌一个文档noVisited到message中,如下所示

    {
        "_id" : ObjectId("5a294ee567732445e8adfff3"),
        "title" : "msg1",
        "content" : "msg ...",
        "createDate" : ISODate("2017-12-15T05:22:20.103Z"),
        "user" : {
            "id" : ObjectId("5a29467b67732445e8adffe5"),
            "name" : "foo"
        },
        "attach" : {
            "name" : "world.txt",
            "type" : "txt",
            "createDate" : "Thu Dec 07 2017 23:34:54 GMT+0800",
            "size" : 23
        },
        "noVisited" : [ 
            {
                "_id" : ObjectId("5a294ee567732445e8adfff3"),
                "count" : 10.0
            }
        ]
    }
    

    如果感觉里面的数据太多了,可以首先通过project来进行投影,最后把生成的信息拷贝到一个文档中

    db.message.aggregate([
        {$project:{
            title:1
         }},
        {$lookup:{
             from:"msgsNoVisited",
             localField:"_id",
             foreignField:"_id",
             as:"noVisited"
        }},
        {$out:"msgsNoVisited"}
    ])
    db.msgsNoVisited.find()
    ---------------------------------------
    {
        "_id" : ObjectId("5a294ee567732445e8adfff3"),
        "title" : "msg1",
        "noVisited" : [ 
            {
                "_id" : ObjectId("5a294ee567732445e8adfff3"),
                "count" : 10.0
            }
        ]
    }
    

    以上就是unwind和out的各种用法,希望对大家有所帮助

    sort、skip和limit管道

    这三个管道,sort用来排序,skip和limit用来进行分页,这个操作和查询操作非常类似,其实是互通的,看一个实例即可学会。

    var pageNum = 1
    var pageSize = 10
    db.user.aggregate([
        {$project:{name:1,age:1}},
        {$skip:(pageNum-1)*pageSize},
        {$limit:pageSize},
        {$sort:{age:-1}}
    ])
    

    以上实例通过分页的方式来查询,并且以age倒序排序。

    重塑文档

    在第一小部分已经看了如何重塑文档,MongoDB还提供了很多好用的函数来重塑文档,重塑文档都是基于project的,这种函数分成很多类,这里只能简单演示一些,首先字符串相关的函数有

    字符串相关函数
    $concat   连接两个字符串
    $strcasecmp 区分大小写比较数字
    $substr 取子串
    $toLower 转化为小写
    $toUpper 转换为大写
    

    看看如下实例,获取用户姓名的信息

    db.user.aggregate([
        {$project:{
            fullname:{$concat:['$username','-','$name']},
            fname:{$substr:['$username',0,1]}
         }}
    ])
    
    {
        "_id" : ObjectId("5a29467b67732445e8adffe5"),
        "fullname" : "foo1-foo",
        "fname" : "f"
    }
    

    接下来看看算术运算函数和日期函数

    算术运算函数
        $add   求和
        $divide 除法
        $mod 求余数
        $multiply 乘法
        $subtract 减法
    日期函数
        $year 取年份
        $month 取月份
        $week 取一年中的某一周(0-53)
        $hour 取小时
        $minute 取分钟
        $second 取秒钟
        $millisecond 取毫秒
        $dayOfYear 一年中的某一天
        $dayOfMonth 一月中的某一天
        $dayOfWeek 一周中的某一天,1表示周日
    

    算数运算符比较简单,我们使用以下日期函数

    db.message.aggregate([
        {$match:{createDate:{$gte:new Date("2017-01-01"),$lte:new Date("2017-12-31")}}},
        {$project:{title:1,year:{$year:"$createDate"},month:{$month:"$createDate"}}},
        {$group:{_id:{year:'$year',month:'$month'},count:{$sum:1}}}
    ])
    

    以上操作完成了根据year和month进行分组查询,最后可以得到2017年每个月发送的message的数量,这是非常好用的一种聚合查询。

    集合操作函数也非常实用

    集合操作符
        $setEquals  查看两个集合时候相同,相同返回true
        $setInterseciont 返回两个集合的公共元素
        $setDifference 返回两个集合中的不同的元素
        $setUnion 合并集合
        $setIsSubset 判断第二个集合是否是第一个的子集,如果是返回true
        $anyElementTrue 如果某个集合元素为true,就为true
        $allElementTrue 集合中所有元素为true就为true
    

    看一下下一个实例

    db.user.aggregate([
        {$project:{nov:{$setDifference:['$msgs.all',{$ifNull:['$msgs.visited',[]]}]}}}
    ])
    

    会得到没有参观过的message信息,下面看一下其他的重塑函数

    逻辑函数
        $and true   与操作
        $cmp 比两个值是否相等
        $cond  if ... then ... else 条件逻辑,类似三元运算符
        $eq 等于
        $gt,$gte,$lt,$lte  大于和小于
        $ifNull 是否为空
        $ne 不等于
        $not. 取反
        $ir 或运算
    其他函数
        $meta  文本搜索
        $size 取数组大小
        $map 对数组的每个成员应用表达式
        $let 定义表达式内的变量
        $literal 返回表达式的值
    

    以上函数就不举例了,只是让大家对这个有所了解,以后具体使用到的时候再去查询

    Map-Reduce

    对于一些复杂的查询可以转换为javascript的处理方式,这就是map reduce,但是map reduce的效率非常低,所以能够使用聚合框架实现的就不要使用Map-Reduce,下面看看Map-Reduce如何实现一个应用。

    首先要定义map函数,map指的是遍历文档中的所有键值对,来做处理,我们现在对user这个collection来做map

    map = function() {
        var did = this.dep.id+"-"+this.dep.name;
        var ageGt = 0;
        if(this.age>40) ageGt=1;
        emit(did,{ageGt:ageGt});
    }
    

    以上操作定义了一个map,该操作会去遍历整个collection的文档,首先确定了key是部门id+部门名称,接着完成下面一种存储如果年龄大于40存储ageGt为1否则为0,emit表示返回值,此时的map文档中有一组数据,存储了部门id+部门名称为key并且存储了年龄的文档(年龄的文档中一些是0,一些是1),之后把这个值交给reduce来返回一个结果集

    reduce=function(key,values) {
        var result = {nums:0};
        values.forEach(function(v){
            result.nums+=v.ageGt;
        })
        return result;
    }
    

    reduce中就会根据key的值把返回的数据定义成为一个数组,这个数组就是reduce的第二个参数values,此时定义了一个结果为nums,并且遍历整个数组,将nums的值增加,这意味着只要发现ageGt是1表示有一个40岁以上的人,这样累加之后,就可以根据情况统计出每个部门大于40岁的人员。

    定义好map和reduce之后,下一步就需要通过collection来调用

    db.user.mapReduce(map,reduce,{query:{},out:"test"})
    

    此时使用user调用刚才定义的map和reduce,第三个参数中的query是查询条件,可以过滤一组数据,第二个参数out表示要输出的文档,输出之后我们可以通过test文档查询

    /* 1 */
    {
        "_id" : "5a2947c367732445e8adfff0-教务处",
        "value" : {
            "nums" : 0.0
        }
    }
    
    /* 2 */
    {
        "_id" : "5a2947c367732445e8adfff1-财务处",
        "value" : {
            "nums" : 1.0
        }
    }
    
    /* 3 */
    {
        "_id" : "5a2947c367732445e8adfff2-计算机学院",
        "value" : {
            "nums" : 0.0
        }
    }
    

    这个map-reduce完成了部门大于40岁人的统计操作,Map-Reduce基本可以实现你想要的所有功能,但是由于效率低下,依然建议大家如果能使用聚合框架就不要用MapReduce。

    总结

    这部分讲解了聚合框架,这是MongoDB的核心,它可以提供非常出色的查询操作,这是必须要熟悉的一块内容,当然聚合框架的效率问题将会在索引之后详细探讨。

    相关文章

      网友评论

          本文标题:MongoDB第六讲聚合

          本文链接:https://www.haomeiwen.com/subject/ojwswxtx.html