首页 > 解决方案 > 在MongoDB中对最近的位置进行分组

问题描述

位置点另存为

{
  "location_point" : {
  "coordinates" : [ 
      -95.712891, 
      37.09024
  ],
  "type" : "Point"
  },
  "location_point" : {
  "coordinates" : [ 
      -95.712893, 
      37.09024
  ],
  "type" : "Point"
  },
  "location_point" : {
  "coordinates" : [ 
      -85.712883, 
      37.09024
  ],
  "type" : "Point"
  },
  .......
  .......
}

有几个文件。我需要到group最近的位置。分组后,第一个第二个位置将在一个文档中,第三个在第二个文档中。请注意,第一个和第二个的位置点不相等。两者都是最近的地方。

有什么办法吗?提前致谢。

标签: mongodbgeolocationaggregation-frameworkaggregategeonear

解决方案


快速而懒惰的解释是同时使用$geoNear$bucket聚合管道阶段来获得结果:

.aggregate([
    {
      "$geoNear": {
        "near": {
          "type": "Point",
          "coordinates": [
            -95.712891,
            37.09024
          ]
        },
        "spherical": true,
        "distanceField": "distance",
        "distanceMultiplier": 0.001
      }
    },
    {
      "$bucket": {
        "groupBy": "$distance",
        "boundaries": [
          0, 5, 10, 20,  50,  100,  500
        ],
        "default": "greater than 500km",
        "output": {
          "count": {
            "$sum": 1
          },
          "docs": {
            "$push": "$$ROOT"
          }
        }
      }
    }
])

更长的形式是您可能应该理解“为什么?” 这是如何解决问题的一部分,甚至可以选择理解即使这确实应用了至少一个仅在最近的 MongoDB 版本中引入的聚合运算符,但实际上这一切都可以追溯到 MongoDB 2.4。

使用 $geoNear

在任何“分组”中要查找的主要内容基本上是"distance"添加到“近”查询的结果中的字段,指示该结果与搜索中使用的坐标的距离。幸运的是,这正是$geoNear聚合管道阶段所做的。

基本阶段将是这样的:

{
  "$geoNear": {
    "near": {
      "type": "Point",
      "coordinates": [
        -95.712891,
        37.09024
      ]
    },
    "spherical": true,
    "distanceField": "distance",
    "distanceMultiplier": 0.001
  }
},

此阶段具有必须提供的三个强制参数:

  • near - 是用于查询的位置。这可以是传统坐标对形式或 GeoJSON 数据。任何作为 GeoJSON 的东西基本上都以为单位来考虑结果,因为那是 GeoJSON 标准。

  • 球形-强制,但仅当索引类型为2dsphere. 它默认为false,但您可能确实需要2dsphere地球表面上任何真实地理位置数据的索引。

  • distanceField - 这也始终是必需的,它是要添加到文档中的字段的名称,其中将包含与查询位置的距离near。此结果将以弧度或米为单位,具体取决于near参数中使用的数据格式的类型。结果也受可选参数的影响,如下所述。

可选参数是:

  • distanceMultiplier - 这会将命名字段路径中的结果更改为distanceField. 乘数应用于返回值,可用于将单位“转换”为所需格式。

    注意:不适distanceMultiplier用于其他可选参数,例如maxDistanceor minDistance。应用于这些可选参数的约束必须采用原始返回的单位格式。因此,对于 GeoJSON,任何为“最小”或“最大”距离设置的界限都需要以为单位计算,无论您是否distanceMultiplier使用类似km或的值转换了值miles

这要做的主要事情是简单地返回“最近的”文档(默认情况下最多 100 个),并按从最近到最远的顺序distanceField返回,并在现有文档内容中包含名为 the 的字段,这就是前面提到的允许您“分组”的实际输出。

这里distanceMultiplier只是将GeoJSON的默认米转换为公里以进行输出。如果您想要输出中的英里数,那么您将更改乘数。IE:

"distanceMultiplier": 0.000621371

它完全是可选的,但您需要了解在下一个“分组”阶段要应用哪些单位(已转换或未转换):


实际的“分组”归结为三个不同的选项,具体取决于您可用的 MongoDB 和您的实际需求:

选项 1 - $bucket

$bucket管道阶段是在 MongoDB 3.4 中添加的。它实际上是该版本中添加的几个“管道阶段”之一,它们实际上更像是宏函数或用于编写流水线阶段和实际运算符组合的基本简写形式。稍后再谈。

主要的基本参数是groupBy表达式,boundaries它指定“分组”范围的下限,以及一个选项,只要与表达式匹配的数据不落在定义的条目之间,该default选项基本上用作_id输出中的 *“分组键”或字段groupByboundaries.

    {
      "$bucket": {
        "groupBy": "$distance",
        "boundaries": [
          0, 5, 10, 20,  50,  100,  500
        ],
        "default": "greater than 500km",
        "output": {
          "count": {
            "$sum": 1
          },
          "docs": {
            "$push": "$$ROOT"
          }
        }
      }
    }

另一部分是output,它基本上包含与 a 一起使用的相同的累加器表达式$group,并且它确实应该为您提供$bucket实际扩展到哪个聚合管道阶段的指示。那些根据“分组键”进行实际的“数据收集”。

虽然有用,但有一个小错误,$bucket_id输出只会是在数据超出约束boundaries的选项内或选项内定义的值。如果您想要“更好”的东西,通常会在结果的客户端后处理中完成,例如:defaultboundaries

result = result
  .map(({ _id, ...e }) =>
    ({
      _id: (!isNaN(parseFloat(_id)) && isFinite(_id))
        ? `less than ${bounds[bounds.indexOf(_id)+1]}km`
        : _id,
      ...e
    })
  );

这将用更有意义的“字符串”替换返回字段中的任何普通数值_id,描述实际分组的内容。

请注意,虽然 adefault“可选的”,但如果任何数据超出边界范围,您将收到一个硬错误。事实上,返回的非常具体的错误导致我们进入下一个案例。

选项 2 - $group 和 $switch

从上面所说的,您可能已经意识到,来自管道阶段的“宏转换”$bucket实际上变成了一个$group阶段,并且专门将运算符用作分组字段$switch的参数。_idMongoDB 3.4再次$switch引入了操作符。

本质上,这实际上是使用 对上面显示的内容进行的手动构造,$bucket对字段的输出进行了一些微调,并且对前者产生的表达式_id不太简洁。事实上,您可以使用聚合管道的“解释”输出来查看与以下清单“相似”的内容,但使用上面定义的管道阶段:

{
  "$group": {
    "_id": {
      "$switch": {
        "branches": [
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    5
                  ]
                },
                {
                  "$gte": [
                    "$distance",
                    0
                  ]
                }
              ]
            },
            "then": "less than 5km"
          },
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    10
                  ]
                }
              ]
            },
            "then": "less than 10km"
          },
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    20
                  ]
                }
              ]
            },
            "then": "less than 20km"
          },
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    50
                  ]
                }
              ]
            },
            "then": "less than 50km"
          },
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    100
                  ]
                }
              ]
            },
            "then": "less than 100km"
          },
          {
            "case": {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    500
                  ]
                }
              ]
            },
            "then": "less than 500km"
          }
        ],
        "default": "greater than 500km"
      }
    },
    "count": {
      "$sum": 1
    },
    "docs": {
      "$push": "$$ROOT"
    }
  }
}

事实上,除了更清晰的“标签”$bucket之外,唯一的实际区别是$gte$lte每个case. 这不是必需的,因为$switch实际工作方式以及逻辑条件如何“失败”,就像它们在switch逻辑块的通用语言对应用法中一样。

这实际上更多地取决于个人偏好问题,即您是否更乐意为语句中定义输出“字符串”,_id或者case您是否可以对后处理值进行重新格式化以重新格式化此类内容。

无论哪种方式,这些基本上返回与我们的第三个选项相同的输出(除了对结果有定义的顺序)。$bucket

选项 3 - $group 和 $cond

如前所述,上述所有内容基本上都是基于$switch运算符的,但就像它在各种编程语言实现中的对应物一样,“switch 语句”实际上只是一种更清洁、更方便的编写方式if .. then .. else if ...等等。if .. then .. elseMongoDB在 MongoDB 2.2 中也有一个表达式$cond

{
  "$group": {
    "_id": {
      "$cond": [
        {
          "$and": [
            {
              "$lt": [
                "$distance",
                5
              ]
            },
            {
              "$gte": [
                "$distance",
                0
              ]
            }
          ]
        },
        "less then 5km",
        {
          "$cond": [
            {
              "$and": [
                {
                  "$lt": [
                    "$distance",
                    10
                  ]
                }
              ]
            },
            "less then 10km",
            {
              "$cond": [
                {
                  "$and": [
                    {
                      "$lt": [
                        "$distance",
                        20
                      ]
                    }
                  ]
                },
                "less then 20km",
                {
                  "$cond": [
                    {
                      "$and": [
                        {
                          "$lt": [
                            "$distance",
                            50
                          ]
                        }
                      ]
                    },
                    "less then 50km",
                    {
                      "$cond": [
                        {
                          "$and": [
                            {
                              "$lt": [
                                "$distance",
                                100
                              ]
                            }
                          ]
                        },
                        "less then 100km",
                        "greater than 500km"
                      ]
                    }
                  ]
                }
              ]
            }
          ]
        }
      ]
    },
    "count": {
      "$sum": 1
    },
    "docs": {
      "$push": {
        "_id": "$_id",
        "location_point": "$location_point",
        "distance": "$distance"
      }
    }
  }
}

同样,它实际上都是一样的,主要区别在于,不是一个“干净的数组”选项来处理为“案例”,而是你拥有的是一组嵌套的条件,其中else只包含另一个$cond,直到找到“边界”的末尾,然后else只包含该default值。

由于我们至少还“假装”我们将回到 MongoDB 2.4(这是实际运行的约束条件)$geoNear,因此其他类似的东西$$ROOT在该版本中不可用,因此您只需命名所有字段表达式的文档,以便使用$push.


代码生成

所有这一切真的应该归结为“分组”实际上是$bucket用在撰写本文时在 3.4 下运行任何 MongoDB)。

当然,所需语法中的任何其他形式都更长,但实际上可以应用相同的参数数组来基本上生成和运行上面显示的任何一种形式。

下面是一个示例清单(针对 NodeJS),它表明它实际上只是一个简单的过程,从一个简单bounds的分组数组生成这里的所有内容,甚至只是几个定义的选项,它们都可以在管道操作中重复使用作为用于生成流水线指令或将返回的结果操作为“更漂亮”的输出格式的任何客户端预处理或后处理。

const { Schema } = mongoose = require('mongoose');

const uri = 'mongodb://localhost:27017/test',
      options = { useNewUrlParser: true };

mongoose.set('useFindAndModify', false);
mongoose.set('useCreateIndex', true);
mongoose.set('debug', true);

const geoSchema = new Schema({
  location_point: {
    type: { type: String, enum: ["Point"], default: "Point" },
    coordinates: [Number, Number]
  }
});

geoSchema.index({ "location_point": "2dsphere" },{ background: false });

const GeoModel = mongoose.model('GeoModel', geoSchema, 'geojunk');

const [{ location_point: near }] = data = [
  [ -95.712891, 37.09024 ],
  [ -95.712893, 37.09024 ],
  [ -85.712883, 37.09024 ]
].map(coordinates => ({ location_point: { type: 'Point', coordinates } }));


const log = data => console.log(JSON.stringify(data, undefined, 2));

(async function() {

  try {
    const conn = await mongoose.connect(uri, options);

    // Clean data
    await Promise.all(
      Object.entries(conn.models).map(([k,m]) => m.deleteMany())
    );

    // Insert data
    await GeoModel.insertMany(data);

    const bounds = [ 5, 10, 20, 50, 100, 500 ];
    const distanceField = "distance";


    // Run three sample cases
    for ( let test of [0,1,2] ) {

      let pipeline = [
        { "$geoNear": {
          near,
          "spherical": true,
          distanceField,
          "distanceMultiplier": 0.001
        }},
        (() => {

          // Standard accumulators
          const output = {
            "count":  { "$sum": 1 },
            "docs": { "$push": "$$ROOT" }
          };

          switch (test) {

            case 0:
              log("Using $bucket");
              return (
                { "$bucket": {
                  "groupBy": `$${distanceField}`,
                  "boundaries": [ 0, ...bounds ],
                  "default": `greater than ${[...bounds].pop()}km`,
                  output
                }}
              );
            case  1:
              log("Manually using $switch");
              let branches = bounds.map((bound,i) =>
                ({
                  'case': {
                    '$and': [
                      { '$lt': [ `$${distanceField}`, bound ] },
                      ...((i === 0) ? [{ '$gte': [ `$${distanceField}`, 0 ] }]: [])
                    ]
                  },
                  'then': `less than ${bound}km`
                })
              );
              return (
                { "$group": {
                  "_id": {
                    "$switch": {
                      branches,
                      "default": `greater than ${[...bounds].pop()}km`
                    }
                  },
                  ...output
                }}
              );
            case 2:
              log("Legacy using $cond");
              let _id = null;

              for (let i = bounds.length -1; i > 0; i--) {
                let rec = {
                  '$cond': [
                    { '$and': [
                      { '$lt': [ `$${distanceField}`, bounds[i-1] ] },
                      ...((i == 1) ? [{ '$gte': [ `$${distanceField}`, 0 ] }] : [])
                    ]},
                    `less then ${bounds[i-1]}km`
                  ]
                };

                if ( _id == null ) {
                  rec['$cond'].push(`greater than ${bounds[i]}km`);
                } else {
                  rec['$cond'].push( _id );
                }
                _id = rec;
              }

              // Older MongoDB may require each field instead of $$ROOT
              output.docs.$push =
                ["_id", "location_point", distanceField]
                  .reduce((o,e) => ({ ...o, [e]: `$${e}` }),{});
              return ({ "$group": { _id, ...output } });

          }

        })()
      ];

      let result = await GeoModel.aggregate(pipeline);


      // Text based _id for test: 0 with $bucket
      if ( test === 0 )
        result = result
          .map(({ _id, ...e }) =>
            ({
              _id: (!isNaN(parseFloat(_id)) && isFinite(_id))
                ? `less than ${bounds[bounds.indexOf(_id)+1]}km`
                : _id,
              ...e
            })
          );

      log({ pipeline, result });

    }

  } catch (e) {
    console.error(e)
  } finally {
    mongoose.disconnect();
  }

})()

和示例输出(当然,上面的所有列表都是从此代码生成的):

Mongoose: geojunk.createIndex({ location_point: '2dsphere' }, { background: false })
"Using $bucket"
{
  "result": [
    {
      "_id": "less than 5km",
      "count": 2,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe94",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712891,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 0
        },
        {
          "_id": "5ca897dd2efdc41b79d5fe95",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712893,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 0.00017759511720976155
        }
      ]
    },
    {
      "_id": "greater than 500km",
      "count": 1,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe96",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -85.712883,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 887.5656539981669
        }
      ]
    }
  ]
}
"Manually using $switch"
{
  "result": [
    {
      "_id": "greater than 500km",
      "count": 1,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe96",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -85.712883,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 887.5656539981669
        }
      ]
    },
    {
      "_id": "less than 5km",
      "count": 2,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe94",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712891,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 0
        },
        {
          "_id": "5ca897dd2efdc41b79d5fe95",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712893,
              37.09024
            ]
          },
          "__v": 0,
          "distance": 0.00017759511720976155
        }
      ]
    }
  ]
}
"Legacy using $cond"
{
  "result": [
    {
      "_id": "greater than 500km",
      "count": 1,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe96",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -85.712883,
              37.09024
            ]
          },
          "distance": 887.5656539981669
        }
      ]
    },
    {
      "_id": "less then 5km",
      "count": 2,
      "docs": [
        {
          "_id": "5ca897dd2efdc41b79d5fe94",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712891,
              37.09024
            ]
          },
          "distance": 0
        },
        {
          "_id": "5ca897dd2efdc41b79d5fe95",
          "location_point": {
            "type": "Point",
            "coordinates": [
              -95.712893,
              37.09024
            ]
          },
          "distance": 0.00017759511720976155
        }
      ]
    }
  ]
}

推荐阅读