Группировка ближайших локаций в Mongodb - PullRequest
1 голос
/ 03 апреля 2019

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

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

Есть ли способ? Заранее спасибо.

1 Ответ

1 голос
/ 06 апреля 2019

Быстрое и ленивое объяснение состоит в том, чтобы использовать $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
  }
},

Этот этап имеет три обязательных аргумента, которые должны быть предоставлены:

  • вблизи - местоположение, используемое для запроса. Это может быть либо в виде устаревшей пары координат, либо в виде данных GeoJSON. Все, что касается GeoJSON, в основном рассматривается в метрах для результатов, поскольку это стандарт GeoJSON.

  • сферический - Обязательный , но только в том случае, если тип индекса равен 2dsphere. По умолчанию это false, но вам, вероятно, нужен индекс 2dsphere для любых реальных данных геолокации на поверхности Земли.

  • distanceField - Это также всегда требуется, и это имя поля, которое будет добавлено в документ, который будет содержать расстояние от запрашиваемого местоположения через near. Этот результат будет в радианах или метрах в зависимости от типа формата данных, используемого в аргументе near. На результат также влияет необязательный аргумент , как указано далее.

Необязательный аргумент:

  • distanceMultiplier - Это изменяет результат в пути именованного поля на distanceField. множитель применяется к возвращаемому значению и может использоваться для «преобразования» единиц в желаемый формат.

    ПРИМЕЧАНИЕ: distanceMultiplier действительно НЕ применяется к другим необязательным аргументам, таким как maxDistance или minDistance. Ограничения, применяемые к этим необязательным аргументам , должны быть в исходном формате возвращаемых единиц . Поэтому с GeoJSON любые границы, установленные для расстояний «мин» или «макс», должны быть рассчитаны как метры независимо от того, преобразовали ли вы значение distanceMultiplier в что-то вроде km или miles.

Главное, что мы собираемся сделать, это просто вернуть «ближайшие» документы (по умолчанию до 100) в порядке от ближайшего к дальней и включить поле, названное distanceField в существующее содержимое документа и то, что было упомянуто ранее как фактический вывод, который позволит вам «группировать».

distanceMultiplier здесь просто конвертирует значение по умолчанию метров GeoJSON в километров для вывода. Если вы хотите, чтобы миль на выходе, вы бы изменили множитель. то есть:

"distanceMultiplier": 0.000621371

Это совершенно необязательно, но вам необходимо знать, какие единицы (преобразованные или нет) должны быть применены в следующей «группировке» стадии:


Фактическая «группировка» сводится к трем различным вариантам в зависимости от вашей доступной MongoDB и ваших реальных потребностей:

Вариант 1 - $ bucket

Стадия конвейера $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)) && 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, но, как и его аналог в различных реализациях языка программирования, «оператор 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"
      }
    }
  }
}

Опять же, на самом деле все одинаково, главное отличие состоит в том, что вместо «чистого массива» параметров для обработки в качестве «дел» вместо этого используется вложенный набор условий, в которых else просто содержитдругой $cond, вплоть до конца "границ", а затем else содержит только значение default.

Так как мы по крайней мере «притворяясь» , что мы возвращаемся к MongoDB 2.4 (что является ограничением для фактического запуска с $geoNear, тогда другие вещи, такие как $$ROOT, не будут доступны в этомверсии, поэтому вместо этого вы просто назовете все выражения полей документа, чтобы добавить это содержимое с $push.


Генерация кода

Всеэто действительно должно сводиться к тому, что «группировка» на самом деле выполняется с помощью $bucket и что это, вероятно, то, что вы использовали бы, если вы не хотите какую-то настройку вывода или если ваш MongoDBверсия не поддерживает его (хотя, вероятно, в настоящее время не следует запускать MongoDB под 3.4).

Конечно, любая другая форма длиннее в требуемом синтаксисе, но на самом деле это тот же массивАргументы могут быть применены для создания и запуска любой из показанных выше форм.

Ниже приведен пример списка (для 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
        }
      ]
    }
  ]
}
...