
На связи разработчики продукта Аврора Центр компании Открытая мобильная платформа. Сегодня мы расскажем как реализовать сервис self-hosted карт в закрытом контуре.
Наша компания активно развивается и добавляет новый функционал в продукт по удалённому управлению устройствами — Аврора Центр (UEM-решение, которое позволяет управлять устройствами и жизненным циклом приложений на ОС Аврора, Android и Linux). Так по запросам заказчиков было решено добавить отображение геопозиции мобильного устройства на карте территории России. И вот перед нами встаёт задача по работе с картами в АЦ.
Требования к карте:
-
должна работать офлайн, без наличия доступа к сети;
-
не должна вызывать лишних вопросов в свете геополитических событий;
-
должна быть актуальной.
Введение в карты
Существует много картографических сервисов с различными подходами. Так, например, есть сервисы, предоставляющие механизм (инструмент) по получению картографических данных через запросы (API). Например, это сервисы 2GIS и Yandex.
Также существуют сервисы, поставляющие географические данные, предназначенные для обработки и предоставления в желаемом формате. Такие сервисы представляют собой комплексные и разнородные системы, в которых необходимо выбрать подход к тому, как хранить, обновлять и поставлять географические данные. Например, они могут предоставлять их в «портативном» виде mbtiles или хранить в формате PostGIS.
Самым известным поставщиком географических данных является OpenStreetMap — открытая компания, которая предоставляет набор инструментов, необходимых для взаимодействия с картографической информацией, а также публикует их документацию на собственном Wiki. Как несложно догадаться, основной рабочей силой в OpenStreetMap являются волонтёры, которые пишут упомянутый софт, а также наполняют карту географическими данными.
Закатываем рукава
Начинаем работать над задачей и исследуем уже готовые решения. Заботливый аналитик подкидывает варианты и первым из них является использование карт Яндекса, 2GIS или OpenStreetMap.
Вариант с использованием внешних API, выглядит вполне закономерным решением — меньше трудозатрат. Использование сервисов 2GIS и Yandex требует предоставления конечному заказчику ключей и прав доступа к платному API. Возможно, некоторым заказчикам такое решение подходит, но мы стали изучать следующий вариант. Смотрим инструменты OpenStreetMap и понимаем, что их использование не соответствует одному из главных требований — карты должны работать без доступа в интернет. И аналитик предлагает ещё один вариант, чтобы соответствовать этому требованию, можно попробовать выгрузить необходимую нам территорию в виде изображений! Казалось бы — просто, что может пойти не так?
Обсудив решение с аналитиком и архитектором, приступили к реализации. Написали простенький скрипт, который выкачивает png-файлы тайлов по определённым координатам (x/y/z).
Вся карта разбита на небольшие кусочки, количество которых в сетке зависит от зума. Тайлы — это эти кусочки карты. x и y — координаты относительно левого верхнего угла сетки в направлении справа вниз. z — уровень зума.
К сожалению, попытка оказалась неудачной. Изображения одной только территории Москвы занимали очень много места. Но проблема объемов данных не является критичной, ведь HDD ёмкостью в десятки терабайт — обычное дело, и они вполне доступны. Суть проблемы кроется в обновлении этих данных — выкачивать каждый раз огромные объемы нецелесообразно, если, к примеру, изменилось только название улицы/парка/аптеки.
Не опускаем руки
Не опускаем руки и продолжаем изучать готовые решения, но уже не API, а сервера, которые могут хостить «наши данные» в пределах Аврора Центра. Выбор пал на два популярных инструмента: tileserver-gl и mbtileserver. Первый реализован на JS, второй на Go. Обрадовались, взяли самый удобный — tileserver-gl. Инструмент позволяет запрашивать и векторные тайлы, и растровые картинки из исходного файла mbtiles. Также есть возможность подгружать файлы стилей к карте, что тоже добавляет гибкости. Но тут пришёл безопасник и надавал по неопущенным рукам, ведь интерпретируемые языки (коим и является JS) сертифицировать весьма проблематично. Проблема заключается в том, что для сертификации продукта, написанного на интерпретируемом языке, как правило, необходимо сертифицировать его интерпретатор. Поэтому нельзя просто так взять и использовать какой-то интерпретируемый язык.
Пришли к решению на Go — mbtileserver. С одной стороны это решило проблему с сертификацией, с другой — реализация этого сервера не подходит под нашу архитектуру и его необходимо переписывать.
Первый этап пройден: создан инструмент, который умеет отдавать тайлы тестовой карты по запросу фронтенда и всё работает! Переходим к следующему этапу — где взять полную карту? И тут начинается тернистый путь и самое интересное.
Карты генерировали-генерировали, да не выгенерировали
Выяснилось, что есть готовые файлы mbtiles. Поискав в открытом доступе, нашли пару ресурсов, которые предлагают «на пробу» mbtiles небольших размеров, например Лихтенштейна, а если нужны конкретные регионы больших размеров — добро пожаловать в мир платных подписок. Такой вариант нам не подходит. Но ведь mbtiles как-то генерируются? Как? Начинаем изучать этот вопрос и находим ответ — некоторые сервисы предоставляют «сырые» данные, содержащие географическую информацию — osm.pbf. Из этих «ПБФок» мы можем сформировать mbtiles. Что является исходными данными мы выяснили, но как перевести один формат в другой? К сожалению, Wiki OpenStreetMap не даёт прямых ответов на этот вопрос (мы знаем, мы искали). Потратив кучу времени, мы нашли инструмент, который выглядел наиболее удовлетворяющим нашим потребностям — tilemaker.
«Исходники» карты (osm.pbf) есть, их можно скачать на сайте. Утилита для формирования mbtiles — есть! Приступаем к генерации. Для начала пробуем что-нибудь небольшое, а именно территорию Северного Кавказа (её размер около 100 Мб). Запускаем tilemaker.
$ tilemaker --input north-caucasus-fed-district-latest.osm.pbf --output north-caucasus-fed-district.mbtiles
Получаем следующий вывод:
Reading .pbf north-caucasus-fed-district-latest.osm.pbf (Scanning for ways used in relations: 83%) (56 ms) Block 2370/2371 (393 ms) SortedNodeStore: 59102 groups, 407009 chunks, 18956414 nodes, 108239858 bytes (24% wasted) Block 266/267 (3125 ms) SortedWayStore: 14150 groups, 132594 chunks, 1565189 ways, 17370675 nodes, 56313586 bytes only 6 relation blocks; subdividing for better parallelism Block 95/96 (3384 ms) Generated points: 936512, lines: 26, polygons: 536435 Attributes: 79817 sets from 3109888 objects (830464 uncached), 2691072 pairs (683008 uncached) Creating mbtiles at north-caucasus-fed-district.mbtiles indexed 207564 contended objects osm: finalizing z6 tile 4096/4096 (165 ms) osm: finalizing z6 tile 4096/4096 (0 ms) indexed 0 contended objects shp: finalizing z6 tile 4096/4096 (0 ms) shp: finalizing z6 tile 4096/4096 (0 ms) collecting tiles: 23ms, filtering tiles: z0 (1, 0ms) z1 (1, 0ms) z2 (1, 0ms) z3 (3, 0ms) z4 (3, 0ms) z5 (3, 0ms) z6 (8, 0ms) z7 (18, 0ms) z8 (51, 0ms) z9 (163, 0ms) z10 (554, 2ms) z11 (2121, 8ms) z12 (8030, 31ms) z13 (30947, 129ms) z14 (118776, 516ms) z6/40/23, writing tile 160680 of 160680 Filled the tileset with good things at north-caucasus-fed-district.mbtiles
…и сгенерированный файл mbtiles!
Что ж, репетиция прошла успешно, пришло время сгенерировать карту всей России. Скачиваем исходники карты России и запускаем генерацию, счастливые наблюдаем за процессом… проходит полчаса и ловим зависание компьютера, т.к. заканчивается вся оперативная память. Локально протестировать не получилось, запрашиваем ресурсы, а именно виртуалку с 256 Гб ОЗУ и какое-то время экспериментируем на ней. Но в итоге находим другой выход — tilemaker умеет «скидывать» промежуточные этапы на диск, не храня всё в ОЗУ. Для включения этой опции используйте флаг --store. Победа!
Стоит отметить, что размер необходимой оперативной памяти напрямую зависит от размера исходного файла (.pbf), без опции сохранения промежуточных этапов на диск. Так, например, для генерации карты из файла russia-latest.osm.pbf, размером 3,7 Гб, требуется до 32 Гб ОЗУ.
А это чьё?
На текущий момент в предоставляемых OpenStreetMap данных некоторых территорий могут находиться в разных исходных файлах. Для формирования карт, интересующих заказчика, может потребоваться объединение нескольких исходных файлов, что может привести к неправильному отображению границ территорий. Перед нами встаёт новая задача, или даже две: 1) решить, что с этим делать и 2) сделать. Пообщавшись с аналитиком, пришли к выводу, что можно позаимствовать идею у Яндекс карт и убрать границы. Таким образом, отображаем только границы административных регионов.
Для того чтобы сделать невидимыми границы стран, необходимо в файле стилей установить фильтр для boundary-land-level-2:
{ "id": "boundary-land-level-2", "type": "line", "source": "openmaptiles", "source-layer": "boundary", "filter": ["all", ["!=", "maritime", 1], ["!=", "disputed", 1]], "layout": { "line-cap": "round", "line-join": "round", "visibility": "visible" } }
За границы регионов отвечает boundary-land-level-4, он остаётся без изменений. В итоге получилось как-то так:

Для того чтобы карта выглядела полноценной, были добавлены исходные данные Европы и Азии, а также на карте убрана отрисовка границ между странами. Конечно, после этого карта начала занимать десятки гигабайтов на диске и встал новый вопрос — как эти данные передавать конечному заказчику с каждым релизом? Ведь карта должна быть актуальной, а значит, регулярно обновляться. Для решения этой проблемы было придумано следующее — карты, интересующие заказчиков, были детализированы до 14 зума. Данные по остальным территориям были отфильтрованы, и оставлен набор элементов ways, relations, nodes, которые нужны для нормального отображения до 8 зума. На бОльших зумах pbf будут просто растягиваться. Для Азии такой фильтр может включать, например, основные магистрали, парки, ЖД пути, названия регионов и областей. Нужное подчеркнуть и добавить с помощью утилиты osmfilter. Например:
osmfilter asia-latest.o5m --keep= --keep-ways="highway=trunk or railway=rail or natural= or boundary=national_park or boundary=administrative and type=boundary" -o=asia-latest-filter.o5m
Также карты остальных территорий можно приправить добавлением названий столиц, областей или дополнительной информации на ваш вкус. Например, столицы можно скачать с ресурса overpass-turbo. Запрос будет выглядеть так:
[out:xml][timeout:10000]; ( node["capital"="yes"](4,4,-20,80.5,180); ); out body; >; out skel qt;
Запрос для континентов, стран, областей, городов:
[out:xml][timeout:10000]; ( node["place"="continent"](4,4,-20,80.5,180); node["place"="country"](4,4,-20,80.5,180); node["place"="state"](4,4,-20,80.5,180); node["place"="city"](4,4,-20,80.5,180); ); out body; >; out skel qt;
Экспортируем результат в необработанные данные OSM. Добавить столицы и страны:
osmconvert eurasia-latest-filter.o5m capitals.osm country.osm -o=eurasia-filter.pbf
-
eurasia-latest-filter.o5m — файл для Евразии, отфильтрованный до нужного зума.
-
capitals.osm — скачанные столицы из шага выше.
-
country.osm — скачанные названия континентов, стран, областей и городов из шага выше.
-
eurasia-filter.pbf — результирующий файл.
Просто добавь воды
Карта, включающая территории РФ, Европы и Азии, может выглядеть не совсем законченной, т.к. не обрамляется океанами или морями. Чтобы «налить воды», можно внести изменения конфигурационный файл tilemaker’a /resources/process-openmaptiles.lua, а именно в секции:
if natural=="water" or natural=="bay" or leisure=="swimming_pool" or landuse=="reservoir" or landuse=="basin" or waterClasses[waterway] then if way:Find("covered")=="yes" or not isClosed then return end local class="lake"; if natural=="bay" then class="ocean" elseif waterway~="" then class="river" end if class=="lake" and way:Find("wikidata")=="Q192770" then return end if class=="ocean" and isClosed and (way:AreaIntersecting("ocean")/way:Area() > 0.98) then return end way:Layer("water",true) way:MinZoom(0) -- SetMinZoomByArea(way) way:Attribute("class",class) ...
и
-- Set 'landcover' (from landuse, natural, leisure) local l = landuse if l=="" then l=natural end if l=="" then l=leisure end if landcoverKeys[l] then way:Layer("landcover", true) way:MinZoom(0) -- SetMinZoomByArea(way) way:Attribute("class", landcoverKeys[l]) if l=="wetland" then way:Attribute("subclass", way:Find("wetland")) else way:Attribute("subclass", l) end write_name = true ...
SetMinZoomByArea(way) заменить на way:MinZoom(0), где 0 — нужный зум.
Однако необходимо учитывать, что добавление воды, как и полагается, будет влиять на конечный размер файла.
Обновления
И так, промежуточные итоги:
-
генерация карт — есть;
-
сервис для хостинга данных этих карт — есть;
-
фронт их отрисовывает — есть!
Можно праздновать победу? Не совсем. Как уже говорилось ранее, данные карт должны обновляться. Снова погружаемся в пучину Wiki OpenStreetMap.
Находим несколько интересных особенностей:
-
Обновления предоставляются geofabric.de в формате ocs.gz.
-
Обновления пропускать нельзя, иначе это поломает .osm.pbf.
-
Чтобы обновить, необходимо выяснить порядковый номер (sequence number) текущей и обновлённой pbf.
-
Высчитать URL для файлов обновлений и загрузить их, основываясь на порядковых номерах.
-
Объединить файлы обновлений в один.
-
Применить.
Для этого в дело вступает еще один инструмент — Osmium. С его помощью из pbf достаём replication info, в которой хранится текущий sequence number. Также из replication info достаём базовый URL, с которого можно подтянуть обновления. А теперь наиболее интересное в данном процессе — высчитываем путь до файлов обновления. Для этого сходим за файлом state.txt (например, сюда), в котором хранится последний sequence number для нашей pbf (например, текущая pbf имеет sn=3700, а файл state.txt содержит в себе sn=3710). После того как мы узнали текущий sn и sn в файле, начинаем считать. Да, стоит отметить особенность URL для файлов обновления, они выглядят примерно так. Проходимся в цикле от текущего sn+1 до sn из state.txt, делим на 1000, чтобы получить часть URL (3701/1000 = 3) и делим с остатком, чтобы получить номер обновления (3701%1000 = 701). На основе упомянутых ранее особенностей формируем URL и получаем результат. Ниже приведён bash-скрипт, который используется для автоматизации процесса:
# get pbf header # $1 argument -- name of the file in format name.osm.gz function get_pbf_header() { if [[ -z $REPLICATION_INFO ]] then REPLICATION_INFO=`$OSMIUM fileinfo osm-data/pbf/$1` fi } # parse pbf header and get sequence number # $1 argument -- name of the file in format name.osm.gz function get_sequence_number() { get_pbf_header $1 echo $(echo -e $REPLICATION_INFO | grep -E -o "replication_sequence_number=([0-9]+)" | cut -d '=' -f2) } # parse pbf header and get base url # $1 argument -- name of the file in format name.osm.gz function get_base_url() { get_pbf_header $1 echo $(echo $REPLICATION_INFO | grep -E -o "replication_base_url=.*[^\s]" | cut -d ' ' -f1 | cut -d '=' -f2) } # get information about pbf, parse and prepare download url # $1 argument -- pbf name # $2 argument -- wanted sequence number function calc_osc_update_path() { get_pbf_header $1 local __seq_num=$2 local __base_url=$(get_base_url) local __lead=$(( $__seq_num / 1000 )) local __remain=$(( $__seq_num % 1000 )) local i=${#__lead} local __lead_prefix="" while [ $i -lt $SUB_DIR_LEN ] do local __lead_prefix="0""$__lead_prefix" local i=$(( $i + 1)) done i=${#__remain} local __remain_prefix="" while [ $i -lt $SUB_DIR_LEN ] do local __remain_name="0""$__remain_prefix" local i=$(( $i + 1)) done echo "$__base_url/$UPDATES_PREFIX/$__lead_prefix$__lead/$__remain_prefix$__remain" } # get last change number from origin resourse # $1 argument -- name of the file in format name.osm.gz function get_latest_change_number() { get_pbf_header $1 local __base_url=$(get_base_url) echo $(curl -sL $__base_url/state.txt | grep -oE "sequenceNumber=(.*)" | cut -d"=" -f2) } # download needed change files # from current sequence number to latest # $1 argument -- file name in the "name.osm.pbf" format function download_changes() { local __fullname=$1 local __filename="${__fullname%.*.*}" local cur_seq_num=$(get_sequence_number $__fullname) local last_seq_num=$(get_latest_change_number $__fullname) if [[ $cur_seq_num -gt $last_seq_num ]]; then echo "[critical] bad sequence numbers for $__fullname. current $cur_seq_num, latest $last_seq_num" return 1 elif [[ $cur_seq_num -eq $last_seq_num ]]; then echo "[warn] no updates for $__fullname with latest $last_seq_num" return 2 fi echo "[info] downloading updates for $__fullname" for i in $(seq $(( $cur_seq_num + 1 )) $last_seq_num) do local __url=$(calc_osc_update_path $__fullname $i) local __file_date=$(curl -s $__url.state.txt | grep -oE "timestamp=([0-9]+-[0-9]+-[0-9]+)" | cut -d"=" -f2) wget \ --quiet \ --progress=bar \ -e robots=off \ -np \ -nH \ -R "index.html*" \ -O "osm-data/changes/$__filename"_"$i"_"$__file_date.osc.gz" \ $__url.osc.gz done }
Файлы обновлений скачаны. Самое время их объединить в один. Для этого используем уже упомянутый Osmium:
# merge multiple osc.gz change files into one # $1 argument -- file name in the "name.osm.pbf" format function merge_changes() { local __filename=$1 local __filename="${__filename%.*.*}" echo "[info] merging changes for $1" $OSMIUM merge-changes osm-data/changes/"$__filename"_*.gz \ --progress \ -O \ -o osm-data/changes/"$__filename"-merged.osc.gz }
Ну и наконец, применяем наш файл к pbf’ке:
# apply change files to pbfs function apply_changes() { local __filename_ext=$1 local __filename="${__filename_ext%.*.*}" local __seq_num=$(get_latest_change_number $__filename_ext) local __base_url=$(get_base_url $__filename_ext) echo "[info] applying changes for $1" $OSMIUM apply-changes \ -O \ --progress \ --output-header=osmosis_replication_base_url=$__base_url \ --output-header=osmosis_replication_sequence_number=$(( $__seq_num )) \ -o osm-data/pbf/$__filename-updated.osm.pbf \ osm-data/pbf/$__filename.osm.pbf \ osm-data/changes/$__filename-merged.osc.gz if [[ $? -eq 0 ]]; then echo "[info] removing intermediate files" mv osm-data/pbf/$__filename-updated.osm.pbf osm-data/pbf/$__filename.osm.pbf echo $($OSMIUM fileinfo osm-data/pbf/$__filename.osm.pbf) else echo "[critical] something went wrong" return $? fi }
Обновлять ли все файлы территорий конечной карты, решать вам. В соответствии с этим нужно будет скорректировать скрипты. Вот и всё. И с обновлениями мы успешно справились!
Мы подошли из-за угла
Использование данных, которые поддерживаются сообществом, несет в себе некоторые риски и опасности. Данные OpenStreetMap — не исключение. В один прекрасный день мы столкнулись с одной опасностью — неверные или даже намерено испорченные данные. В случае с картами — это нецензурная лексика и различные выражения оскорбительного характера в названиях улиц и «поломанные» дороги. Процесс валидации кажется простым: посмотри и поищи. НО! Нужно это как-то автоматизировать. Вдруг поменяют имя только одной улицы?
Для поиска нецензурных выражений мы составили список возможных слов и словосочетаний. Далее пробегаемся по тегам и элементам в поисках слов из списка. В случае обнаружения невалидных данных мы сообщаем о них. Пусть это и наивный способ, но максимально быстрый и достаточный для обнаружения.
Пусть и не идеально, но:
import atexit import argparse import pathlib import threading import time from typing import TypeAlias import osmium as osm import osmium.osm.types as osm_types # OSMElementType is a type alias for OSM types used in handler. OSMElementType: TypeAlias = osm_types.Node | osm_types.Way | osm_types.Relation # https://wiki.openstreetmap.org/wiki/Key:name TagKeyName: str = 'name' class OSMNameValidator: def __init__(self, forbidden_words: list[str]): self._forbidden_words = forbidden_words def valid(self, line: str) -> bool: for word in self._forbidden_words: if word in line.lower(): return False return True class OSMHandler(osm.SimpleHandler): def __init__(self, validator: OSMNameValidator): osm.SimpleHandler.__init__(self) self.__validator = validator self.processed_elements = 0 self.processed_tags = 0 self.non_valid_count = 0 self.processed_files = 0 def tag_inventory(self, elem: OSMElementType): for tag in elem.tags: if (key := tag.k) == TagKeyName: if not self.__validator.valid(tag.v): self.non_valid_count += 1 print(f"{tag.v} contains forbidden words") self.processed_tags += 1 self.processed_elements += 1 def node(self, node: osm_types.Node): self.tag_inventory(node) def way(self, w: osm_types.Way): self.tag_inventory(w) def relation(self, r: osm_types.Relation): self.tag_inventory(r) def apply_file(self, filename: str, locations: bool = False, idx: str = '') -> None: self.processed_files += 1 return super().apply_file(filename, locations, idx) class VerbosityHandlerThread: '''Prints statistics if verbosity flag is set''' def __init__(self, verbose: bool, handler: OSMHandler, forbidden_words_count: int): self.__handler = handler self.__verbose = verbose self.__fwc = forbidden_words_count self.__f_count = 0 self.__time_now = lambda : time.ctime(time.time()) self.__run() atexit.register(self.__exit) def add_files_count(self, count: int): self.__f_count += count def __fetch_stats(self) -> str: elems = self.__handler.processed_elements tags = self.__handler.processed_tags non_valid = self.__handler.non_valid_count processed_files = self.__handler.processed_files now = self.__time_now() return ( f'Processed elements: {elems} tags: {tags}' f' processed files: {processed_files}/{self.__f_count}' f' non valid: {non_valid} {now}' ) def __print_stats(self): stats = self.__fetch_stats() print(stats, end='\r') def __print_stats_forever(self): while True: self.__print_stats() time.sleep(5) def __run(self): if self.__verbose: print("started", self.__time_now()) print("forbidden words count", self.__fwc) t = threading.Thread( target=self.__print_stats_forever, daemon=True, ) t.start() def __exit(self): self.__print_stats() print("\nfinished", self.__time_now()) def cli_args() -> argparse.Namespace: ap = argparse.ArgumentParser() ap.add_argument( '-s', '--source', help='data source file to process', type=str, ) ap.add_argument( '-d', '--directory', type=str, ) ap.add_argument( '-f', '--forbidden', default='./forbidden_words.data', ) ap.add_argument( '-v', '--verbose', default=False, action='store_true', ) return ap.parse_args() def main(): args = cli_args() if args.source is None and args.directory is None: print("source or directory arguments must be provided") exit(1) try: f = open(args.forbidden, 'r') forbidden = f.read().split(',') f.close() except Exception as e: print(e) exit(1) osm_name_validator = OSMNameValidator(forbidden) osmhandler = OSMHandler(osm_name_validator) verbosity_handler = VerbosityHandlerThread( args.verbose, osmhandler, len(forbidden), ) if args.source: osmhandler.apply_file(args.source) verbosity_handler.add_files_count(1) if args.directory: file_dir = pathlib.Path(args.directory) globed_files = [f for f in file_dir.glob('**/*')] verbosity_handler.add_files_count(len(globed_files)) for file in globed_files: osmhandler.apply_file(file) if __name__ == '__main__': main()
Финал
Self-hosted карты — это весьма трудозатратное занятие. Построение такой инфраструктуры с нуля требует больших временных затрат и высокой квалификации сотрудников. Так как информация, доступная из открытых источников, неоднородная и неисчерпывающая, приходится собирать всё по крупицам методом проб и ошибок. Плюс не стоит забывать о возможных подводных камнях, которые могут встретиться как во время исследования и реализации, так и в процессе эксплуатации, поддержки и обновления данных для карт. Но то, насколько это увлекательно и какой прирост функциональности даёт — несравнимо ни с какими сложностями, которые мы повстречали на пути реализации. С момента создания статьи могли появиться новые возможности по работе с картами.
ссылка на оригинал статьи https://habr.com/ru/articles/912000/
Добавить комментарий