Модуль 3 · Форма данных и скорость

Справочник по MongoDB · модуль 3 · форма данных и скорость

3.5

3.5

География:
$near и $geoWithin

В этой главе разобраны запросы по координатам: ближайшие к покупателю пункты выдачи, пункты внутри круга или многоугольника и зона доставки, в которую попадает адрес. Точки и области записываются в формате GeoJSON, запросы к ним обслуживает индекс 2dsphere. Отдельно разобран порядок координат — долгота, затем широта — и что происходит, если его перепутать.

Категория
Форма данных
Уровень
Начальный
Связанные темы
Индексы (глава 3.2), вложенные документы и массивы (глава 2.1)
Зачем это знать
Находить ближайшие объекты и проверять, попадает ли точка в область, запросом к базе

Как запускать примеры этой главы

БазыГлава меняет песочницу. Перед началом верните базы в исходное состояние: docker compose run --rm reset или скрипт из главы 1.0, §6.
Архивmongodb-glava-3.5-python.zip, 61 КБmongodb-glava-3.5-ruby.zip, 61 КБmongodb-glava-3.5-go.zip, 65 КБmongodb-glava-3.5-cpp.zip, 64 КБ — все примеры главы готовыми программами, базы и стенд в Docker. В README.md — запуск в Docker, если Compass нет или он не подключается, и на установленном сервере с Compass, а также что должна напечатать каждая программа.
Файлlessons/ch3_5.py — пример вставляется в конец файлаlessons/ch3_5.rb — пример вставляется в конец файлаlessons/ch3_5.go — пример внутрь скобок в конце main, функции и типы — выше mainlessons/ch3_5.cpp — пример внутрь скобок в конце main, функции — выше main
Запускcd lessons, затем python ch3_5.pycd lessons, затем python3 ch3_5.py · в Docker: docker compose run --rm python lessons/ch3_5.pycd lessons, затем ruby ch3_5.rb · в Docker: docker compose run --rm ruby lessons/ch3_5.rbcd lessons, затем go run ch3_5.go · в Docker: docker compose run --rm go lessons/ch3_5.gocd lessons, затем g++ -std=c++17 ch3_5.cpp -o prog $(pkg-config --cflags --libs libmongocxx1) && ./prog · в Docker: docker compose run --rm cpp lessons/ch3_5.cpp
ПорядокКаждый пример вставляется вместо предыдущего и запускается один раз, по порядку, — так получены все выводы главы. Подробно — глава 1.0, §10.

§1Точка на карте: GeoJSON и индекс 2dsphere

Магазин открывает пункты выдачи и хочет показывать покупателю ближайшие. Положение пункта задаётся двумя числами: долготой — углом к востоку от нулевого меридиана, и широтой — углом к северу от экватора. Центр Ярославля — примерно 39,89° восточной долготы и 57,63° северной широты.

MongoDB хранит такие места в формате GeoJSON — стандартной записи географических объектов в JSON. Точка — вложенный документ из двух полей: type со значением "Point" и coordinates — массив из двух чисел, сначала долгота, затем широта. Порядок обратный привычному «широта, долгота» из картографических сервисов; что бывает при путанице, разобрано в §5.

Пункт выдачиpv-1
_id: "pv-1"
name: "Пункт 1, центр"
location: {
  type: "Point",
  coordinates: [39.8938, 57.6261] ← долгота, широта
}

Координат в базах стенда нет, поэтому пример сам создаёт в песочнице коллекцию pickup (sandbox.pickup) — шесть условных пунктов выдачи: пять в разных районах Ярославля и один в Москве. Названия пунктов придуманы, координаты взяты по центрам районов. Точка покупателя here — в центре Ярославля, она объявлена в заготовке главы.

Запросы по расстоянию обслуживает индекс 2dsphere: он считает Землю сферой и хранит точки так, чтобы сервер быстро находил ближайшие. У поля в ключе такого индекса вместо направления стоит "2dsphere". Пример сначала пробует найти ближайшие пункты без индекса, затем создаёт его.

Python · PyMongoточки на карте и индекс 2dsphere
from pymongo.errors import OperationFailure

points = [
    ("pv-1", "Пункт 1, центр", 39.8938, 57.6261),
    ("pv-2", "Пункт 2, вокзал", 39.8370, 57.6337),
    ("pv-3", "Пункт 3, Брагино", 39.8178, 57.6929),
    ("pv-4", "Пункт 4, Фрунзенский район", 39.9050, 57.5848),
    ("pv-5", "Пункт 5, Заволжье", 39.9500, 57.6650),
    ("pv-6", "Пункт 6, Москва", 37.6056, 55.7650),
]
pickup.drop()
pickup.insert_many({"_id": pid, "name": name, "location": {"type": "Point", "coordinates": [lng, lat]}}
                   for pid, name, lng, lat in points)
print("пунктов выдачи:", pickup.count_documents({}))

try:
    list(pickup.find({"location": {"$near": {"$geometry": here}}}))
except OperationFailure as e:
    print("$near без индекса: ошибка", e.code)

print("создан индекс:", pickup.create_index([("location", "2dsphere")]))
C++ · mongocxxточки на карте и индекс 2dsphere
struct point { std::string id, name; double lng, lat; };
std::vector<point> points{
    {"pv-1", "Пункт 1, центр", 39.8938, 57.6261},
    {"pv-2", "Пункт 2, вокзал", 39.8370, 57.6337},
    {"pv-3", "Пункт 3, Брагино", 39.8178, 57.6929},
    {"pv-4", "Пункт 4, Фрунзенский район", 39.9050, 57.5848},
    {"pv-5", "Пункт 5, Заволжье", 39.9500, 57.6650},
    {"pv-6", "Пункт 6, Москва", 37.6056, 55.7650}};
pickup.drop();
std::vector<bsoncxx::document::value> docs;
for (const auto& p : points) {
    docs.push_back(make_document(
        kvp("_id", p.id),
        kvp("name", p.name),
        kvp("location", make_document(kvp("type", "Point"), kvp("coordinates", make_array(p.lng, p.lat))))));
}
pickup.insert_many(docs);
std::cout << "пунктов выдачи: " << pickup.count_documents(make_document()) << std::endl;

try {
    for (auto&& doc : pickup.find(make_document(kvp("location", make_document(kvp("$near", make_document(kvp("$geometry", here.view())))))))) {
        (void)doc;
    }
} catch (const mongocxx::operation_exception& e) {
    std::cout << "$near без индекса: ошибка " << e.code().value() << std::endl;
}

auto created = pickup.create_index(make_document(kvp("location", "2dsphere")));
std::cout << "создан индекс: " << created.view()["name"].get_string().value << std::endl;
Go · mongo-driverточки на карте и индекс 2dsphere
points := []struct {
    id, name string
    lng, lat float64
}{
    {"pv-1", "Пункт 1, центр", 39.8938, 57.6261},
    {"pv-2", "Пункт 2, вокзал", 39.8370, 57.6337},
    {"pv-3", "Пункт 3, Брагино", 39.8178, 57.6929},
    {"pv-4", "Пункт 4, Фрунзенский район", 39.9050, 57.5848},
    {"pv-5", "Пункт 5, Заволжье", 39.9500, 57.6650},
    {"pv-6", "Пункт 6, Москва", 37.6056, 55.7650},
}
pickup.Drop(ctx)
docs := []any{}
for _, p := range points {
    docs = append(docs, bson.D{
        {Key: "_id", Value: p.id},
        {Key: "name", Value: p.name},
        {Key: "location", Value: bson.D{{Key: "type", Value: "Point"}, {Key: "coordinates", Value: bson.A{p.lng, p.lat}}}}})
}
pickup.InsertMany(ctx, docs)
n, _ := pickup.CountDocuments(ctx, bson.D{})
fmt.Println("пунктов выдачи:", n)

_, err := pickup.Find(ctx, bson.D{{Key: "location", Value: bson.D{{Key: "$near", Value: bson.D{{Key: "$geometry", Value: here}}}}}})
var ce mongo.CommandError
if errors.As(err, &ce) {
    fmt.Println("$near без индекса: ошибка", ce.Code)
}

name, _ := pickup.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "location", Value: "2dsphere"}}})
fmt.Println("создан индекс:", name)
Ruby · mongoточки на карте и индекс 2dsphere
points = [
  ["pv-1", "Пункт 1, центр", 39.8938, 57.6261],
  ["pv-2", "Пункт 2, вокзал", 39.8370, 57.6337],
  ["pv-3", "Пункт 3, Брагино", 39.8178, 57.6929],
  ["pv-4", "Пункт 4, Фрунзенский район", 39.9050, 57.5848],
  ["pv-5", "Пункт 5, Заволжье", 39.9500, 57.6650],
  ["pv-6", "Пункт 6, Москва", 37.6056, 55.7650],
]
pickup.drop
pickup.insert_many(points.map do |pid, name, lng, lat|
  { "_id" => pid, "name" => name, "location" => { "type" => "Point", "coordinates" => [lng, lat] } }
end)
puts "пунктов выдачи: " + pickup.count_documents({}).to_s

begin
  pickup.find({ "location" => { "$near" => { "$geometry" => here } } }).to_a
rescue Mongo::Error::OperationFailure => e
  puts "$near без индекса: ошибка #{e.code}"
end

pickup.indexes.create_one({ "location" => "2dsphere" })
puts "создан индекс: " + pickup.indexes.get({ "location" => "2dsphere" })["name"]
Вывод в терминалрезультат запуска
пунктов выдачи: 6
$near без индекса: ошибка 291
создан индекс: location_2dsphere
Вывод в терминалрезультат запуска
пунктов выдачи: 6
$near без индекса: ошибка 291
создан индекс: location_2dsphere
Вывод в терминалрезультат запуска
пунктов выдачи: 6
$near без индекса: ошибка 291
создан индекс: location_2dsphere
Вывод в терминалрезультат запуска
пунктов выдачи: 6
$near без индекса: ошибка 291
создан индекс: location_2dsphere

Шесть пунктов записаны. Поиск ближайших без индекса завершился ошибкой с кодом 291 — NoQueryExecutionPlans: у сервера нет способа выполнить такой запрос. После создания индекса location_2dsphere он выполняется (§2).

Compass, коллекция sandbox.pickup: шесть документов, у первых двух раскрыто поле location с type Point и coordinates
Compass · sandbox.pickup, вкладка Documents

У раскрытых документов поле location — вложенный документ с type и coordinates; в массиве первым идёт 39,8938 — долгота, вторым 57,6261 — широта. Вкладка Indexes подписана числом 2: _id_ и location_2dsphere.

§2Ближайшие точки: $near

Оператор $near отбирает документы, точка которых лежит рядом с заданной, и возвращает их по расстоянию: ближайшие первыми. Точка задаётся в параметре $geometry в том же формате GeoJSON. Необязательный параметр $maxDistance ограничивает расстояние в метрах; без него возвращаются все документы коллекции, упорядоченные по расстоянию.

Пример ищет пункты в пределах 3,5 км от покупателя, затем выводит все пункты по расстоянию.

Python · PyMongoближайшие пункты: $near
near = {"location": {"$near": {"$geometry": here, "$maxDistance": 3500}}}
for doc in pickup.find(near):
    print("до 3,5 км:", doc["name"])

print("все по расстоянию:")
for doc in pickup.find({"location": {"$near": {"$geometry": here}}}):
    print("  ", doc["name"])
C++ · mongocxxближайшие пункты: $near
auto near = make_document(kvp("location", make_document(kvp("$near", make_document(
    kvp("$geometry", here.view()), kvp("$maxDistance", 3500))))));
for (auto&& doc : pickup.find(near.view())) {
    std::cout << "до 3,5 км: " << doc["name"].get_string().value << std::endl;
}

std::cout << "все по расстоянию:" << std::endl;
for (auto&& doc : pickup.find(make_document(kvp("location", make_document(kvp("$near", make_document(kvp("$geometry", here.view())))))))) {
    std::cout << "   " << doc["name"].get_string().value << std::endl;
}
Go · mongo-driverближайшие пункты: $near
near := bson.D{{Key: "location", Value: bson.D{{Key: "$near", Value: bson.D{
    {Key: "$geometry", Value: here}, {Key: "$maxDistance", Value: 3500}}}}}}
cursor, _ := pickup.Find(ctx, near)
for cursor.Next(ctx) {
    fmt.Println("до 3,5 км:", cursor.Current.Lookup("name").StringValue())
}

fmt.Println("все по расстоянию:")
all, _ := pickup.Find(ctx, bson.D{{Key: "location", Value: bson.D{{Key: "$near", Value: bson.D{{Key: "$geometry", Value: here}}}}}})
for all.Next(ctx) {
    fmt.Println("  ", all.Current.Lookup("name").StringValue())
}
Ruby · mongoближайшие пункты: $near
near = { "location" => { "$near" => { "$geometry" => here, "$maxDistance" => 3500 } } }
pickup.find(near).each { |doc| puts "до 3,5 км: " + doc["name"] }

puts "все по расстоянию:"
pickup.find({ "location" => { "$near" => { "$geometry" => here } } }).each { |doc| puts "   " + doc["name"] }
Вывод в терминалрезультат запуска
до 3,5 км: Пункт 1, центр
до 3,5 км: Пункт 2, вокзал
все по расстоянию:
   Пункт 1, центр
   Пункт 2, вокзал
   Пункт 4, Фрунзенский район
   Пункт 5, Заволжье
   Пункт 3, Брагино
   Пункт 6, Москва
Вывод в терминалрезультат запуска
до 3,5 км: Пункт 1, центр
до 3,5 км: Пункт 2, вокзал
все по расстоянию:
   Пункт 1, центр
   Пункт 2, вокзал
   Пункт 4, Фрунзенский район
   Пункт 5, Заволжье
   Пункт 3, Брагино
   Пункт 6, Москва
Вывод в терминалрезультат запуска
до 3,5 км: Пункт 1, центр
до 3,5 км: Пункт 2, вокзал
все по расстоянию:
   Пункт 1, центр
   Пункт 2, вокзал
   Пункт 4, Фрунзенский район
   Пункт 5, Заволжье
   Пункт 3, Брагино
   Пункт 6, Москва
Вывод в терминалрезультат запуска
до 3,5 км: Пункт 1, центр
до 3,5 км: Пункт 2, вокзал
все по расстоянию:
   Пункт 1, центр
   Пункт 2, вокзал
   Пункт 4, Фрунзенский район
   Пункт 5, Заволжье
   Пункт 3, Брагино
   Пункт 6, Москва

В пределах 3,5 км два пункта: в центре и у вокзала. Все шесть идут от ближнего к дальнему, московский пункт — последним. Сортировку $near выполняет сам: в запросе её нет.

Расстояния $near не возвращает. Их выдаёт стадия $geoNear конвейера агрегации (модуль 4); здесь она приведена в mongosh, чтобы проверить порядок числами:

mongosh · оболочкарасстояния до пунктов в метрах
db.pickup.aggregate([
  { $geoNear: { near: { type: "Point", coordinates: [39.8875, 57.6300] }, distanceField: "metres" } },
  { $project: { _id: 0, name: 1, metres: { $round: ["$metres", 0] } } }
])
Вывод mongoshшесть пунктов
[
  { name: 'Пункт 1, центр', metres: 574 },
  { name: 'Пункт 2, вокзал', metres: 3038 },
  { name: 'Пункт 4, Фрунзенский район', metres: 5139 },
  { name: 'Пункт 5, Заволжье', metres: 5389 },
  { name: 'Пункт 3, Брагино', metres: 8140 },
  { name: 'Пункт 6, Москва', metres: 250077 }
]

До пункта в центре 574 м, до вокзального — 3038 м: оба меньше 3500. Следующий пункт — в 5139 м. До Москвы — 250 км.

Compass, коллекция sandbox.pickup: фильтр $near с maxDistance 3500, найдены pv-1 и pv-2, счётчик 1 – 2 of N/A
Compass · { location: { $near: { $geometry: …, $maxDistance: 3500 } } }

Найдены те же два пункта, в том же порядке. Вместо общего числа документов счётчик показывает N/A: Compass считает документы отдельным запросом, а подсчёт с $near сервер не выполняет — оператору нужна сортировка по расстоянию, которой при подсчёте нет. Пункты в радиусе считают через $geoWithin (§3).

§3Точки внутри области: $geoWithin

Оператор $geoWithin отбирает документы, точка которых лежит внутри области. Порядка по расстоянию у него нет, и индекс для него не обязателен, хотя ускоряет поиск. Область задаётся одним из двух способов:

  • $centerSphere — круг на поверхности Земли: центр [долгота, широта] и радиус в радианах. Чтобы перевести километры в радианы, их делят на радиус Земли, 6378,1 км: 5 км — это 5 / 6378.1.
  • $geometry с многоугольником GeoJSON: {"type": "Polygon", "coordinates": [[…]]}. Внешний массив — список контуров, внутри — точки контура. Контур должен быть замкнут: последняя точка повторяет первую.

Пример ищет пункты в круге радиусом 5 км вокруг покупателя и в прямоугольнике, который накрывает центр и север города. Результаты отсортированы по _id.

Python · PyMongoточки внутри области: $geoWithin
circle = {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
print("в круге 5 км:    ", [doc["_id"] for doc in pickup.find(circle).sort("_id", 1)])

square = {"type": "Polygon",
          "coordinates": [[[39.80, 57.60], [39.92, 57.60], [39.92, 57.70], [39.80, 57.70], [39.80, 57.60]]]}
inside = {"location": {"$geoWithin": {"$geometry": square}}}
print("в прямоугольнике:", [doc["_id"] for doc in pickup.find(inside).sort("_id", 1)])
C++ · mongocxxточки внутри области: $geoWithin
mongocxx::options::find by_id;
by_id.sort(make_document(kvp("_id", 1)));
auto ids = [&](bsoncxx::document::view filtr) {
    std::string found;
    for (auto&& doc : pickup.find(filtr, by_id)) {
        found += (found.empty() ? "" : " ") + std::string{doc["_id"].get_string().value};
    }
    return found;
};

auto circle = make_document(kvp("location", make_document(kvp("$geoWithin", make_document(
    kvp("$centerSphere", make_array(make_array(39.8875, 57.6300), 5 / 6378.1)))))));
std::cout << "в круге 5 км:     " << ids(circle.view()) << std::endl;

auto square = make_document(kvp("type", "Polygon"), kvp("coordinates", make_array(make_array(
    make_array(39.80, 57.60), make_array(39.92, 57.60), make_array(39.92, 57.70), make_array(39.80, 57.70), make_array(39.80, 57.60)))));
auto inside = make_document(kvp("location", make_document(kvp("$geoWithin", make_document(kvp("$geometry", square.view()))))));
std::cout << "в прямоугольнике: " << ids(inside.view()) << std::endl;
Go · mongo-driverточки внутри области: $geoWithin
ids := func(filtr bson.D) []string {
    cursor, _ := pickup.Find(ctx, filtr, options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}))
    found := []string{}
    for cursor.Next(ctx) {
        found = append(found, cursor.Current.Lookup("_id").StringValue())
    }
    return found
}

circle := bson.D{{Key: "location", Value: bson.D{{Key: "$geoWithin", Value: bson.D{
    {Key: "$centerSphere", Value: bson.A{bson.A{39.8875, 57.6300}, 5 / 6378.1}}}}}}}
fmt.Println("в круге 5 км:    ", ids(circle))

square := bson.D{{Key: "type", Value: "Polygon"}, {Key: "coordinates", Value: bson.A{bson.A{
    bson.A{39.80, 57.60}, bson.A{39.92, 57.60}, bson.A{39.92, 57.70}, bson.A{39.80, 57.70}, bson.A{39.80, 57.60}}}}}
inside := bson.D{{Key: "location", Value: bson.D{{Key: "$geoWithin", Value: bson.D{{Key: "$geometry", Value: square}}}}}}
fmt.Println("в прямоугольнике:", ids(inside))
Ruby · mongoточки внутри области: $geoWithin
circle = { "location" => { "$geoWithin" => { "$centerSphere" => [[39.8875, 57.6300], 5 / 6378.1] } } }
puts "в круге 5 км:     " + pickup.find(circle).sort({ "_id" => 1 }).map { |doc| doc["_id"] }.to_s

square = { "type" => "Polygon",
           "coordinates" => [[[39.80, 57.60], [39.92, 57.60], [39.92, 57.70], [39.80, 57.70], [39.80, 57.60]]] }
inside = { "location" => { "$geoWithin" => { "$geometry" => square } } }
puts "в прямоугольнике: " + pickup.find(inside).sort({ "_id" => 1 }).map { |doc| doc["_id"] }.to_s
Вывод в терминалрезультат запуска
в круге 5 км:     ['pv-1', 'pv-2']
в прямоугольнике: ['pv-1', 'pv-2', 'pv-3']
Вывод в терминалрезультат запуска
в круге 5 км:     pv-1 pv-2
в прямоугольнике: pv-1 pv-2 pv-3
Вывод в терминалрезультат запуска
в круге 5 км:     [pv-1 pv-2]
в прямоугольнике: [pv-1 pv-2 pv-3]
Вывод в терминалрезультат запуска
в круге 5 км:     ["pv-1", "pv-2"]
в прямоугольнике: ["pv-1", "pv-2", "pv-3"]

В круге 5 км — два пункта, те же, что ближе 3,5 км в §2: следующий пункт лежит в 5139 м, чуть дальше границы. В прямоугольник попали центр, вокзал и Брагино на севере; Фрунзенский район южнее нижней границы 57,60, Заволжье восточнее правой границы 39,92.

Compass, вкладка Schema коллекции sandbox.pickup: поле location с типом coordinates показано на карте, пять точек в Ярославле и одна в Москве
Compass · вкладка Schema, поле location на карте

Вкладка Schema после кнопки Analyze показывает поля коллекции; поле с точками GeoJSON Compass опознаёт как coordinates и рисует на карте. Пять точек собрались у Ярославля вверху справа, одна — в Москве внизу. Кнопки справа на карте рисуют круг или многоугольник, и Compass переносит их в фильтр как $geoWithin.

§4В какую зону попадает точка: $geoIntersects

Обратная задача: в базе хранятся области, а нужно найти ту, в которую попадает точка. Так определяют зону доставки по адресу покупателя. Оператор $geoIntersects отбирает документы, область которых пересекается с заданной геометрией; если задана точка, это область, внутри которой точка лежит.

Пример создаёт коллекцию zones с двумя зонами доставки — «центр» (доставка за 1 день) и «север» (за 2 дня), строит индекс 2dsphere по полю area и ищет зону для трёх точек: покупателя, района Брагино и Москвы.

Python · PyMongoв какую зону попадает точка: $geoIntersects
zones.drop()
zones.insert_many([
    {"_id": "z-centr", "name": "Зона центр", "days": 1,
     "area": {"type": "Polygon", "coordinates": [[[39.85, 57.61], [39.93, 57.61], [39.93, 57.65], [39.85, 57.65], [39.85, 57.61]]]}},
    {"_id": "z-sever", "name": "Зона север", "days": 2,
     "area": {"type": "Polygon", "coordinates": [[[39.78, 57.65], [39.98, 57.65], [39.98, 57.72], [39.78, 57.72], [39.78, 57.65]]]}},
])
zones.create_index([("area", "2dsphere")])

for label, point in [("покупатель", here),
                     ("Брагино", {"type": "Point", "coordinates": [39.8178, 57.6929]}),
                     ("Москва", {"type": "Point", "coordinates": [37.6056, 55.7650]})]:
    zone = zones.find_one({"area": {"$geoIntersects": {"$geometry": point}}})
    print(label + ":", f"{zone['name']}, доставка {zone['days']} дн." if zone else "вне зон доставки")
C++ · mongocxxв какую зону попадает точка: $geoIntersects
zones.drop();
std::vector<bsoncxx::document::value> areas;
areas.push_back(make_document(kvp("_id", "z-centr"), kvp("name", "Зона центр"), kvp("days", 1),
    kvp("area", make_document(kvp("type", "Polygon"), kvp("coordinates", make_array(make_array(make_array(39.85, 57.61), make_array(39.93, 57.61), make_array(39.93, 57.65), make_array(39.85, 57.65), make_array(39.85, 57.61))))))));
areas.push_back(make_document(kvp("_id", "z-sever"), kvp("name", "Зона север"), kvp("days", 2),
    kvp("area", make_document(kvp("type", "Polygon"), kvp("coordinates", make_array(make_array(make_array(39.78, 57.65), make_array(39.98, 57.65), make_array(39.98, 57.72), make_array(39.78, 57.72), make_array(39.78, 57.65))))))));
zones.insert_many(areas);
zones.create_index(make_document(kvp("area", "2dsphere")));

auto point = [](double lng, double lat) {
    return make_document(kvp("type", "Point"), kvp("coordinates", make_array(lng, lat)));
};
std::vector<std::pair<std::string, bsoncxx::document::value>> places;
places.emplace_back("покупатель", here);
places.emplace_back("Брагино", point(39.8178, 57.6929));
places.emplace_back("Москва", point(37.6056, 55.7650));
for (const auto& [label, where] : places) {
    auto zone = zones.find_one(make_document(kvp("area", make_document(kvp("$geoIntersects", make_document(kvp("$geometry", where.view())))))));
    if (zone) {
        std::cout << label << ": " << zone->view()["name"].get_string().value
                  << ", доставка " << zone->view()["days"].get_int32().value << " дн." << std::endl;
    } else {
        std::cout << label << ": вне зон доставки" << std::endl;
    }
}
Go · mongo-driverв какую зону попадает точка: $geoIntersects
zones.Drop(ctx)
zones.InsertMany(ctx, []any{
    bson.D{{Key: "_id", Value: "z-centr"}, {Key: "name", Value: "Зона центр"}, {Key: "days", Value: 1},
        {Key: "area", Value: bson.D{{Key: "type", Value: "Polygon"}, {Key: "coordinates", Value: bson.A{bson.A{bson.A{39.85, 57.61}, bson.A{39.93, 57.61}, bson.A{39.93, 57.65}, bson.A{39.85, 57.65}, bson.A{39.85, 57.61}}}}}}},
    bson.D{{Key: "_id", Value: "z-sever"}, {Key: "name", Value: "Зона север"}, {Key: "days", Value: 2},
        {Key: "area", Value: bson.D{{Key: "type", Value: "Polygon"}, {Key: "coordinates", Value: bson.A{bson.A{bson.A{39.78, 57.65}, bson.A{39.98, 57.65}, bson.A{39.98, 57.72}, bson.A{39.78, 57.72}, bson.A{39.78, 57.65}}}}}}}})
zones.Indexes().CreateOne(ctx, mongo.IndexModel{Keys: bson.D{{Key: "area", Value: "2dsphere"}}})

point := func(lng, lat float64) bson.D {
    return bson.D{{Key: "type", Value: "Point"}, {Key: "coordinates", Value: bson.A{lng, lat}}}
}
for _, place := range []struct {
    label string
    where bson.D
}{{"покупатель", here}, {"Брагино", point(39.8178, 57.6929)}, {"Москва", point(37.6056, 55.7650)}} {
    var zone bson.M
    err := zones.FindOne(ctx, bson.D{{Key: "area", Value: bson.D{{Key: "$geoIntersects", Value: bson.D{{Key: "$geometry", Value: place.where}}}}}}).Decode(&zone)
    if err != nil {
        fmt.Println(place.label + ": вне зон доставки")
    } else {
        fmt.Printf("%s: %s, доставка %d дн.\n", place.label, zone["name"], zone["days"])
    }
}
Ruby · mongoв какую зону попадает точка: $geoIntersects
zones.drop
zones.insert_many([
  { "_id" => "z-centr", "name" => "Зона центр", "days" => 1,
    "area" => { "type" => "Polygon", "coordinates" => [[[39.85, 57.61], [39.93, 57.61], [39.93, 57.65], [39.85, 57.65], [39.85, 57.61]]] } },
  { "_id" => "z-sever", "name" => "Зона север", "days" => 2,
    "area" => { "type" => "Polygon", "coordinates" => [[[39.78, 57.65], [39.98, 57.65], [39.98, 57.72], [39.78, 57.72], [39.78, 57.65]]] } },
])
zones.indexes.create_one({ "area" => "2dsphere" })

[["покупатель", here],
 ["Брагино", { "type" => "Point", "coordinates" => [39.8178, 57.6929] }],
 ["Москва", { "type" => "Point", "coordinates" => [37.6056, 55.7650] }]].each do |label, point|
  zone = zones.find({ "area" => { "$geoIntersects" => { "$geometry" => point } } }).first
  puts label + ": " + (zone ? "#{zone["name"]}, доставка #{zone["days"]} дн." : "вне зон доставки")
end
Вывод в терминалрезультат запуска
покупатель: Зона центр, доставка 1 дн.
Брагино: Зона север, доставка 2 дн.
Москва: вне зон доставки
Вывод в терминалрезультат запуска
покупатель: Зона центр, доставка 1 дн.
Брагино: Зона север, доставка 2 дн.
Москва: вне зон доставки
Вывод в терминалрезультат запуска
покупатель: Зона центр, доставка 1 дн.
Брагино: Зона север, доставка 2 дн.
Москва: вне зон доставки
Вывод в терминалрезультат запуска
покупатель: Зона центр, доставка 1 дн.
Брагино: Зона север, доставка 2 дн.
Москва: вне зон доставки

Покупатель в центре попал в центральную зону, точка в Брагино — в северную, Москва — ни в одну: зон, в которые она попадает, нет, и поиск не нашёл ни одного документа.

Если документ не найден, FindOne(…).Decode возвращает ошибку mongo.ErrNoDocuments; пример считает любую ошибку признаком того, что зоны нет.

§5Долгота и широта

Картографические сервисы показывают координаты в порядке «широта, долгота»: центр Ярославля — «57.6261, 39.8938». Если перенести эти числа в GeoJSON как есть, сервер прочитает 57,6261 как долготу, а 39,8938 — как широту, и точка окажется в другом месте. Ошибки при этом нет: оба числа — допустимые координаты. Широта не бывает больше 90° по модулю, поэтому ошибку видно только тогда, когда долгота места больше 90°, как у Владивостока: переставленные числа дают широту 131,8855.

Пример добавляет пункт 7 с переставленными координатами центра Ярославля, выводит все пункты по расстоянию и пункты в круге 5 км, затем пробует добавить пункт во Владивостоке с переставленными координатами.

Python · PyMongoпорядок: долгота, потом широта
from pymongo.errors import WriteError

pickup.insert_one({"_id": "pv-7", "name": "Пункт 7, координаты перепутаны",
                   "location": {"type": "Point", "coordinates": [57.6261, 39.8938]}})
print("по расстоянию:", [doc["_id"] for doc in pickup.find({"location": {"$near": {"$geometry": here}}})])
circle = {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
print("в круге 5 км: ", [doc["_id"] for doc in pickup.find(circle).sort("_id", 1)])

try:
    pickup.insert_one({"_id": "pv-8", "name": "Пункт 8, Владивосток, координаты перепутаны",
                       "location": {"type": "Point", "coordinates": [43.1155, 131.8855]}})
except WriteError as e:
    print("широта 131.8855 отклонена, код", e.code)
C++ · mongocxxпорядок: долгота, потом широта
pickup.insert_one(make_document(kvp("_id", "pv-7"), kvp("name", "Пункт 7, координаты перепутаны"),
    kvp("location", make_document(kvp("type", "Point"), kvp("coordinates", make_array(57.6261, 39.8938))))));

auto ids = [](mongocxx::cursor cursor) {
    std::string found;
    for (auto&& doc : cursor) found += (found.empty() ? "" : " ") + std::string{doc["_id"].get_string().value};
    return found;
};
std::cout << "по расстоянию: "
          << ids(pickup.find(make_document(kvp("location", make_document(kvp("$near", make_document(kvp("$geometry", here.view())))))))) << std::endl;
mongocxx::options::find by_id;
by_id.sort(make_document(kvp("_id", 1)));
auto circle = make_document(kvp("location", make_document(kvp("$geoWithin", make_document(
    kvp("$centerSphere", make_array(make_array(39.8875, 57.6300), 5 / 6378.1)))))));
std::cout << "в круге 5 км:  " << ids(pickup.find(circle.view(), by_id)) << std::endl;

try {
    pickup.insert_one(make_document(kvp("_id", "pv-8"), kvp("name", "Пункт 8, Владивосток, координаты перепутаны"),
        kvp("location", make_document(kvp("type", "Point"), kvp("coordinates", make_array(43.1155, 131.8855))))));
} catch (const mongocxx::operation_exception& e) {
    std::cout << "широта 131.8855 отклонена, код " << e.code().value() << std::endl;
}
Go · mongo-driverпорядок: долгота, потом широта
pickup.InsertOne(ctx, bson.D{{Key: "_id", Value: "pv-7"}, {Key: "name", Value: "Пункт 7, координаты перепутаны"},
    {Key: "location", Value: bson.D{{Key: "type", Value: "Point"}, {Key: "coordinates", Value: bson.A{57.6261, 39.8938}}}}})

ids := func(cursor *mongo.Cursor) []string {
    found := []string{}
    for cursor.Next(ctx) {
        found = append(found, cursor.Current.Lookup("_id").StringValue())
    }
    return found
}
byDistance, _ := pickup.Find(ctx, bson.D{{Key: "location", Value: bson.D{{Key: "$near", Value: bson.D{{Key: "$geometry", Value: here}}}}}})
fmt.Println("по расстоянию:", ids(byDistance))
circle := bson.D{{Key: "location", Value: bson.D{{Key: "$geoWithin", Value: bson.D{
    {Key: "$centerSphere", Value: bson.A{bson.A{39.8875, 57.6300}, 5 / 6378.1}}}}}}}
inCircle, _ := pickup.Find(ctx, circle, options.Find().SetSort(bson.D{{Key: "_id", Value: 1}}))
fmt.Println("в круге 5 км: ", ids(inCircle))

_, err := pickup.InsertOne(ctx, bson.D{{Key: "_id", Value: "pv-8"}, {Key: "name", Value: "Пункт 8, Владивосток, координаты перепутаны"},
    {Key: "location", Value: bson.D{{Key: "type", Value: "Point"}, {Key: "coordinates", Value: bson.A{43.1155, 131.8855}}}}})
var we mongo.WriteException
if errors.As(err, &we) {
    fmt.Println("широта 131.8855 отклонена, код", we.WriteErrors[0].Code)
}
Ruby · mongoпорядок: долгота, потом широта
pickup.insert_one({ "_id" => "pv-7", "name" => "Пункт 7, координаты перепутаны",
                    "location" => { "type" => "Point", "coordinates" => [57.6261, 39.8938] } })
puts "по расстоянию: " + pickup.find({ "location" => { "$near" => { "$geometry" => here } } }).map { |doc| doc["_id"] }.to_s
circle = { "location" => { "$geoWithin" => { "$centerSphere" => [[39.8875, 57.6300], 5 / 6378.1] } } }
puts "в круге 5 км:  " + pickup.find(circle).sort({ "_id" => 1 }).map { |doc| doc["_id"] }.to_s

begin
  pickup.insert_one({ "_id" => "pv-8", "name" => "Пункт 8, Владивосток, координаты перепутаны",
                      "location" => { "type" => "Point", "coordinates" => [43.1155, 131.8855] } })
rescue Mongo::Error::OperationFailure => e
  puts "широта 131.8855 отклонена, код #{e.code}"
end
Вывод в терминалрезультат запуска
по расстоянию: ['pv-1', 'pv-2', 'pv-4', 'pv-5', 'pv-3', 'pv-6', 'pv-7']
в круге 5 км:  ['pv-1', 'pv-2']
широта 131.8855 отклонена, код 16755
Вывод в терминалрезультат запуска
по расстоянию: pv-1 pv-2 pv-4 pv-5 pv-3 pv-6 pv-7
в круге 5 км:  pv-1 pv-2
широта 131.8855 отклонена, код 16755
Вывод в терминалрезультат запуска
по расстоянию: [pv-1 pv-2 pv-4 pv-5 pv-3 pv-6 pv-7]
в круге 5 км:  [pv-1 pv-2]
широта 131.8855 отклонена, код 16755
Вывод в терминалрезультат запуска
по расстоянию: ["pv-1", "pv-2", "pv-4", "pv-5", "pv-3", "pv-6", "pv-7"]
в круге 5 км:  ["pv-1", "pv-2"]
широта 131.8855 отклонена, код 16755

Пункт 7 записан, но по расстоянию он последний — дальше Москвы — и в круг 5 км не попал. По стадии $geoNear в mongosh до него 2349 км: точка с долготой 57,63 и широтой 39,89 лежит в Средней Азии. Пункт во Владивостоке не записан: индекс 2dsphere не принимает широту 131,8855, ошибка 16755 — не удалось извлечь геоключи. Без индекса такой документ записался бы, поэтому координаты проверяют до записи — например, правилом $jsonSchema из главы 3.1 с minimum и maximum.

Compass, вкладка Schema после добавления пункта 7 с перепутанными координатами: точка на карте в Туркмении, у Ашхабада
Compass · вкладка Schema после добавления пункта 7

Карта отдалилась, чтобы вместить все точки. Пункты у Ярославля и Москвы — вверху, пункт 7 — внизу справа, в Туркмении, рядом с Ашхабадом: туда указывают долгота 57,63 и широта 39,89. На карте такую ошибку видно сразу, в документе — нет.

§6География в Compass

Кадры в разделах выше сняты на коллекции pickup в тех же состояниях, что и после примеров. Геозапросы пишутся в поле фильтра вкладки Documents так же, как в коде; при $near счётчик документов показывает N/A (§2).

Вкладка Schema показывает поля с точками GeoJSON на карте (кадры в §2 и §5). Так проверяют координаты после загрузки данных: точка, попавшая не в тот город или на другой континент, видна на карте сразу. Индекс 2dsphere создаётся в окне Create Index (глава 3.2, §9) — в списке типа поля выбирается 2dsphere.

§7Частые ошибки

Что написаноЧто происходитКак правильно
"coordinates": [57.6261, 39.8938] — широта первойТочка записана, но в другом месте: на 2349 км от Ярославля; ошибки нет[долгота, широта]: [39.8938, 57.6261]
$near без индекса 2dsphereОшибка 291 NoQueryExecutionPlansИндекс {"location": "2dsphere"}
$centerSphere с радиусом 5Радиус 5 радиан больше половины окружности Земли (π радиан): в круг попадают все пунктыКилометры делить на радиус Земли: 5 / 6378.1
$maxDistance: 3.5 в расчёте на километрыИщется в пределах 3,5 метра: для GeoJSON расстояние в метрах$maxDistance: 3500
Многоугольник, у которого последняя точка не равна первойОшибка 2: Loop is not closed — первая и последняя вершины не совпадаютПовторить первую точку в конце контура
count_documents с $nearОшибка 5626500: $near нельзя использовать там, где нет сортировки по расстояниюДля подсчёта — $geoWithin с $centerSphere
Расчёт на порядок по расстоянию у $geoWithinДокументы идут в порядке индекса или коллекции$near, если нужен порядок от ближнего к дальнему
Что написаноЧто происходитКак правильно
"coordinates": [57.6261, 39.8938] — широта первойТочка записана, но в другом месте: на 2349 км от Ярославля; ошибки нет[долгота, широта]: [39.8938, 57.6261]
$near без индекса 2dsphereОшибка 291 NoQueryExecutionPlansИндекс {"location": "2dsphere"}
$centerSphere с радиусом 5Радиус 5 радиан больше половины окружности Земли (π радиан): в круг попадают все пунктыКилометры делить на радиус Земли: 5 / 6378.1
$maxDistance: 3.5 в расчёте на километрыИщется в пределах 3,5 метра: для GeoJSON расстояние в метрах$maxDistance: 3500
Многоугольник, у которого последняя точка не равна первойОшибка 2: Loop is not closed — первая и последняя вершины не совпадаютПовторить первую точку в конце контура
count_documents с $nearОшибка 5626500: $near нельзя использовать там, где нет сортировки по расстояниюДля подсчёта — $geoWithin с $centerSphere
Расчёт на порядок по расстоянию у $geoWithinДокументы идут в порядке индекса или коллекции$near, если нужен порядок от ближнего к дальнему
Что написаноЧто происходитКак правильно
"coordinates": [57.6261, 39.8938] — широта первойТочка записана, но в другом месте: на 2349 км от Ярославля; ошибки нет[долгота, широта]: [39.8938, 57.6261]
$near без индекса 2dsphereОшибка 291 NoQueryExecutionPlansИндекс {"location": "2dsphere"}
$centerSphere с радиусом 5Радиус 5 радиан больше половины окружности Земли (π радиан): в круг попадают все пунктыКилометры делить на радиус Земли: 5 / 6378.1
$maxDistance: 3.5 в расчёте на километрыИщется в пределах 3,5 метра: для GeoJSON расстояние в метрах$maxDistance: 3500
Многоугольник, у которого последняя точка не равна первойОшибка 2: Loop is not closed — первая и последняя вершины не совпадаютПовторить первую точку в конце контура
count_documents с $nearОшибка 5626500: $near нельзя использовать там, где нет сортировки по расстояниюДля подсчёта — $geoWithin с $centerSphere
Расчёт на порядок по расстоянию у $geoWithinДокументы идут в порядке индекса или коллекции$near, если нужен порядок от ближнего к дальнему
Что написаноЧто происходитКак правильно
"coordinates": [57.6261, 39.8938] — широта первойТочка записана, но в другом месте: на 2349 км от Ярославля; ошибки нет[долгота, широта]: [39.8938, 57.6261]
$near без индекса 2dsphereОшибка 291 NoQueryExecutionPlansИндекс {"location": "2dsphere"}
$centerSphere с радиусом 5Радиус 5 радиан больше половины окружности Земли (π радиан): в круг попадают все пунктыКилометры делить на радиус Земли: 5 / 6378.1
$maxDistance: 3.5 в расчёте на километрыИщется в пределах 3,5 метра: для GeoJSON расстояние в метрах$maxDistance: 3500
Многоугольник, у которого последняя точка не равна первойОшибка 2: Loop is not closed — первая и последняя вершины не совпадаютПовторить первую точку в конце контура
count_documents с $nearОшибка 5626500: $near нельзя использовать там, где нет сортировки по расстояниюДля подсчёта — $geoWithin с $centerSphere
Расчёт на порядок по расстоянию у $geoWithinДокументы идут в порядке индекса или коллекции$near, если нужен порядок от ближнего к дальнему

§8Проверьте себя

В каком порядке записываются числа в coordinates точки GeoJSON?

Сначала долгота, затем широта: [39.8938, 57.6261] для центра Ярославля. Картографические сервисы обычно показывают обратный порядок.

Чем $near отличается от $geoWithin с $centerSphere?

$near возвращает документы по расстоянию, ближайшие первыми, и требует индекса 2dsphere; ограничение задаётся в метрах параметром $maxDistance. $geoWithin только отбирает точки внутри круга, порядка по расстоянию у него нет; радиус задаётся в радианах.

Как записать радиус 10 км для $centerSphere?

Разделить на радиус Земли в километрах: 10 / 6378.1, это около 0,00157 радиана.

Какой оператор нужен, чтобы по адресу покупателя найти его зону доставки?

$geoIntersects с точкой покупателя в $geometry: он находит документы зон, многоугольник которых содержит эту точку.

Почему точку с переставленными координатами Ярославля сервер принял, а Владивостока — нет?

У Ярославля оба числа меньше 90, и после перестановки широта 39,89 допустима — точка просто оказывается в другом месте. У Владивостока долгота 131,89; после перестановки она становится широтой больше 90, и индекс 2dsphere отклоняет документ с ошибкой 16755.

§9Самостоятельная работа

Работа выполняется на языке, выбранном в шапке главы, в заготовке lessons/ch3_5, на коллекциях pickup и zones после примеров §1–§4. Новый покупатель живёт на севере города, его координаты — 39,8600° восточной долготы и 57,6800° северной широты.

  1. Записать точкуСоставить документ GeoJSON для точки покупателя, соблюдая порядок координат.
  2. Два ближайших пунктаНайти два ближайших к покупателю пункта выдачи: $near и предел 2.
  3. Пункты рядомНайти пункты в круге радиусом 3 км вокруг покупателя.
  4. Зона доставкиОпределить зону покупателя и срок доставки.
  5. Пункты в зонеНайти пункты выдачи внутри северной зоны: взять её многоугольник из документа z-sever и передать в $geoWithin.
Ответ для самопроверки

Точка — {"type": "Point", "coordinates": [39.8600, 57.6800]}. Два ближайших пункта — pv-3 (Брагино, около 2,9 км) и pv-2 (вокзал, около 5,3 км). В круге 3 км — только pv-3. Зона — «Зона север», доставка 2 дня. В северной зоне два пункта: pv-3 и pv-5 (Заволжье). Если пункт 7 из §5 остался в коллекции, на ответы он не влияет: он далеко.

§10Шпаргалка

Точка GeoJSON {"type": "Point", "coordinates": [39.8938, 57.6261]} долгота, затем широта
Индекс coll.create_index([("location", "2dsphere")])
Ближайшие {"location": {"$near": {"$geometry": here, "$maxDistance": 3500}}}
Внутри круга {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
Внутри многоугольника {"location": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": [[...]]}}}}
Зона для точки {"area": {"$geoIntersects": {"$geometry": here}}}
Точка GeoJSON {"type": "Point", "coordinates": [39.8938, 57.6261]} долгота, затем широта
Индекс coll.create_index(make_document(kvp("location", "2dsphere")));
Ближайшие {"location": {"$near": {"$geometry": here, "$maxDistance": 3500}}}
Внутри круга {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
Внутри многоугольника {"location": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": [[...]]}}}}
Зона для точки {"area": {"$geoIntersects": {"$geometry": here}}}
Точка GeoJSON {"type": "Point", "coordinates": [39.8938, 57.6261]} долгота, затем широта
Индекс Keys: bson.D{{Key: "location", Value: "2dsphere"}}
Ближайшие {"location": {"$near": {"$geometry": here, "$maxDistance": 3500}}}
Внутри круга {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
Внутри многоугольника {"location": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": [[...]]}}}}
Зона для точки {"area": {"$geoIntersects": {"$geometry": here}}}
Точка GeoJSON {"type": "Point", "coordinates": [39.8938, 57.6261]} долгота, затем широта
Индекс coll.indexes.create_one({ "location" => "2dsphere" })
Ближайшие {"location": {"$near": {"$geometry": here, "$maxDistance": 3500}}}
Внутри круга {"location": {"$geoWithin": {"$centerSphere": [[39.8875, 57.6300], 5 / 6378.1]}}}
Внутри многоугольника {"location": {"$geoWithin": {"$geometry": {"type": "Polygon", "coordinates": [[...]]}}}}
Зона для точки {"area": {"$geoIntersects": {"$geometry": here}}}

Итог главы

Точка на карте хранится как GeoJSON: {"type": "Point", "coordinates": [долгота, широта]}, область — как Polygon с замкнутым контуром. Индекс 2dsphere обязателен для $near, который возвращает документы от ближнего к дальнему и ограничивает расстояние параметром $maxDistance в метрах. $geoWithin отбирает точки внутри круга ($centerSphere, радиус в радианах — километры, делённые на 6378,1) или многоугольника, без порядка по расстоянию. $geoIntersects находит области, в которые попадает точка, — так определяют зону доставки. Перепутанные долгота и широта дают точку в другом месте без ошибки, и только широта больше 90° отклоняется индексом с ошибкой 16755.

Глава 3.5 · Справочник по MongoDB Автор: Макаров Максим Николаевич