Подружить Gorm и PostGIS, решение промышленного уровня

Подружить Gorm и PostGIS, решение промышленного уровня

Интеграция GORM и PostGIS для работы с геоданными в микросервисах на Go может быть сложной из-за отсутствия нативной поддержки геометрических типов данных в GORM. В статье описано, как обойти эту проблему с помощью собственного решения — библиотеки georm, предоставляющей поддержку таких типов, как Point, Polygon и других.

Подружить Gorm и PostGIS, решение промышленного уровня

Как подружить 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)
);

Основные проблемы

  1. По сравнению с функциями которые используют возможности gorm в полном объёме, функции с SQL запросами были в 2-3 раза длиннее и соответственно менее читаемые.
  2. Пропадает возможность использовать автоматическую миграцию gorm.
  3. Был выбран неподходящий формат данных, так как использование WKT в разы менее производителен чем WKB, убедиться в этом помог бенчмарк, который наглядно показывает разницу в производительности при работе с форматами WKT и WKB.

Результаты бенчмарка:

Formatsizeconvert toconvert fromserialize to parquetdeserialize from parquet
wkb54.6 MB0.089s0.046s0.044s0.03s
wkt71.6 MB0.44s0.45s0.38s0.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.