Группировка ближайших местоположений в Mongodb

#mongodb #geolocation #aggregation-framework #aggregate #geonear

#mongodb #геолокация #агрегация-фреймворк #агрегировать #geonear

Вопрос:

Точка местоположения сохранена как

 {
  "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 это по ближайшим местоположениям.
После группировки первые местоположения econd будут в одном документе, а третьи — во втором.
Пожалуйста, не обращайте внимания на то, что точки расположения первого и второго не равны. Оба являются ближайшими местами.

Есть ли какой-нибудь способ? Заранее спасибо.

Комментарии:

1. Как вы думаете, что на самом деле означает «группировать по ближайшим»? Покажите пример некоторых документов и то, что вы ожидаете получить в качестве выходных данных запроса. Один документ действительно ничего нам не говорит, кроме того, что он, вероятно, ближайший и, следовательно, результат. Также показывайте любые попытки кода, так как, по крайней мере, это может дать некоторое представление о том, что вы на самом деле спрашиваете.

2. Обновлен вопрос.

3. Все еще больше, чем просто немного расплывчато. Когда я сказал продемонстрировать, что вы на самом деле имеете в виду, тогда ожидаемый результат из предоставленных документов, по крайней мере, даст некоторое представление о том, чего вы ожидаете. Возможно, вы имеете в виду «группы» «в пределах 5 километров», и «от 5 до 10 километров», и так далее до «более 100 км». Но без отображения фактического ожидания «группировка по ближайшему» на самом деле нам ничего не говорит. Вы могли бы быть и действительно «должны быть» намного более описательными.

4. ДА. Ты это сказал. Например, «группы» из «в пределах 5 километров;

5. Есть ли что-то в предоставленном ответе, что, по вашему мнению, не отвечает на ваш вопрос? Если да, то, пожалуйста, прокомментируйте ответ, чтобы уточнить, что именно необходимо решить, а что нет. Если это действительно отвечает на заданный вами вопрос, пожалуйста, обратите внимание, чтобы принять ваши ответы на вопросы, которые вы задаете

Ответ №1:

Быстрое и ленивое объяснение заключается в использовании обоих этапов конвейера $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" полем, добавляемым к результату запроса «near», указывающим, как далеко этот результат находится от координат, используемых в поиске. К счастью, это именно то, что делает $geoNear этап конвейера агрегации.

Базовый этап будет примерно таким:

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

This stage has three mandatory arguments that must be supplied:

  • near — Is the location to use for the query. This can either be in legacy coordinate pair form or as GeoJSON data. Anything as GeoJSON is basically considered in meters for results, since that is the GeoJSON standard.

  • sphericalMandatory, but really only when the index type is 2dsphere . It defaults to false , but you probably do want a 2dsphere index for any real Geolocation data on the surface of the earth.

  • distanceField — This is also always required and it is the name of the field to be added to the document which will contain the distance from the queried location via near . This result will be in either radians or meters depending on the type of the data format used in the near argument. The result is also affected by the optional argument as noted next.

The optional argument is:

  • distanceMultiplier — This alters the result in the named field path to distanceField . The multiplier is applied to the returned value and can be used for «conversion» of units to the desired format.

    NOTE: The distanceMultiplier does NOT apply to other optional arguments like maxDistance or minDistance . Constraints applied to these optional arguments must be in the original returned units format. Therefore with GeoJSON any bounds set for «min» or «max» distances need to be calculated as meters regardless of whether you converted a distanceMultiplier value with something like km or miles .

The main thing this is going to do is simply return the «nearest» documents ( up to 100 by default ) in order of nearest to furthest away and include the field named as the distanceField within the existing document contents and that’s what was mentioned earlier as the actual output which will allow you to «group».

The distanceMultiplier here is simply converting the default meters of GeoJSON to kilometers for output. If you wanted miles in the output then you would change the multiplier. i.e:

 "distanceMultiplier": 0.000621371
  

It’s totally optional, but you will need to be aware of what units ( converted or not ) are to be applied in the next «grouping» stage:


The actual «grouping» comes down to three different options depending on your available MongoDB and you actual needs:

Вариант 1 — $ ведро

$bucket Этап конвейера был добавлен с MongoDB 3.4. На самом деле это один из нескольких «этапов конвейера», которые были добавлены в этой версии и которые на самом деле больше похожи на макрофункцию или базовую форму сокращения для написания комбинации этапов конвейера и реальных операторов. Подробнее об этом позже.

Основными базовыми аргументами являются groupBy выражение, boundaries которое определяет нижние границы для диапазонов «группировки», и default параметр, который в основном применяется как * «ключ группировки» или _id поле в выходных данных всякий раз, когда данные, соответствующие groupBy выражению, не попадают между записями, определенными с помощью boundaries .

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

Другой раздел — это output , который в основном содержит те же выражения накопителя, которые вы использовали бы с $group , и это действительно должно дать вам представление о том, до какой стадии конвейера агрегации это $bucket фактически расширяется. Они выполняют фактический «сбор данных» по «ключу группировки».

Несмотря на полезность, есть одна небольшая ошибка, $bucket заключающаяся в том, что _id выводом всегда будут только значения, определенные в boundaries или в default опции, где данные выходят за пределы boundaries ограничения. Если вы хотите что-то «более приятное», обычно это делается при последующей обработке результатов клиентом с помощью чего-то вроде:

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

Это заменило бы любые простые числовые значения в возвращаемых _id полях более значимой «строкой», описывающей, что на самом деле группируется.

Обратите внимание, что, хотя default является «необязательным», вы получите серьезную ошибку в случае, когда какие-либо данные выходят за пределы граничного диапазона. Фактически, возвращенная очень специфическая ошибка приводит нас к следующему случаю.

Вариант 2 — $group и $switch

Из сказанного выше вы, возможно, поняли, что «макроперевод» со стадии $bucket конвейера фактически становится $group этапом, который специально применяет $switch оператор в качестве аргумента к _id полю для группировки. Снова $switch оператор был представлен с MongoDB 3.4.

По сути, это действительно ручная конструкция того, что было показано выше с использованием $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 операторе, но, как и его аналог в различных реализациях языков программирования, «оператор переключения» на самом деле является просто более чистым и удобным способом записи if .. then .. else if ... и так далее. В MongoDB также есть if .. then .. else выражение, возвращающееся к 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"
      }
    }
  }
}
  

Again it’s really all just the same, with the main difference being that instead of a «clean array» of options to process as «cases», what you have instead is a nested set of conditions where the else just contains another $cond , right up until the end of the «boundaries» are found and then the else contains just the default value.

Since we are also at least «pretending» that we are going back as far as MongoDB 2.4 ( which is the constraint for actually running with $geoNear , then other things like $$ROOT would not be available in that version so instead you would simply name all the field expressions of the document in order to add that content with a $push .


Code Generation

All of this really should come down to that the «grouping» is actually done with the $bucket and that it’s probably what you would use unless you wanted some customization of the output or if your MongoDB version did not support it ( though you probably should not be running any MongoDB under 3.4 at this present time of writing ).

Of course any other form is longer in the required syntax, but really just the same array of arguments can be applied to essentially generate and run either of the shown forms above.

Ниже приведен пример листинга (для 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)) amp;amp; 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
        }
      ]
    }
  ]
}
  

Комментарии:

1. Это отличный ответ! 😍