Добрый день, уважаемые читатели Хабра. В один день для моего pet-проекта понадобилось сделать добавление адресов клиентов, и проверка входит ли этот адрес в зону доставки. 

Так как статей на эту тему на хабре раз и обчелся, то вот держите еще одну ?

В этой статье мы разберем, что такое геометрия, как с ней можно работать в sql и no-sql базах данных. Приступим.

Глава 1. Геометрия из далекого космоса

Для того, чтобы описать некий обьект или точку на карте используется такая сущность как Geometry, которая описана спецификацией OGC (Open Geospatial Consortium). По сути это базовый объект для всех географических сущностей. Каждая сущность описывает координатное пространство, в котором находится геометрический объект.

Все виды геометрических объектов из стандарта OGC 06-103r4 за 2010 год
Все виды геометрических объектов из стандарта OGC 06-103r4 за 2010 год

Нам из всего этого многообразия интересны лишь 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.

Spring Initializr
Spring Initializr

Добавим необходимые зависимости:

  • 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)

Хорошо, что позаботились об этом заранее и не будем составлять json для отправки руками
Хорошо, что позаботились об этом заранее и не будем составлять json для отправки руками

Отправим наш GeoJson через Swagger и смотрим что у нас в базе данных.

TablePlus, если заинтересовала программа
TablePlus, если заинтересовала программа

Вот что сгенерировал 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 (и поставить звездочку)

Комментарии (5)


  1. egribanov Автор
    25.07.2024 19:13
    +3

    Опростоволосился и не написал как работать в nosql, ну это на следующий раз)


  1. popov654
    25.07.2024 19:13

    Очень раздражают эти стримы в коде, если честно. Трудно читается. Или я просто слишком консервативен?)


    1. varenkine
      25.07.2024 19:13

      А кем вы работаете? )


  1. filippov70
    25.07.2024 19:13

    Я ожидал что-то типа GeoTools тут будет, исходя из названия, а тут GeoJSON )


  1. detalby
    25.07.2024 19:13

    Ошибки принято выбрасывать если это нарушает бизнес логику, Что-то вышло за рамки. Здесь же просто должен ответ, что вне зоны.