【问题标题】:Accumulate Documents with Dynamic Keys使用动态键累积文档
【发布时间】:2018-05-23 13:22:07
【问题描述】:

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

{
   _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 一段时间了,也许有一些我不知道的新方法可以做到这一点?

【问题讨论】:

    标签: mongodb mongodb-query aggregation-framework


    【解决方案1】:

    聚合框架

    聚合框架对你来说会比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"
            }      
          ]
        }  
      }}
    ])
    

    作为一种替代方法,您可以在此处应用 $group 中的 $mergeObjects

    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 阶段来执行此操作,一次用于累积“每个水果”,然后使用$push"k""v" 值下将所有水果压缩到一个数组中以保持它们的“键”和他们的“计数”。我们在此处使用$toLower$concat 对“键”进行一些转换以加入字符串。在此阶段这是可选的,但通常更容易。

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

    “平均”累加是通过$avg 完成的,"bald" 使用$cond 进行计数,以测试字符串并将一个数字提供给$sum。随着数组“卷起”,我们可以再次对所有这些累加进行汇总。

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

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

    使用 mongo shell 兼容代码中的 JavaScript Array.reduce()Object.assign() 方法应用完全相同的转换。这是一件非常简单的事情,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

    如果你真的下定决心至少“尝试”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 执行的支持通常正在“减少”,并且一旦从一开始就使用它的遗留选项正在逐渐从产品中删除。

    【讨论】:

      【解决方案2】:
      db.demo.aggregate(
      
          // Pipeline
          [
              // Stage 1
              {
                  $project: {
                      weight: 1,
                      height: 1,
                      Orange: {
                          $cond: {
                              if: {
                                  $eq: ["$fruit", 'Orange']
                              },
                              then: {
                                  $sum: 1
                              },
                              else: 0
                          }
                      },
                      Apple: {
                          $cond: {
                              if: {
                                  $eq: ["$fruit", 'Apple']
                              },
                              then: {
                                  $sum: 1
                              },
                              else: 0
                          }
                      },
                      bald: {
                          $cond: {
                              if: {
                                  $eq: ["$bald", 'Yes']
                              },
                              then: {
                                  $sum: 1
                              },
                              else: 0
                          }
                      },
                  }
              },
      
              // Stage 2
              {
                  $group: {
                      _id: null,
                      avgWeight: {
                          $avg: '$weight'
                      },
                      avgHeight: {
                          $avg: '$height'
                      },
                      orangeCount: {
                          $sum: '$Orange'
                      },
                      appleCount: {
                          $sum: '$Apple'
                      },
                      baldCount: {
                          $sum: '$bald'
                      }
                  }
              },
      
          ]
      
      
      
      );
      

      【讨论】:

      • 如果有西瓜怎么办?我认为您错过了问题的重点,因为 OP 显然不知道所有数据或想要为每个可能的值构造一个条件。
      • @NeilLunn 我特别说过永远不会有西瓜。今晚下班后我会试试这个,它看起来完全符合我的需要。我什至没有想过使用 $project 和 $group 来模拟 map/reduce。这看起来很有希望。
      • @JoshuaLawrenceAustill 你确实这么说。但是,即使条件评估会增加更多开销,我也不建议对这些进行“硬编码”。对于"bald" 数据的“一个”有效用法还有另一种情况,我也对此进行了解释。尽管如此,$project 在这里是不必要的,并且通过在累积之前转换所有数据来增加更多开销。这真的很昂贵,而且可以做得更好,正如我所展示的那样。
      • 这很好用,甚至可以让我创建一个视图。
      猜你喜欢
      • 2016-05-12
      • 2017-05-16
      • 2015-06-07
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-03-07
      • 1970-01-01
      • 2018-05-13
      相关资源
      最近更新 更多