#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.
-
spherical — Mandatory, but really only when the index type is
2dsphere
. It defaults tofalse
, but you probably do want a2dsphere
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 thenear
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 likemaxDistance
orminDistance
. 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 adistanceMultiplier
value with something likekm
ormiles
.
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. Это отличный ответ! 😍