09、Mongodb 聚合

Par @Martin dans le
Tags :

MongoDB 中聚合(aggregate)主要用于处理数据(如统计平均值、求和等), 并返回计算后的数据结果, 这样我们就可能利用 Mongdb 直接去处理一些数据, 而不用先把数据拿到本地再运算.

1. count

count 是最简单的聚合工具, 返回集合中的 Document 数量.

db.col.count()
0
db.col.insert({'x': 1})
db.col.count()
1
db.col.insert({'x': 2})
db.col.count()
2


count 和 find 一样, 也接受条件:

db.col.count({'x': 1})
1


从结果可以看出,只有符合条件的文档参与了计算.

2. distinct

distinct 用来统计集合中给定 key 所有不同的值.

插入 4 条测试数据(请留意Age字段):

db.test.insert({'name': 'Ada', 'age': 20})
db.test.insert({'name': 'Fred', 'age': 35})
db.test.insert({'name': 'Andy', 'age': 35})
db.test.insert({'name': 'Susan', 'age': 60})


distinct 命令必须指定集合名称和需要统计的字段, 如: age

db.runCommand({"distinct":"test", "key":"age"})
{
        "values" : [
                20,
                35,
                60
        ],
        "stats" : {
                "n" : 4,
                "nscanned" : 4,
                "nscannedObjects" : 4,
                "timems" : 0,
                "cursor" : "BasicCursor"
        },
        "ok" : 1
}


3. group

3.1 group 基础

group 聚合工具有些复杂:
先选定分组所依据的键, 此后 MongoDB 就会将集合中的数据, 依据 选定键的值的不同 分成若干组(即选定键的值相同的为一组), 然后可以通过聚合(运算)每一组内的文档, 产生一个结果文档.

有点类似 05、Mongodb Cursor 中提到的 forEach() 方法和 map() 方法, 不同的是 group 会先根据指定 key 对要聚合的 Documents 进行分组, 而且它的运算也更复杂.

先插入一点测试数据:

db.test.insert({'day': '2016-04-20', 'time': '2016-04-20 03:20:40', 'price': 4.23})
db.test.insert({'day': '2016-04-21', 'time': '2016-04-21 11:28:00', 'price': 4.27})
db.test.insert({'day': '2016-04-20', 'time': '2016-04-20 05:00:00', 'price': 4.10})
db.test.insert({'day': '2016-04-22', 'time': '2016-04-22 05:26:00', 'price': 4.30})
db.test.insert({'day': '2016-04-21', 'time': '2016-04-21 08:34:00', 'price': 4.01})


我们先分析下这几条测试数据:
根据 day 的不同, 上面插入的测试数据可以分为三组, 即: ‘2016-04-20’、‘2016-04-21’ 和 ‘2016-04-22’, 然后:
‘2016-04-20’ 这一组里, time 最新的为 ‘2016-04-20 05:00:00’
‘2016-04-21’ 这一组里, time 最新的为 ‘2016-04-21 11:28:00’
‘2016-04-22’ 这一组里, time 最新的为 ‘2016-04-22 05:26:00’

那么, Mongodb 的 group 工具就可以完成上面的这种功能.
可以将 day 作为 group 的分组键, 然后取出 time 键值为最新时间的文档, 同时也取出该文档的 price 键值.

db.test.group({
    'key': {'day': true}, // 如果是多个字段, 可以为 {'f1': true,'f2': true}
    'initial': {'time': '0'}, // initial 表示 $reduce 函数参数 prev 的初始值
    '$reduce': function(doc, prev) { // $reduce 方法接受两个参数, doc 正在迭代的当前 document, prev 表示上一次迭代的结果 document
        if (doc.time > prev.time) {
            prev.day = doc.day;
            prev.price = doc.price;
            prev.time = doc.time;
        }
    }
})

// 下面是 group 的返回结果, 可以看到就是 $reduce 方法中最终 prev 的数据
[
    {
        'day': '2016-04-20',
        'time': '2016-04-20 05:00:00',
        'price': 4.1
    },
    {
        'day': '2016-04-21',
        'time': '2016-04-21 11:28:00',
        'price': 4.27
    },
    {
        'day': '2016-04-22',
        'time': '2016-04-22 05:26:00',
        'price': 4.3
    }
]


在某些时候, 你还可以使用 condition 参数 (也可以用 cond 参数 或者 q 参数, 功能是一样的) 来过滤要运算的文档, 就像 find() 方法的 query 一样.

例如, 我现在只想运算 price > 4.1 的文档:

db.test.group({
    'key': {'day': true},
    'initial': {'time': '0'},
    '$reduce': function(doc, prev) {
        if (doc.time > prev.time) {
            prev.day = doc.day;
            prev.price = doc.price;
            prev.time = doc.time;
        }
    },
    'condition': {'price': {'$gt': '4.1'}} // 只运算 price > 4.1 的文档
})


3.2 完成器

通过上面已经知道 group 工具最终会返回一个结果文档, 但有的时候, 我们可能不需要整个结果文档, 而是需要对这个结果再运算, 然后得到一个值, 可以是字符串, 可以是数字, 我们只想要这个结果值, 那怎么办?
或者有的时候, 我们是想要在结果文档中添加一个新字段, 那又该怎么办?

group 工具的 finalize (完成器) 参数就是用来对 group 返回的结果文档再加工的…

db.test.group({
    key: { day: true},
    initial: {count: 0},
    reduce: function(obj, prev) {
        prev.count++;
    },
    finalize: function(out) { // $finalize 方法接受一个参数, 即 $reduce 返回的结果文档, 这里做测试用, 仅在结果文档中新增一个键
        out.scaledCount = out.count * 10
    }
})

[
    {
        "day" : "2012-08-20",
        "count" : 2,
        "scaledCount" : 20
    },
    {
        "day" : "2012-08-21",
        "count" : 2,
        "scaledCount" : 20
    },
    {
        "day" : "2012-08-22",
        "count" : 1,
        "scaledCount" : 10
    }
]


3.3 将函数做为分组依据

在 3.1 和 3.2 中我们都是用 day 来做为分组依据的, 但在某些情况下, 单纯的使用字段来做为分组依据可能实现不了我们实际想要的功能.

例如, 假设存在一个保存了文章列表的集合, 集合里面有个 tag 字段, 用来标识文章的标签, 此时我们应该忽略 tag 值的大小写, 因为 AAA 和 aaa 表示的都是同一个标签…但如果直接用 tag 做分组依据, AAA 和 aaa 就会被分为不同组....

为了消除这个问题, 就要定义一个__函数__来确定文档分组的依据, 定义分组函数要用到 $keyf, 注意, 不是 key.

db.blog.group({
    '$keyf' : function(x) { // $keyf 方法接收一个参数, 表示当前集合的文档, 要返回一个值, 表示分组依据, 这里就把 tag 转成小写后的 值做为分组依据
        return x.tag.toLowerCase();
    },
    ......
})