Добрый день, уважаемые читатели Хабра. В один день для моего pet-проекта понадобилось сделать добавление адресов клиентов, и проверка входит ли этот адрес в зону доставки.
Так как статей на эту тему на хабре раз и обчелся, то вот держите еще одну 😁
В этой статье мы разберем, что такое геометрия, как с ней можно работать в sql и no-sql базах данных. Приступим.
Глава 1. Геометрия из далекого космоса
Для того, чтобы описать некий обьект или точку на карте используется такая сущность как Geometry, которая описана спецификацией OGC (Open Geospatial Consortium). По сути это базовый объект для всех географических сущностей. Каждая сущность описывает координатное пространство, в котором находится геометрический объект.
Нам из всего этого многообразия интересны лишь Point и Polygon. Можно было бы разобрать больше видов геометрии, но для решения нашей задачи нам интересны только эти. Маршруты мы не строим (это пока, потом может придем к этому, нам же нужно будет делать приложение с построением маршрута доставки для курьера, верно?).
Начнем с Point, или точка - представляет собой 0-мерный геометрический объект в пространстве, или просто одно место в координатах. Имеет значение X и Y.
Вот так этот объект представляется в GeoJson
{
"type": "Feature",
"properties": {
"name": "Точка"
},
"geometry": {
"coordinates": [-71.29611889288435, -14.408046706270259],
type": "Point"
}
}
Далее у нас по плану Polygon. Полигон — это плоская поверхность. Состоит как минимум из 4 координат. Последняя координата обязательно должна быть такой же как первая, чтобы замкнуть плоскость.
Вот так выглядит в GeoJson:
{
"type": "Feature",
"properties": {
"name": "Даже не пытайтесь"
},
"geometry": {
"coordinates": [
[
[
73.53858903225691,
55.0618396333995
],
[
73.18414465774254,
55.06397418032029
],
[
73.18785994493194,
54.891434295145984
],
[
73.53857157115723,
54.88824517863438
],
[
73.53858903225691,
55.0618396333995
]
]
],
"type": "Polygon"
}
}
Для заметки, что же такое этот GeoJson.
The GeoJSON Specification (RFC 7946).
GeoJSON - это формат для кодирования различных структур географических данных.
GeoJSON поддерживает следующие типы геометрии: Point, LineString, Polygon, MultiPoint, MultiLineString и MultiPolygon. Геометрические объекты с дополнительными свойствами являются объектами Feature. Наборы объектов содержатся в объектах FeatureCollection.
В 2015 году Рабочая группа по проектированию Интернета (IETF) совместно с авторами оригинальной спецификации сформировала GeoJSON WG для стандартизации GeoJSON. RFC 7946 был опубликован в августе 2016 года и является новой стандартной спецификацией формата GeoJSON, заменившей спецификацию GeoJSON 2008 года.
Глава 2. Разложим объекты по полочкам
Теперь как вы узнали необходимый минимум информации приступим к работе. Создадим новый проект Spring boot.
Добавим необходимые зависимости:
Lombok
Spring Web
Spring Data JPA
PostgreSQL Driver
Flyway Migration
Теперь подготовим необходимую нам базу данных PostgreSQL с уже установленной надстройкой для работы с гео-данными.
docker-compose.yml
version: '3.8'
name: "geo-spring"
services:
postgres:
image: postgis/postgis:15-3.4-alpine
container_name: postgres_geo_spring
restart: unless-stopped
environment:
PGUSER: PGCL_HABR
POSTGRES_USER: PGCL_HABR
POSTGRES_PASSWORD: PGCL_VERYSECURE
POSTGRES_DB: GEO_DB
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5454:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 10
command: >
postgres
-c shared_buffers=256MB
-c effective_cache_size=512MB
-c maintenance_work_mem=128MB
-c checkpoint_completion_target=0.7
-c wal_buffers=8MB
-c random_page_cost=2
-c effective_io_concurrency=1
-c work_mem=8192kB
networks:
- local
volumes:
cache:
pgdata:
driver: local
networks:
local:
driver: bridge
Запущенный контейнер занимает 65 MB, будьте готовы 😊
Подготовим теперь конфигурацию для нашего приложения.
application.yml
spring:
application:
name: geo-spring-boot
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5454/GEO_DB}
username: ${POSTGRES_USER:PGCL_HABR}
password: ${POSTGRES_PASSWORD:PGCL_VERYSECURE}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: true
flyway:
user: ${POSTGRES_USER:PGCL_HABR}
password: ${POSTGRES_PASSWORD:PGCL_VERYSECURE}
default-schema: public
enabled: true
Добавим так же свагер, чтобы не пользоваться сторонними утилитами для работы с нашим приложением.
<dependency>
<!-- swagger -->
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
А так же чтобы контроллер понимал какие объекты мы ему передаем.
<dependency>
<groupId>org.n52.jackson</groupId>
<artifactId>jackson-datatype-jts</artifactId>
<version>1.2.10</version>
</dependency>
Еще не забудем добавить расширение для Hibernate, чтобы он понимал наши запросы.
<dependency>
<!-- support postgis (need for jpql queries) -->
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
Наш финальный файл pom.xml
Hidden text
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/>
</parent>
<groupId>com.habr.egribanov</groupId>
<artifactId>geometry</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>geometry</name>
<properties>
<java.version>17</java.version>
<swagger.version>2.3.0</swagger.version>
<jackson-datatype-jts.version>1.2.10</jackson-datatype-jts.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<!-- support postgis (need for jpql queries) -->
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${swagger.version}</version>
</dependency>
<dependency>
<groupId>org.n52.jackson</groupId>
<artifactId>jackson-datatype-jts</artifactId>
<version>${jackson-datatype-jts.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
Для начала подготовим наш контроллер для загрузки в него GeoJson, для этого нам понадобятся три сущности:
1. Наш запрос на создание зоны доставки.
@Schema(description = "Создание зоны доставки, формат GeoJson")
public record CreateZonesRequest(
@JsonProperty("type")
@Schema(description = "Тип, по умолчанию FeatureCollection", example = "FeatureCollection")
String type,
@JsonProperty("features")
@Schema(description = "Список зон")
List<GeoJsonFeatureDto> features
) {
}
2. Сама зона доставки
В данном объекте интересно то, что используются GeometryDeserializer и GeometrySerializer чтобы мы корректно работали с новым типом геометрии.
import org.locationtech.jts.geom.Geometry;
import org.n52.jackson.datatype.jts.GeometryDeserializer;
import org.n52.jackson.datatype.jts.GeometrySerializer;
@Schema(description = "Зона доставки")
public record GeoJsonFeatureDto(
@JsonProperty("type")
@Schema(description = "Тип, по умолчанию Feature", example = "Feature")
String type,
@JsonProperty("properties")
@Schema(description = "Параметры зоны доставки")
GeoJsonPropertiesDto properties,
@JsonDeserialize(using = GeometryDeserializer.class)
@JsonSerialize(using = GeometrySerializer.class)
@JsonProperty("geometry")
@Schema(description = "Координаты, объект GEOMETRY(Polygon, 4326)")
Geometry geometry
) {
}
3. Наши параметры зоны доставки. В них мы можем указать что нам угодно, в данном случае это город, район, цена доставки в эту зону и цвет зоны, чтобы сохранить красивый вид, если мы будем передавать нашу зону доставки в какой-нибудь редактор в будущем.
@Schema(description = "Параметры зоны доставки")
public record GeoJsonPropertiesDto(
@JsonProperty("city")
@Schema(description = "Город", example = "Волгоград")
String city,
@JsonProperty("district")
@Schema(description = "Район", example = "Центральный район")
String district,
@JsonProperty("price_rub")
@Schema(description = "Цена в рублях", example = "100")
BigDecimal price,
@JsonProperty("fill")
@Schema(description = "Цвет зоны", example = "#37ab0d")
String fill
) {
}
Так же дополнительно создадим сущности для еще одного нашего контроллера, который будет работать уже с координатами:
1. Координаты точки
При этом учитываем, что для геодезических координат X - это долгота, а Y - широта
@Schema(description = "Координаты")
public record LocationPointRequest(
@JsonProperty("latitudeY")
@Schema(description = "Широта", example = "48.716496")
Float latitudeY,
@JsonProperty("longitudeX")
@Schema(description = "Долгота", example = "44.530353")
Float longitudeX
) {
}
2. Условия доставки в эту зону
@Schema(description = "Условия доставки")
public record DeliveryTermsResponse(
@Schema(description = "Город")
@JsonProperty("city")
String city,
@Schema(description = "Район")
@JsonProperty("district")
String district,
@Schema(description = "Цена в рублях")
@JsonProperty("price_rub")
BigDecimal price
) {
}
Теперь сформируем наш контроллер
@RestController
@RequiredArgsConstructor
@Tag(name="Зоны доставки")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Зоны добавлены / Адрес в зоне доставки"),
@ApiResponse(responseCode = "406", description = "Адрес вне зоны доставки")
})
class DeliveryController {
public static final String SAVE_DELIVERY_LOCATION_URL = "/v1/location";
public static final String DELIVERY_LOCATION_TERM_URL = "/v1/location-term";
public static final String DELIVERY_LOCATION_CHECK_URL = "/v1/location-check";
private final GeoService geoService;
@Operation(summary = "Добавить зоны доставки в формате GeoJson")
@PostMapping(SAVE_DELIVERY_LOCATION_URL)
@ResponseStatus(HttpStatus.OK)
public void saveAllDeliveryZones(@RequestBody CreateZonesRequest geoJson) {
geoService.saveAllDeliveryZones(geoJson);
}
@Operation(summary = "Получить условия доставки в эту зону")
@PostMapping(DELIVERY_LOCATION_TERM_URL)
@ResponseStatus(HttpStatus.OK)
public DeliveryTermsResponse getDeliveryTerms(@RequestBody LocationPointRequest request) {
return geoService.getDeliveryTerms(request);
}
@Operation(summary = "Находится ли в зоне доставки")
@PostMapping(DELIVERY_LOCATION_CHECK_URL)
@ResponseStatus(HttpStatus.OK)
public void inDeliveryZone(@RequestBody LocationPointRequest request) {
geoService.inDeliveryZone(request);
}
}
Теперь же самое интересное, это как же работать с базой данных
@Repository
public interface DeliveryLocationRepository extends JpaRepository<DeliveryLocation, UUID> {
@Query("""
SELECT COUNT(loc) > 0 FROM DeliveryLocation loc
WHERE ST_Contains(loc.polygon, ST_SetSRID(ST_MakePoint(:x, :y), 4326))
""")
boolean existsLocationContainingPoint(@Param("x") double longitudeX, @Param("y") double latitudeY);
@Query("""
SELECT loc FROM DeliveryLocation loc
WHERE ST_Contains(loc.polygon, ST_SetSRID(ST_MakePoint(:x, :y), 4326))
""")
Optional<DeliveryLocation> findLocationByCoordinates(@Param("x") double longitudeX, @Param("y") double latitudeY);
}
Для этого нам нужно написать гео-запросы к нашей базе данных. В этом запросе мы используем JPQL, а за понимание его отвечает добавленное ранее расширение hibernate-spatial.
В этом запросе мы используем функцию ST_Contains:
boolean ST_Contains(geometry geomA, geometry geomB);
Которая возвращает true, если геометрия A находится внутри геометрии B.
Далее мы первым аргументом указываем наш полигон из таблицы DeliveryLocation, а вторым аргументом вызываем функцию, которая создаст нам геометрию типа Point по нашим координатам.
Так же вторым аргументом уже этой функции мы передает цифры, это так называемое значение EPSG:4326 (идентификатор системы пространственной привязки для данного геометрического объекта). Для большинства стран используется SRID=4326, но также существует и SRID=4269 для Северной Америки и Канады.
Теперь опишем наши сервис, который будет сохранять наши зоны доставки и проверять, находится ли точка в ней, а также условия доставки для этой зоны. Проверять что эти зоны уже добавлены или как-то валидировать входные данные не будем, чтобы не усложнять.
@Service
@RequiredArgsConstructor
public class DeliveryGeoService implements GeoService {
private final DeliveryLocationRepository locationRepository;
private final DeliveryLocationToTermsMapper locationToTermsMapper;
public void saveAllDeliveryZones(CreateZonesRequest geoJson) {
var zones = geoJson.features().stream()
.map(feature -> DeliveryLocation.builder()
.city(feature.properties().city())
.district(feature.properties().district())
.price(feature.properties().price())
.fill(feature.properties().fill())
.polygon((Polygon) feature.geometry())
.build())
.toList();
locationRepository.saveAll(zones);
}
public DeliveryTermsResponse getDeliveryTerms(LocationPointRequest request) {
var location = locationRepository.findLocationByCoordinates(
request.longitudeX(), request.latitudeY()
).orElseThrow(() -> new RestException(Message.ADDRESS_OUT_OF_DELIVERY_ZONE));
return locationToTermsMapper.toResponse(location);
}
@Override
public void inDeliveryZone(LocationPointRequest request) {
var isDeliverable = locationRepository.existsLocationContainingPoint(
request.longitudeX(), request.latitudeY()
);
if (!isDeliverable) throw new RestException(Message.ADDRESS_OUT_OF_DELIVERY_ZONE);
}
}
Так же не забудем написать миграцию для нашей таблицы в БД.
-- Migration to create delivery_location table
-- Author: EGribanov
-- Date: 2024-07-24
-- Service: geo
create table if not exists delivery_location
(
id UUID NOT NULL PRIMARY KEY UNIQUE,
version BIGINT NOT NULL,
city VARCHAR(50) NOT NULL,
district VARCHAR(50) NOT NULL,
price_rub NUMERIC(10, 2) NOT NULL,
fill VARCHAR(10),
polygon GEOMETRY(Polygon, 4326),
created_date TIMESTAMP(6) NOT NULL,
last_modified_date TIMESTAMP(6)
);
CREATE INDEX polygons_geom_idx ON delivery_location USING GIST (polygon);
Глава 3. Где же взять эти зоны
Как ты уже понял, чтобы это все работало, нам все-таки надо где-то взять эти самые зоны доставки. Для этого мы воспользуемся сервисом geojson.io и начертим наши зоны доставки на карте. Вот тут нам и понадобилось поле fill, чтобы все красиво выглядело.
Сразу можем добавить наши параметры для каждой зоны (и потратить на это кучу времени)
Хорошо, теперь у нас есть наш geoJson. Осталось дело за малым, откроем наш свагер (если не забыли, то он имеет следующий адрес localhost:8080/swagger-ui/index.html
)
Отправим наш GeoJson через Swagger и смотрим что у нас в базе данных.
Вот что сгенерировал hibernate:
insert into delivery_location
(city,created_date,district,fill,last_modified_date,polygon,price_rub,version,id)
values (?,?,?,?,?,?,?,?,?)
Здесь для каждой зоны мы видим все наши данные, цена, цвет, но самое интересное это столбец polygon. В нем мы видим SRID=4326 и наш обьект POLYGON с координатами.
Данные сохранились, отлично. Теперь представим, что пользователь хочет добавить адрес доставки, а мы должны знать, что точно сможем доставить по этому адресу.
Такие моменты как геокодинг (получение координат по введенному адресу) опустим за скобки. Представим, что у нас уже есть координаты нужного нам адреса и попробуем проверить, входит ли этот адрес в зону доставки.
Для этого выполним запрос на /v1/location-check
Получим в ответ HTTP 200 OK, значит все хорошо.
Посмотрим на получившийся запрос:
select count(dl1_0.id) > 0
from delivery_location dl1_0
where st_contains(dl1_0.polygon,st_setsrid(st_makepoint(?,?),4326))
Теперь попробуем проверить, что будет, если ввести координаты соседнего города, он явно не в зоне доставки.
Получим ошибку HTTP 406 как и ожидали.
Хорошо, убедились, что все работает. Теперь попробуем получить условия доставки.
Вот что получили от приложения:
select dl1_0.id,dl1_0.city,dl1_0.created_date,dl1_0.district,dl1_0.fill,dl1_0.last_modified_date,dl1_0.polygon,dl1_0.price_rub,dl1_0.version
from delivery_location dl1_0
where st_contains(dl1_0.polygon,st_setsrid(st_makepoint(?,?),4326))
Как видим обьект мы получили, а большего нам и не надо.
Пару слов в заключении
Вот таким вот несложным способом мы можем работать с геометрией с использованием JPA и Spring Boot 3. На основе этой базы можно уже делать более сложные задачи, например, направлять заказ в ближайший к пользователю ресторан.
Аналогичным образом можно делать такие же гео-запросы на основе NoSQL баз данных, например, такой, как MongoDB. А если сервис нужен только для вот таких вот целей и ничего более, то можно использовать H2GIS, написать миграции и при каждом запуске приложения в памяти уже будут эти зоны.
Посмотреть исходный код можно на GitHub (и поставить звездочку)