Как подружить Gorm и PostGIS, решение промышленного уровня
GORM Фантастическая ORM для Golang.
PostGIS расширяет возможности реляционной базы данных PostgreSQL , добавляя поддержку хранения, индексирования и запросов геопространственных данных.
В этой статье поделимся своим опытом интеграции GORM и PostGIS, сложностями при попытке использования gorm для работы с геометрическими данными и конечно предлагаем готовое решение.
Изначально эта статья была опубликована здесь.
Задача
Реализация микросервиса, отвечающего за работу с геоданными:
Хранение полигонов зон доставки;
Хранение точек доставки (адресов покупателей);
Поиск вхождений точки в зоны доставки заведений;
Хранение маршрутов доставки, рассчитанных с учётом различных параметров.
Поскольку, большая часть микросервисов в проекте (часть проекта описана в кейсе Telegram App Shawarma bar & KINTO’S) написана на Go с основной реляционной СУБД PostgreSQL. Было принято решение хранить данные микросервиса также в PostgreSQL, учитывая предшествующий положительный опыт работы с его расширением PostGIS.
Был определён следующий стек технологий: Go, GORM, PostgreSQL, PostGIS.
Проблема интеграции GORM и PostGIS
Однако с самого начала было понятно что GORM не поддерживает геометрические типы данных «из коробки», поэтому было принято решение использовать сырые SQL-запросы. Это решение не позволяло раскрыть возможности GORM и значительно увеличило сложность разработки и сопровождения микросервиса.
Поиск решения в интернете не привёл к успеху. Единственное, что удалось найти — это пример реализации пользовательского типа Location
на сайте GORM и несколько библиотек, поддерживающих лишь базовые геометрические типы (Point
и в некоторых случаях Polygon
).
Пример использования SQL-запросов для работы с геоданными
Для работы с геометрическими данными приходилось использовать SQL-запросы. Например, для получения полигона:
SELECT
p.id,
p.address_id,
ST_AsText(p.geo_polygon) as geo_polygon,
FROM public.polygons p
WHERE p.id = $1
Поле geo_polygon содержит полигон, с помощью функции ST_AsText
преобразуется в текстовый формат wkt.
Пример строки WKT, которая может содержаться в поле geo_polygon:
POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))
Затем этот текст нужно преобразовать в структуру для работы с полигоном внутри приложения.
Для создания таблиц с геометрическими типами данных (миграции) также приходилось писать SQL-запросы:
CREATE TABLE IF NOT EXISTS public.addresses (
id bigserial,
address text NULL,
geo_point geometry NOT NULL,
CONSTRAINT pk_address_id PRIMARY KEY(id)
);
Основные проблемы
По сравнению с функциями которые используют возможности gorm в полном объёме, функции с SQL запросами были в 2–3 раза длиннее и соответственно менее читаемые.
Пропадает возможность использовать автоматическую миграцию gorm.
Был выбран неподходящий формат данных, так как использование WKT в разы менее производителен чем WKB, убедиться в этом помог бенчмарк, который наглядно показывает разницу в производительности при работе с форматами WKT и WKB.
Результаты бенчмарка:
Format | size | convert to | convert from | serialize to parquet | deserialize from parquet |
---|---|---|---|---|---|
wkb | 54.6 MB | 0.089s | 0.046s | 0.044s | 0.03s |
wkt | 71.6 MB | 0.44s | 0.45s | 0.38s | 0.12s |
Из результатов видно, что преобразование полигона в текстовый формат WKT для передачи в БД занимает в 5 раз больше времени, чем преобразование в бинарный формат WKB. А получения значения из базы в текстовом формате потребует в 9 раз больше времени чем данных в бинарном формате.
Решение
Для упрощения и оптимизации работы с геоданными в GORM было принято решения написать свои типы для геометрий, которые будут расширять функциональность gorm.
Реализована поддержка следующих типов:
Point
LineString
Polygon
MultiPoint
MultiLineString
MultiPolygon
GeometryCollection
Реализация интерфейсов:
sql.Scanner и driver.Valuer способствовала простому получению и записи данных.
schema.GormDataTypeInterface обеспечила правильное поведение GORM при миграции таблиц с геометрическими типами.
fmt.Stringer добавила возможность отображения данных в человекочитаемом формате WKT.
В основе решения лежит библиотека go-geom реализующая эффективные типы геометрии для геопространственных приложений, кроме того go-geom имеет поддержку неограниченного количества измерений, реализует кодирование и декодирование в формат wkb и другие форматы, функции для работы с 2D и 3D топологиями и другие особенности.
Решение является в некотором роде адаптацией go-geom для работы с GORM и получило название georm (сочетание слов «geometry» и «ORM»). Вы можете ознакомиться с решением на GitHub georm.
Примеры использования
Описание структур с геометрическими типами:
type Address struct {
ID uint `gorm:"primaryKey"`
Address string
GeoPoint georm.Point
}
type Zone struct {
ID uint `gorm:"primaryKey"`
Title string
GeoPolygon georm.Polygon
}
Простая, автоматическая миграция gorm.
db.AutoMigrate(
// CREATE TABLE "addresses" ("id" bigserial,"address" text,"geo_point" Geometry(Point, 4326),PRIMARY KEY ("id"))
Address{},
// CREATE TABLE "zones" ("id" bigserial,"title" text,"geo_polygon" Geometry(Polygon, 4326),PRIMARY KEY ("id"))
Zone{},
)
Полноценное использование возможностей ORM для запросов, передача геометрических данных в wkb формате:
// INSERT INTO "addresses" ("address","geo_point") VALUES ('some address','010100000000000000000045400000000000003840') RETURNING "id"
tx.Create(&Address{
Address: "some address",
GeoPoint: georm.Point{
Geom: geom.NewPoint(geom.XY).MustSetCoords(geom.Coord{42, 24}),
},
})
// ...
// INSERT INTO "zones" ("title","geo_polygon") VALUES ('some zone','010300000001000000050000000000000000003e4000000000000024400000000000004440000000000000444000000000000034400000000000004440000000000000244000000000000034400000000000003e400000000000002440') RETURNING "id"
tx.Create(&Zone{
Title: "some zone",
GeoPolygon: georm.Polygon{
Geom: geom.NewPolygon(geom.XY).MustSetCoords([][]geom.Coord{
{{30, 10}, {40, 40}, {20, 40}, {10, 20}, {30, 10}},
}),
},
})
// ...
// SELECT * FROM "zones" WHERE ST_Contains(geo_polygon, '0101000020e610000000000000000039400000000000003a40') ORDER BY "zones"."id" LIMIT 1
db.Model(&Zone{}).
Where("ST_Contains(geo_polygon, ?)", point).
First(&result)
// ...
Не большой бонус — реализация интерфейса fmt.Stringer, вывод в человеко читаемом wkt формате.
// POINT (25 26)
fmt.Println(georm.Point{
Geom: geom.NewPoint(geom.XY).MustSetCoords(geom.Coord{25, 26}).SetSRID(georm.SRID),
})
// POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))
fmt.Println(georm.Polygon{
Geom: geom.NewPolygon(geom.XY).MustSetCoords([][]geom.Coord{
{{30, 10}, {40, 40}, {20, 40}, {10, 20}, {30, 10}},
}),
})
Для получения дополнительной информации и примеров использования посетите репозиторий georm на GitHub.