golang

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

  • вторник, 1 октября 2024 г. в 00:00:07
https://habr.com/ru/articles/847048/

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.

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

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.