Self-hosted Mapbox Vector Tiles на PHP: ускоряем обновление данных

Чтобы преодолеть ограничения существующих решений для создания векторных карт, мной была разработана PHP-библиотека heymoon/vector-tile-data-provider, реализующая полный цикл преобразования геоданных, совместимых со спецификацией OpenGIS (чтение GeoJSON, WKT, WKB либо собственного формата на базе brick/geo) в тайлы в формате Mapbox Vector Tile, без привязки к фреймворку и хранилищу данных. Далее я изложу ключевые технические особенности, дающие представление об области применения и механизме работы этой библиотеки.

Интерактивный пример работы подключения тайлового сервера на базе php-vector-tile-data-provider с использованием PHP 8.1, GEOS, Symfony 6.1 и Redis в качестве источника данных для Leaflet.VectorTileLayer. Все элементы кликабельны, для взаимодействия с дорогами необходимо приблизить карту.

Данные из примера (10.2M):

Для проверки “ленивой” генерации тайлов из GeoJSON в момент запроса, деактивируйте флаг “Use cache”.

Примечание: Leaflet не является оптимальным способом для отображения векторных тайлов в браузере, однако хорошо подходит для встраивания примера на страницу, потребляя минимум ресурсов в состоянии покоя. Отображение карты с графическим ускорением возможно с помощью официальной библиотеки Mapbox GL либо сторонних MapLibre GL, OpenLayers или множества других решений.

Источник данных из примера можно посмотреть в Maputnik.

Проблематика

Объём данных

Когда вы открываете карты от Google или Yandex и смотрите на землю издалека, ваше устройство загружает только те данные, которые будут заметны на карте при данном масштабе: слишком маленькие географические объекты скрываются, а у оставшихся видимыми детализация границ снижается до минимальной необходимой при данной плотности пикселей вашего устройства. Чтобы это было возможным, серверу необходимо заранее обработать данные карты с самой высокой детализацией и подготовить упрощённые версии для разных масштабов. Чем больше площадь тайла, тем больше данных в выборке влияют на его отображение, и для отображения тайла на зуме 0, покрывающего всю землю, нам необходимо обработать все существующие на карте геометрии за один вызов. Генерация таких тайлов выполняется дольше, чем генерация тайлов вблизи, включающих в себя небольшой набор геометрий. Однако количество тайлов для зума рассчитывается по формуле (zoom + 1)^2: чем больше значение zoom, тем больше тайлов ожидается на данном масштабе и тем больше отдельных записей нам придётся создать, чтобы зранее сформировать данные требуемого масштаба. И если при zoom равном 0 обработка может занять 10 секунд, то при zoom=20, не смотря на высокую скорость генерации отдельных тайлов, количество операций и необходимость создания большого числа маленьких записей приводят к существенному замедлению обработки карты. Это наблюдается даже при работе с официальным mbutil. Так что если нужно визуализировать постоянно меняющиеся границы грозового фронта или актуальную загруженность городской инфраструктуры, стандартные средства не позволяют достичь нужного результата. Производительность отдачи тайлов напрямую из PostgreSQL с помощью postgis-vt-util будет проседать при каждом обновлении индекса. Требуемая производительность достижима только когда обращение к данным производится по точным ключам, уникальным для каждого тайла, в то время как SELECT по диапазонам координат в постоянно изменяющейся таблице обречён на низкую отзывчивость.

Картографическая проекция

Если окажется, что земля всё таки плоская, этот функционал станет лишним. Однако пока чтобы спроэцировать квадратную сетку матрицы тайлов на поверхность шара земли, необходимо получить проекцию, в которой цена деления горизонтальной и вертикальной осей координат соответствует одному и тому же расстоянию на поверхности. Грубо говоря, нам нужно перевести координаты из системы WGS 84 в EPSG:3857. В PostGIS это достижимо с помощью ST_Transform((some_geometry), 'WGS84', 3857), однако такой запрос сработает не на всех версиях и оставляет желать лучшего по скорости выполнения. Более эффективное решение нашлось на stackoverflow: долгота перетягивается пропорционально, а для получения широты применяется формула.

Почему PHP?

Когда-то PHP расшифровывался как “Personal Home Page Construction Kit”, а сейчас это объектно-ориентированный язык со строгой типизацией и множеством бинарных библиотек, которых не встретишь в JS и Go. С их помощью можно использовать нативные решения, написанные на компилируемых языках с большим быстродействием чем Java или Python, не считая JS, где работа с числами с плавающей точкой может вести себя непредсказуемо и нельзя подключить нативный GEOS. В этом PHP похож на Python, который уже занял место в качестве популярного языка для работы с тайлами. Однако по некоторым оценкам PHP 8.1 работает в 3 раза быстрее чем Python, так что его эффективность для решения данной задачи несправедливо недооценена. При этом для нас предпочтительнее реализовать обновление тайлов именно на интерпретируемом языке, для упрощения процесса внедрения и поддержки индивидуальной логики получения и обработки исходных данных. PHP 8.1 выполняется немного медленнее JS, но чуть быстрее чем Java, и значительно быстрее чем Python, что делает PHP 8.1 как минимум конкурентоспособным вариантом. Среди тайловых решений на PHP кроме моего я встретил только tileserver-php — почти не изменившийся с появления в 2012м году но до сих пор поддерживаемый скрипт для хостинга готовых тайлов в формате MBTiles (без функционала генерации тайлов, на котором сосредоточено моё решение) на web-хостингах с поддержкой PHP но без доступа к руту виртуалки, как правило самый дешёвый вариант из-за упрощённой виртуализации. В первую очередь это рассчитано на совместное использование с веб-хостингом под управлением ISPmanager, с загрузкой файлов по FTP или через панель. Но всё же в текущих реалиях удобнее использовать то, что есть в Dockerhub и Composer, и готовый конфигурируемый тайловый сервер с функционалом генерации из источника по запросу теперь можно легко создать на базе этой библиотеки. При этом, в отличие от однопоточного JS (более популярного для тайлов судя по количеству решений на нём), PHP в связке с FPM легко поддерживает многопоточную обработку запросов, а многопоточность на этапе загрузки можно обеспечить параллельным запуском нескольких скриптов.

В PHP есть нативный модуль для работы с GEOS — проверенной временем библиотеки, используемой самим PostGIS. К тому же PHP is a fun language: здесь действительно удобно работать с разными типами геометрий. Универсальная функция по объединению геометрий в коллекцию реализуется очень просто именно благодаря возможности динамического вызова функционала по строке.

Свой тайловый сервис

Для обеспечения частого обновления данных в рамках изначальной задачи было решено формировать тайлы заранее только при zoom<17. При обработке данных с zoom=16, после сохранения MVT в Redis, туда же сохраняется GeoJSON вошедших в тайл геометрий. В момент запроса, если zoom<17, данные всегда берутся только по ключу готового MVT. Иначе для запрашиваемого зума ищем исходные данные, полученные для zoom=16, и “на лету” генерируем новые тайлы, так же сохраняя результат. Изначальный рассчёт был на то, что запросы с zoom>16 будут редкими, и сервер будет справляться уже засчёт этого, но нагрузочное тестирование показало, что благодаря заранее сгруппированным по тайлам данным, создание тайлов при zoom>16 происходит почти с такой же скоростью, как получение крупных тайлов из кеша.

В данном примере при формировании тайлов линий для zoom>18 используются данные, сформированные при обработке zoom=18.

Используемый в демо-карте класс, отдающий контент векторных тайлов из исходных данных, предварительно сгруппированных с помощью HeyMoon\VectorTileDataProvider\Service\GridService и записанных в кеш Symfony, который в моей конфигурации указывает на Redis:

use Brick\Geo\Exception\CoordinateSystemException;
use Brick\Geo\Exception\EmptyGeometryException;
use Brick\Geo\Exception\GeometryEngineException;
use Brick\Geo\Exception\GeometryException;
use Brick\Geo\Exception\InvalidGeometryException;
use Brick\Geo\Exception\UnexpectedGeometryException;
use Brick\Geo\IO\GeoJSONReader;
use HeyMoon\VectorTileDataProvider\Factory\SourceFactory;
use HeyMoon\VectorTileDataProvider\Service\TileService;
use HeyMoon\VectorTileDataProvider\Entity\TilePosition;
use HeyMoon\VectorTileDataProvider\Spatial\WebMercatorProjection;
use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface;

class TileRepository
{
    public function __construct(
        private readonly CacheInterface $cache,
        private readonly GeoJSONReader $geoJSONReader,
        private readonly SourceFactory $sourceFactory,
        private readonly TileService $tileService
    ) {}

    /**
     * @throws GeometryException
     * @throws GeometryEngineException
     * @throws CoordinateSystemException
     * @throws UnexpectedGeometryException
     * @throws EmptyGeometryException
     * @throws InvalidGeometryException
     * @throws InvalidArgumentException
     */
    public function get(TilePosition $position, string $name, ?int $midZoom = null, ?int $extent = null): ?string
    {
        if ($midZoom && $position->getZoom() > $midZoom) {
            $scale = pow(2, $position->getZoom() - $midZoom);
            $dataPosition = TilePosition::xyz(
                (int)floor($position->getColumn() / $scale),
                (int)floor($position->getRow() / $scale),
                $midZoom
            );
            $data = $this->cache->get(
                "{$name}_$dataPosition", fn() => null
            );
        } else {
            $data = $this->cache->get(
                "{$name}_$position", fn() => null
            );
        }
        if (!$data) {
            return null;
        }
        $source = $this->sourceFactory->create();
        $source->addCollection(
            $name,
            $this->geoJSONReader->read($data),
            0,
            WebMercatorProjection::SRID
        );
        return $this->tileService->getTileMVT(
            $source->getFeatures(), $position, $extent ?? TileService::DEFAULT_EXTENT, $position->getTileWidth() / 10
        )->serializeToString();
    }
}

При вызове addCollection в качестве $minZoom передан 0, но на данном этапе этот параметр может иметь любое значение. Он учитывается только при обработке в GridService, поэтому если ранее он был указан при загрузке данных из файла для передачи в Redis из Grid::iterate, данные будут пропущены при значении zoom < $minZoom (при условии что $minZoom <= $midZoom, иначе эти данные не смогут быть отображены ни на одном масштабе и не должны быть загружены, см. далее). В getTileMVT проверка $minZoom не выполняется. Изначально данные были закодированы в WGS 84, но к данному шагу уже были преобразованы в EPSG:3857 при обработке в GridService, поэтому здесь при добавлении коллекции к HeyMoon\VectorTileDataProvider\Entity\Source необходимо указать, что SRID переданных геометрий соответствует значению WebMercatorProjection::SRID (числу 3857). С помощью параметра $midZoom можно уточнить, до какого масштаба была выполнена группировка геометрий. В случае если запрашиваемый масштаб больше, вычисляются координаты тайла с масштабом как в $midZoom, покрывающего тот же участок, для загрузки релевантных данных.

I wish I knew this earlier

Здесь помогут упорядочить ваше представление о векторных тайлах: Overview of Vector Tiles от OpenStreetMap US.

В спецификации Vector Tile 2.1 существуют только единичные типы геометрий. Однако некоторые данные мы получаем как MultiPolygon и хотим подсвечивать все принадлежащие к одному объекту элементы.

MultiPolygon

На самом деле правильнее будет сказать, что в MVT не бывает единичных геометрий. Особенно это нужно учитывать, когда вы разрабатываете клиент. Геометрии объединяются за счёт совпадающих значений feature.id в рамках одного слоя. При этом нет гарантий неизменности топологии: для кодирования тайла объект упрощается через алгоритм Дугласа-Пекера без сохранения топологии: линия, пересекающая сама себя может преобразоваться в две. Ещё на топологию может повлиять кадрирование по рамкам тайла: если соединение линии оказалось за границами рамки, но в область входят два видимых отрезка, LineString превращается в MultiLineString.

Из этого следует вывод, что при интеграции своего источника в SDK, стоит избегать работы только с единичными элементами и закладывать итерацию элементов в случае получения коллекции.

Если в своём слое вы собираетесь выделять улицы, на начальном этапе вам наверняка окажется полезен этот сервис: https://overpass-turbo.eu. С его помощью можно экспортировать GeoJSON из базы OSM, чтобы использовать для тестирования или сопоставления своих данных с объектами на карте.

Применение

Я создал эту библиотеку потому что не нашёл достаточно эффективного и гибкого решения для частого обновления данных на Python, Go, JS или Java среди готовых. Если на интерпретируемых языках есть что-то быстрее, я бы хотел об этом узнать. Данные, которые обновлялись около часа с помощью mbutil, удалось обновить за 5 минут, откладывая формирование контента некоторых тайлов на момент обработки запроса. При этом наблюдаемое время ответа почти не отличается от времени ответа tileserver-gl со статичными данными в формате MBTiles. GeoJSON объёмом около 100 мегабайт можно преобразовать в Mapbox Cector Tile и загрузить в Redis менее чем за 10 минут при обработке в 16-ти параллельных процессах, каждый из которых зарезервирует до 2GB оперативной памяти, что требует не менее 32GB RAM для выполнения. В дальнейшем скорость загрузки сокращается при помощи поиска изменений по хешу исходных данных. Так же в примере выше допустимо поднять $midZoom так высоко, как вам позволяют ресурсы, не забыв добавить кеширование результата. С уменьшением количества потоков значительно уменьшится количество используемой памяти. Текущая реализация предполагает, что каждый поток должен загрузить все данные, а затем пропускать геометрии по очерёдности тайлов перед группировкой и дальнейшей генерацией protobuf. Около 2х гигабайт оперативки занимает прочтённая PHP модель геометрии из GeoJSON объёмом 100 мегабайт. Тем не менее тайловый сервер при работе с условным $midZoom = 16 на этих данных держится ниже отметки 1GB RAM при стресс-тесте.

Что дальше