OpenStreetMaps — это Open Source продукт, в котором 9 млн человек со всего Интернета создают свободную карту мира. Также это бесплатная альтернатива Google Картам при коммерческой разработке. Главная проблема такого продукта в том, что его сложно оптимизировать, а данные могут размечаться по-разному.
Сегодня я хочу поделиться с вами опытом нашего Go-разработчика Владимира, который знает, с какими трудностями можно столкнуться при использовании OSM в создании сложных продуктов с использованием геоданных и как их обойти.
Cтатья впервые была опубликована на Tproger.
Про инструмент
OpenStreetMap, или OSM, несмотря на название, нельзя назвать картой в привычном понимании этого слова. На самом деле это база данных, которая содержит сведения о точках земной поверхности, которая заполняется по принципу Wiki — каждый зарегистрированный пользователь может внести свои изменения. В ход идут данные GPS-трекеров, панорамы улиц или, например, спутниковые снимки. В результате от каждой точке поверхности собирается большой объем данных, на основе которых можно строить карты различного назначения.
Обратная сторона такой гибкости — «разношерстность» сведений, один и тот же параметр может быть записан в разных форматах. Кроме того, исходные данные почти всегда оказываются избыточными для каждой конкретной задачи и нуждаются в фильтрации и приведению к единому виду.
О задаче
Я принимаю участие в разработке системы мониторинга грузового и муниципального транспорта, которая отслеживает передвижения техники, подключенной к региональной навигационно-информационной системе (РНИС) по городу и в области, а также между регионами ЦФО.
Задача сервиса, о котором пойдет речь, — получить координаты от транспортных средств (автобусы, маршрутки, поливальные машины, грузовая и уборочная техника), определить дороги, по которым они движутся, и выяснить максимально разрешенную скорость. Если зафиксировано нарушение — сообщить об этом и записать информацию в лог, который анализируют операторы РНИС.
Если совсем просто, то наша задача состоит в определении скоростного режима на автомобильных дорогах и фиксации нарушений.
Из OpenStreetMap получаем данные о разрешенной скорости в каждой точке. Обработка осуществляется с помощью библиотеки S2 от Google, реализация на Go. Источником данных о самих машинах служат их GPS-датчики.
Проблема 1. Быстродействие
Overpass API — распространенный сервис доступа к OSM, который позволяет извлекать данные по пользовательскому запросу. Наша система работала с инстансом сервиса, развернутым в нашей сети.
По мере роста трафика производительности этого решения стало не хватать. Сервис превратился в бутылочное горлышко, время получения ответа от него росло. Внутренние сервисы системы обращались друг к дружке, а в итоге вся система терпеливо дожидалась ответа от Overpass. Фактически, запрос скорости для десятка точек под нагрузкой мог занимать до секунды.
Решение: разработали новый сервис, который мог бы заменить собой функционал Overpass API. В качестве источника остается OSM. Разрабатываемый сервис парсит данные и на их основе строит B-Tree индекс. В качестве ключа используем s2 CellId, сгенерированный для координат точки. API сервиса реализован с использованием gRPC.
Проще говоря, новый сервис выкачивает дампы базы данных OSM, не обращаясь к API, и строит поисковый B-Tree индекс.
Проблема 2. Импорт и индексирование данных
OpenStreetMap работает с данными в двух форматах — .osm и .pbf. В качестве формата для импорта мы использовали pbf, так как он более компактный, чем .osm.
Для представления данных на картах используется несколько типов элементов:
Точки (Node). Имеет координаты и id, опционально может иметь список тегов.
Пути (Way). Упорядоченная совокупность точек (от 2 до 2000), опционально также может иметь теги.
Теги (Tag). Основной способ описания географических данных, каждый тег представляет из себя пару ключ-значение.
Отношения (Relation). Используются для описания областей на карте, могут содержать теги.
Наибольший интерес для нас представляют элементы типа Way, так как они содержат данные о максимально разрешенной скорости. Имея координаты точек, из которых строятся пути, мы можем построить индекс и использовать его для определения участка дороги.
К сожалению, сразу проиндексировать путь не получится, так как он содержит в себе не координаты точек, а их ID. То есть сервису необходимо держать в памяти отображение ID точек и через них вычислять координаты. Учитывая объёмы — для центрального федерального округа дамп данных в формате .pbf имеет размер 680 Мб, процесс индексирования становится очень ресурсоемкой операцией.
Решение: подготовим данные перед импортом, используя утилиту для работы с данными OSM — osmium. После скачивания данных, запускаем команду:
osmium add-locations-to-ways —output=./prepared-data.osm.pbf ./data.osm.pbf
Когда команда выполнится, в файле ./prepared-data.osm.pbf получим дамп данных, в котором пути вместо ID точек содержат их координаты.
Проблема 3. Разметка данных
Индексы строятся по конкретному полю, а так как координаты точки состоят из двух чисел, то возникает вопрос, как построить индекс по двум полям.
Решение: для работы с s2 в golang есть библиотека geo, с её помощью будем генерировать cellId и использовать его в качестве ключа для поиска по индексу.
Кроме cellId индексируемый элемент должен будет содержать максимально допустимую скорость. Определять ее будем на этапе индексации. Сделать это можно двумя способами: по тегу «maxspeed», либо, если «maxspeed» не указан (что бывает довольно часто), по тегу «highway».
Ограничение скорости может быть указано как в виде числового значения (в км/ч, или реже для России, в милях в час), так и в формате констант, описанных в wiki OpenStreetMap.
Пример реализации:
// Максимальное разрешение
const StorageLevel = 18
coords := make([][]float64, 0, len(Way.Nodes))
// формируем слайс пар точек координат
for _, n := range Way.Nodes {
p := n.Point()
coords = append(coords, []float64{p.Lon(), p.Lat()})
}
// Формируем полигон из точек пути
polygon := geojson.NewLineStringGeometry(coords)
// Генерируем id для индексируемого пути
id := uuid.New()
// Полезные данные
md := Metadata{
MDMaxSpeedKey: wms.MaxSpeed,
}
// Создаём из точек пути регион с нужным разрешением, таким образом
// добиваемся интерполяции с заданным разрешением
cover := s2.RegionCoverer{
MinLevel: 0,
MaxLevel: StorageLevel
MaxCells: math.MaxInt64,
}
cells := cover.InteriorCovering(polygon)
// Добавляем полученные CellId в индекс
for _, cellID := range cells {
index.AddPoint(&IndexPoint{
CellID: cellID,
Geometry: &IndexedGeometry{
UUID: id,
GeometryType: polygon.Type,
Geometry: polygon,
Metadata: md,
}
})
}
***
В итоге мы получили сервис, в котором каждый запрос обрабатывается меньше одной миллисекунды. На его создание ушло примерно три недели: от старта исследования до разработки. Сейчас мы готовим prod-решение, оформление его в виде gRPC-сервиса и интегрируем в РНИС.
Если остались вопросы — предлагаю обсудить в комментариях.