我们已经看过MongoDB的聚合命令count
的例子了(count
被用于分页)。大多数数据库都提供了count
和其他很多内置的聚合函数,用于计算总和、平均数、方差等。这些特性都在MongoDB的规划之中,但在实现前,我们可以使用group
与map-reduce
编写脚本实现各种聚合函数,从简单的求和到计算标准差。
5.3.1 根据用户对评论进行分组
通常人们都想知道哪些用户提供了最有价值的评论。既然应用程序允许用户为评论投票,那么从技术上讲,就能计算出某个用户所有评论的总得票数,以及该用户每篇评论的平均得票数。虽然可以查询所有评论并执行一些基本的客户端处理来获取这些统计信息,但还可以使用MonogoDB的group命令从服务器获取结果。
group
最少需要三个参数。第一个参数是key
,定义如何对数据进行分组。本例中,我们希望结果根据用户分组,因此分组的键是user_id
。第二个参数是一个对结果集做聚合的JavaScript函数,叫reduce
函数。第三个分组参数是reduce
函数的初始文档。
实际并没有听上去那么复杂。让我们仔细看看将用到的初始文档,以及相应的reduce
函数:
initial = {review: 0, votes: 0};reduce = function(doc, aggregator) { aggregator.reviews += 1.0; aggregator.votes += doc.votes;}
我们看到初始化文档为每个分组键定义了一些值,换言之,每次运行group
,我们都希望针对每个user_id
得到一个结果集,其中包含写过的评论总数,以及所有那些评论的总得票数。生成这些总和的工作是由reduce
函数完成的。假设我写了五条评论,也就是说有五个评论文档中标记有我的用户ID,这五个文档都会被分别传递给reduce
函数,作为doc
参数。一开始aggregator
的值是initial
文档,后续每处理一个文档就会往aggregator
里添加值。
下面展示如何在JavaScript Shell中执行group
命令。
代码清单5-1 使用MongoDB的
group
命令
results = db.reviews.group({key: {user_id: true},initial: {reviews: 0, votes: 0.0},reduce: function(doc, aggregator) { aggregator.reviews += 1; aggregator.votes += doc.votes; }finalize: function(doc) { doc.average_votes = doc.votes / doc.reviews; }})
请注意,此处向group
传递了一个额外的参数。我们希望获得每篇评论的平均得票数,但在计算出总的评论得票数和评论总数之前,无法得到该值。这就是使用终结器(finalizer)的原因,它是一个JavaScript函数,在group
命令返回前应用于每个分组结果上。本例中,我们使用终结器计算每篇评论的平均得票数。
下面是针对示例数据集运行以上聚合的结果。
代码清单5-2
group
命令的结果
[ {user_id: ObjectId(/"4d00065860c53a481aeab608/"), votes: 25.0, reviews: 7, average: 3.57 }, {user_id: ObjectId(/"4d00065860c53a481aeab608/"), votes: 25.0, reviews: 7, average: 3.57 }]
本章结尾处我们还会谈到group
命令,包括它所有的选项和特质。
5.3.2 根据地域对订单应用MapReduce
我们可以把MongoDB的map-reduce
当做更灵活的group
。有了map-reduce
,可以更细粒度地控制分组键,还有大量输出选项可用,包括将结果存储在新的集合里,以便后续能够更方便地获取那些数据。让我们通过一个例子来了解两者在实践中的不同。
我们有时希望生成一些销售汇总,可以以此为例。每个月销售量有多少?过去一年里每个月的销售额有多少?通过map-reduce
可以很方便地回答这些问题。正如map-reduce
的名字所暗示的,第一步就是编写一个映射函数,应用于集合里的每个文档,在此过程中实现两个目的:定义分组所用的键,整理计算所需的所有数据。要实际了解这个过程,可以仔细查看以下函数:
map = function { var shipping_month = this.purchase_date.getMonth + /'-/' + this.purchase_data.getFullYear; var items = 0; this.line_items.forEach(function(item) { tmpItems += item.quantity; }); emit(shipping_month, {order_total: this.sub_total, items_total: 0}); }
首先,需要知道变量this
总是指向正在迭代的文档。在函数的第一行里,我们获取了一个表示订单创建月份的整数1。随后调用了 emit
,这是每个映射函数必须要调用的特殊方法。emit
的第一个参数是分组依据的键,第二个参数通常是包含要执行reduce
的值的文档。本例中,我们要根据月份分组,对每个订单的小计和明细项数量做统计。看了与之对应的reduce
函数之后,一切就再明白不过了:
reduce = function(key, values) { var tmpTotal = 0; var tmpItems = 0; tmpTotal += doc.order_total; tmpItems += doc.items_total; return ( {total: tmpTotal, items: tmpItems} ); }
1. 因为JavaScript的月份是从0开始的,所有该值的范围是0~11。我们需要在此基础上加1,这样的月份表述更加直观。后面加了-和年份,因此整个键看起来是这样的:1-2011、2-2011,以此类推。
reduce
函数接受一个键和一个包含一个或多个值的数组。编写reduce
函数时要确保那些值按照既定的方式进行聚合,并且能返回单个值。因为map-reduce
的迭代本质,reduce
可能被执行多次,而编写代码时必须把这种情况也考虑在内。在实践中,这就意味着对于一个映射函数给出的值而言,多次执行reduce
函数的返回值必须保证是相同的。仔细想想,你会发现情况就是这样的。
Shell的map-reduce
方法要求提供一个映射函数和一个reduce
函数作为参数。本例中还增加了另外两个参数。第一个参数是查询过滤器,将聚合操作所涉及的文档限制在2010年之后创建的订单。第二个参数是输出集合的名称。
filter = {purchase_date: {$gte: new Date(2010, 0, 1)}}db.orders.mapReduce(map, reduce, {query: filter, out: /'totals/'})
该操作的结果保存在名为totals
的集合之中,我们可以像查询其他集合一样对它进行查询。下面的代码显示了对totals
集合的查询结果。_id
字段是分组键,其中的内容是年和月;value
字段是统计出的汇总信息。
代码清单5-3 查询map-reduce的输出集合
> db.totals.find{ _id: /"1-2011/", value: { total: 32002300, items: 59 }}{ _id: /"2-2011/", value: { total: 45439500, items: 71 }}{ _id: /"3-2011/", value: { total: 54322300, items: 98 }}{ _id: /"4-2011/", value: { total: 75534200, items: 115 }}{ _id: /"5-2011/", value: { total: 81232100, items: 121 }}
本节的示例从实践出发让我们对MongoDB的聚合能力有了感性的了解,下一节的内容会涵盖它的大部分细节内容。