首页 > 解决方案 > 使用动态键累积文档

问题描述

我有一组看起来像这样的文档

{
   _id: 1,
   weight: 2,
   height: 3,
   fruit: "Orange",
   bald: "Yes"
},
{
   _id: 2,
   weight: 4,
   height: 5,
   fruit: "Apple",
   bald: "No"
}

我需要得到一个将整个集合聚合到其中的结果。

{
   avgWeight: 3,
   avgHeight: 4,
   orangeCount: 1,
   appleCount: 1,
   baldCount: 1
}

我想我可以映射/减少这个,或者我可以分别查询平均值和计数。水果可能拥有的唯一价值是苹果和橙子。你还有什么其他方法可以做到这一点?我已经离开 MongoDB 一段时间了,也许有一些我不知道的新方法可以做到这一点?

标签: mongodbmongodb-queryaggregation-framework

解决方案


聚合框架

聚合框架对你来说会比 mapReduce 做的好得多,而且基本方法兼容每个发布聚合框架时回溯到 2.2 的版本。

如果你有MongoDB 3.6你可以做

db.fruit.aggregate([
  { "$group": {
    "_id": "$fruit",
    "avgWeight": { "$avg": "$weight" },
    "avgHeight": { "$avg": "$height" },
    "baldCount": {
      "$sum": { "$cond": [{ "$eq": ["$bald", "Yes"] }, 1, 0] }
    },
    "count": { "$sum": 1 }
  }},
  { "$group": {
    "_id": null,
    "data": {
      "$push": { 
         "k": { 
           "$concat": [
             { "$toLower": "$_id" },
             "Count"
           ]
         }, 
         "v": "$count"
      }
    },
    "avgWeight": { "$avg": "$avgWeight" },
    "avgHeight": { "$avg": "$avgHeight" },
    "baldCount": { "$sum": "$baldCount" }
  }},
  { "$replaceRoot": {
    "newRoot": {
      "$mergeObjects": [
        { "$arrayToObject": "$data" },
        {
          "avgWeight": "$avgWeight",
          "avgHeight": "$avgHeight",
          "baldCount": "$baldCount"
        }      
      ]
    }  
  }}
])

作为一个轻微的替代方案,您可以$mergeObjects$group此处应用:

db.fruit.aggregate([
  { "$group": {
    "_id": "$fruit",
    "avgWeight": { "$avg": "$weight" },
    "avgHeight": { "$avg": "$height" },
    "baldCount": {
      "$sum": { "$cond": [{ "$eq": ["$bald", "Yes"] }, 1, 0] }
    },
    "count": { "$sum": 1 }
  }},
  { "$group": {
    "_id": null,
    "data": {
      "$mergeObjects": {
        "$arrayToObject": [[{
          "k": { 
            "$concat": [
              { "$toLower": "$_id" },
              "Count"
            ]
          }, 
          "v": "$count"
        }]]
      }
    },
    "avgWeight": { "$avg": "$avgWeight" },
    "avgHeight": { "$avg": "$avgHeight" },
    "baldCount": { "$sum": "$baldCount" }
  }},
  { "$replaceRoot": {
    "newRoot": {
      "$mergeObjects": [
        "$data",
        {
          "avgWeight": "$avgWeight",
          "avgHeight": "$avgHeight",
          "baldCount": "$baldCount"
        }      
      ]
    }
  }}
])

但我个人认为这不是更好的方法是有原因的,这主要导致了下一个概念。

因此,即使您没有“最新”的 MongoDB 版本,您也可以简单地重塑输出,因为这是实际使用 MongoDB 3.6 功能的最后一个管道阶段正在做的事情:

db.fruit.aggregate([
  { "$group": {
    "_id": "$fruit",
    "avgWeight": { "$avg": "$weight" },
    "avgHeight": { "$avg": "$height" },
    "baldCount": {
      "$sum": { "$cond": [{ "$eq": ["$bald", "Yes"] }, 1, 0] }
    },
    "count": { "$sum": 1 }
  }},
  { "$group": {
    "_id": null,
    "data": {
      "$push": { 
         "k": { 
           "$concat": [
             { "$toLower": "$_id" },
             "Count"
           ]
         }, 
         "v": "$count"
      }
    },
    "avgWeight": { "$avg": "$avgWeight" },
    "avgHeight": { "$avg": "$avgHeight" },
    "baldCount": { "$sum": "$baldCount" }
  }},
  /*
  { "$replaceRoot": {
    "newRoot": {
      "$mergeObjects": [
        { "$arrayToObject": "$data" },
        {
          "avgWeight": "$avgWeight",
          "avgHeight": "$avgHeight",
          "baldCount": "$baldCount"
        }      
      ]
    }  
  }}
  */
]).map( d =>
  Object.assign(
    d.data.reduce((acc,curr) => Object.assign(acc,{ [curr.k]: curr.v }), {}),
    { avgWeight: d.avgWeight, avgHeight: d.avgHeight, baldCount: d.baldCount }
  )
)

当然,您甚至可以对密钥进行“硬编码”:

db.fruit.aggregate([
  { "$group": {
    "_id": null,
    "appleCount": {
      "$sum": {
        "$cond": [{ "$eq": ["$fruit", "Apple"] }, 1, 0]
      }
    },
    "orangeCount": {
      "$sum": {
        "$cond": [{ "$eq": ["$fruit", "Orange"] }, 1, 0]
      }
    },
    "avgWeight": { "$avg": "$weight" },
    "avgHeight": { "$avg": "$height" },
    "baldCount": {
      "$sum": {
        "$cond": [{ "$eq": ["$bald", "Yes"] }, 1, 0]
      }
    }
  }}
])

但不建议这样做,因为您的数据可能会在某一天发生变化,如果有“分组”的价值,那么实际使用它比使用条件强制要好。

以任何形式返回相同的结果:

{
        "appleCount" : 1,
        "orangeCount" : 1,
        "avgWeight" : 3,
        "avgHeight" : 4,
        "baldCount" : 1
}

我们使用“两个”$group阶段来完成此操作,一次用于累积“每个水果”,然后使用$pushunder"k""v"值将所有水果压缩到一个数组中,以保持它们的“键”和“计数”。我们在这里使用$toLower$concat连接字符串对“键”进行了一些转换。在此阶段这是可选的,但通常更容易。

3.6 的“替代”只是$mergeObjects在这个早期阶段应用,而不是$push因为我们已经积累了这些密钥。它只是真正将其$arrayToObject转移到管道中的不同阶段。这不是真的必要,也没有任何特定的优势。如果有的话,它只是删除了灵活的选项,如稍后讨论的“客户端转换”所示。

“平均”累加是通过 完成$avg的,并"bald"使用$cond来测试字符串并将数字输入到$sum. 当数组“卷起来”时,我们可以再次进行所有这些累加,以汇总所有内容。

如前所述,真正依赖“新功能”的唯一部分都在$replaceRoot重写“根”文档的阶段。这就是为什么这是可选的,因为您可以在从数据库返回相同的“已聚合”数据后简单地进行这些转换。

我们在这里真正要做的就是将该数组与"k""v"条目一起使用,并将其转换为具有命名键的“对象”,并使用我们已经在“根”处生成的其他键$arrayToObject应用于该结果。$mergeObjects这将该数组转换为结果中返回的主文档的一部分。

使用shell 兼容代码中的 JavaScriptArray.reduce()Object.assign()方法应用完全相同的转换。mongo这是一件非常简单的事情,并且Cursor.map()通常是大多数语言实现的一个特性,因此您可以在开始使用游标结果之前进行这些转换。

使用 ES6 兼容的 JavaScript 环境(不是 shell),我们可以进一步缩短语法:

.map(({ data, ...d }) => ({ ...data.reduce((o,[k,v]) => ({ ...o, [k]: v }), {}), ...d }) )

所以它确实是一个“单行”函数,这就是为什么像这样的转换在客户端代码中通常比服务器代码更好的一般原因。

作为使用 的注释,请$cond注意将其用于“硬编码”评估并不是一个好主意,原因有几个。因此,“强制”该评估确实没有多大意义。即使使用您提供的数据,"bald"也可以更好地表示为Boolean值而不是“字符串”。如果您更改"Yes/No"为,true/false那么即使是“一个”有效用法也将变为:

"baldCount": { "$sum": { "$cond": ["$bald", 1, 0 ] } }

这消除了在字符串匹配上“测试”条件的需要,因为它已经是true/false. MongoDB 4.0 添加了另一个增强功能,使用$toInt“强制”Boolean为整数:

"baldCount": { "$sum": { "$toInt": "$bald" } }

这完全消除了对数据的需要$cond,就像简单地记录一样10但是这种变化可能会导致数据失去清晰度,因此在那里进行这种“强制”仍然可能是合理的,但在其他任何地方都不是最佳的。

即使采用“动态”形式,使用“两”$group阶段进行积累,主要工作还是在第一阶段完成。它只是将剩余的积累留在n结果文档中,以获得分组键的可能唯一值的数量。在这种情况下是“两个”,因此即使它是一条额外的指令,也没有真正的开销来获得灵活的代码。


MapReduce

如果您真的下定决心至少“尝试” a mapReduce,那么这实际上是单次通过,具有finalize仅用于计算平均值的功能

db.fruit.mapReduce(
  function() {
    emit(null,{ 
      "key": { [`${this.fruit.toLowerCase()}Count`]: 1 },
      "totalWeight": this.weight,
      "totalHeight": this.height,
      "totalCount": 1,
      "baldCount": (this.bald === "Yes") ? 1 : 0
    });
  },
  function(key,values) {
    var output = {
      key: { },
      totalWeight: 0,
      totalHeight: 0,
      totalCount: 0,
      baldCount: 0
    };

    for ( let value of values ) {
      for ( let key in value.key ) {
        if ( !output.key.hasOwnProperty(key) )
          output.key[key] = 0;

        output.key[key] += value.key[key];
      }

      Object.keys(value).filter(k => k != 'key').forEach(k =>
        output[k] += value[k]
      )
    }

    return output;
  },
  { 
    "out": { "inline": 1 },
    "finalize": function(key,value) {
      return Object.assign(
        value.key,
        {
          avgWeight: value.totalWeight / value.totalCount,
          avgHeight: value.totalHeight / value.totalCount,
          baldCount: value.baldCount
        }
      )
    }
  }
)

由于我们已经完成了该aggregate()方法的过程,因此一般要点应该非常熟悉,因为我们在这里基本上做同样的事情。

主要区别在于“平均”,您实际上需要完整的总数和计数,当然,您可以通过 JavaScript 代码通过“对象”对累积进行更多控制。

结果基本相同,只是标准mapReduce“弯曲”了它们的呈现方式:

  {
      "_id" : null,
      "value" : {
        "orangeCount" : 1,
        "appleCount" : 1,
        "avgWeight" : 3,
        "avgHeight" : 4,
        "baldCount" : 1
      }
  }

概括

一般的问题当然是 MapReduce 使用解释的 JavaScript 来执行比聚合框架的本机编码操作具有更高的成本和更慢的执行速度。曾经可能有一个选项可以使用 MapReduce 来实现这种输出更大”的结果集,但由于 MongoDB 2.6 为聚合框架引入了“光标”输出,因此规模已经坚定地倾向于更新的选项。

事实上,大多数使用 MapReduce 的“遗留”原因基本上都被它的年轻兄弟所取代,因为聚合框架获得了新的操作,从而消除了对 JavaScript 执行环境的需求。公平地说,对 JavaScript 执行的支持通常正在“减少”,一旦从一开始就使用它的遗留选项正在逐渐从产品中删除。


推荐阅读