Предисловие

Всем привет! Это моя первая статья на Хабре, и в ней я хочу рассказать о том, как мы можем интегрировать Elasticsearch в наше приложение на Spring Boot. Этот проект предназначен для ознакомления с технологиями и служит скорее шпаргалкой/пособием или же фундаментом для дальнейшего погружения в тему.

Введение

В эру огромных объемов данных осуществление эффективного поиска является ключевым фактором для успешного бизнеса. К счастью, разработчикам сегодня доступны инструменты, которые могут справиться с такими задачами, одним из которых является Elasticsearch.

Elasticsearch - это мощный и гибкий open-source инструмент, который позволяет создавать быстрые и масштабируемые системы для поиска и анализа данных. Он представляет собой распределенную поисковую и аналитическую систему, которая может интегрироваться с многими современными технологиями.

В этой статье мы рассмотрим, как интегрировать Elasticsearch в Spring Boot приложение для поиска пассажиров в контексте авиаперевозок. Это полезный пример, который поможет нам детально изучить, как Elasticsearch может быть использован в реальных проектах.

Будем изучать основные концепции Elasticsearch и его преимущества, а также подробно рассмотрим проект, использующий Elasticsearch для выполнения запросов на основе данных о пассажирах. Вы также узнаете, как написать код на Java для реализации функциональности поиска пассажиров с помощью Elasticsearch.

Итак, пристегните ремни, дорогие читатели, мы начинаем наше путешествие в мире Elasticsearch и Spring Boot!

Что такое Elasticsearch и для чего он необходим

Elasticsearch и его преимущества

Elasticsearch - это свободно распространяемый поисковый движок, разработанный на основе Apache Lucene. Он может использоваться для поиска, агрегирования и анализа большого объема данных. Elasticsearch способен интегрироваться с различными приложениями, используя RESTful API или различные клиентские библиотеки.

Один из ключевых принципов работы Elasticsearch заключается в том, что он разделен на несколько узлов (называемых узлами кластера), каждый из которых содержит фрагменты данных, хранящиеся в индексах. При запросе Elasticsearch отправляет запрос на все узлы кластера, а затем обрабатывает и объединяет результаты. Этот подход позволяет Elasticsearch равномерно распределять нагрузку на узлы и масштабировать систему при необходимости.

Основные преимущества Elasticsearch для поиска и анализа данных

Elasticsearch предоставляет множество преимуществ для поиска и анализа данных. Некоторые из них:

  • Масштабируемость: Elasticsearch способен эффективно масштабироваться до очень больших объемов данных.

  • Высокая скорость и производительность: Elasticsearch обеспечивает быстрый и эффективный поиск данных, что делает его подходящим для приложений с высокими нагрузками и большим объемом данных.

  • Низкая латентность: благодаря тому, что Elasticsearch распределенный и использует индексирование в реальном времени, он способен отвечать на запросы пользователя с низкой латентностью.

  • Полнотекстовый поиск: Elasticsearch предоставляет интеллектуальный полнотекстовый поиск, который позволяет быстро и эффективно находить соответствия в документах.

  • Гибкие запросы: в Elasticsearch существует ряд мощных запросов, позволяющих быстро и удобно получать информацию из индекса.

Различия между SQL и Elasticsearch

SQL и Elasticsearch имеют различные подходы к работе с данными. SQL является декларативным языком, который используется для запросов к реляционным базам данных, в то время как Elasticsearch - это документ-ориентированная база данных с декларативным и императивным API.

Различия между SQL и Elasticsearch заключаются в том, как они взаимодействуют с данными. SQL работает с реляционными структурами данных, в то время как Elasticsearch основан на документах, содержащих много полей. Elasticsearch позволяет проводить поиск по строкам данных, а SQL - по столбцам.

Кроме того, Elasticsearch использует многие мощные функции поиска, агрегации и анализа, которые не доступны в SQL. Elasticsearch также предоставляет более низкоуровневый доступ к данным, потому что он напрямую работает с индексами, в то время как SQL запросы обрабатывают базу данных через промежуточный язык SQL.

Перейдем к примерам из реальной жизни

Вышеперечисленные преимущества могут звучать немного сухо, поэтому давайте проиллюстрируем, как конкретный пример может показать, что мы можем получить преимущества от этой технологии:

Допустим, у нас есть таблица с именами пользователей в нашей абстрактной базе данных. Наша социальная сеть требует поиска друзей пользователей.

Давайте найдем нашего друга Александра Александрова. Похоже, что это довольно просто, верно?

select * from profile where full_name ='Александр Александров'

А теперь представим что мы крайне популярный пользователь социальной сети и мы случайно забыли фамилию нашего нашего друга (с кем не бывает)

select * from profile where full_name like 'Александр %'

Окей,все еще не так уж и плохо. Александра мы, возможно, найдем

Но что, если искомый Александр переехал, скажем, в Америку, и его имя написано в другой транскрипции или он решил переименоваться в "Саню" - как нам теперь найти его? Какова будет скорость поиска?

Александр, как тебя найти?
Александр, как тебя найти?

Конечно, вы можете быть гениальным разработчиком SQL или иметь умение щелкать алгоритмы текстового поиска, как семечки (а если у вас это получается, то вы большой молодец!), но использование инструментов, которые могут решить эти проблемы за вас, гораздо более экономически выгодно для продукта.

И здесь Elasticsearch приходит на помощь!

Чтобы найти друга Александра в такой ситуации, мы можем использовать мощные возможности Elasticsearch для поиска по неполному соответствию вводимых данных. В данном случае, мы могли бы создать индекс в Elasticsearch, содержащий информацию об именах друзей нашей абстрактной социальной сети, и использовать поисковый запрос для поиска друга Александра не только по полному соответствию имени, но и по частичному совпадению, транслитерации, креативным вариантам написания и т.д.

Если использовать Elasticsearch, мы также можем использовать фильтры, чтобы ограничить результаты поиска до тех пользователей, которые могут быть потенциальными друзьями или искомым другом. Все это позволит нам эффективно искать друзей, несмотря на неполноту или ошибки в вводе данных, что может очень сильно помочь пользователям нашей социальной сети.

Как говорится, "А ну, Александры, давайте сюда!"

### нашли Александров
GET profile/_search
{
  "query": {
    "match": {
      "name": "Александр"
    }
  }
}

"Бам!"

"А ну вылазьте, Сани"

### нашли Сань
### В этом запросе мы используем оператор "fuzzy", 
### который позволяет выполнить нечеткий поиск. 
### Значение "fuzziness" определяет, насколько сильно мы можем изменять запрос, 
### чтобы выполнить поиск по похожим словам.
GET profile/_search
{
  "query": {
    "fuzzy": {
      "name": {
        "value": "Александр",
        "fuzziness": 2
      }
    }
  }
}

"Бам!"

"Второй"

### создали фонетические фильтры для поиска с разной транскрипцией для поиска Alexandr`ов

"analyzer": {
  "my_analyzer": {
    "tokenizer": "standard",
    "filter": [
      "standard",
      "my_phonetic_english",
      "my_phonetic_cyrillic"
    ]
  }
},
"filter": {
  "my_phonetic_cyrillic": {
    "type": "phonetic",
    "encoder": "beider_morse",
    "rule_type" : "approx",
    "name_type" : "generic",
    "languageset" : ["cyrillic"]
  },
  "my_phonetic_english": {
    "type": "phonetic",
    "encoder": "beider_morse",
    "rule_type" : "approx",
    "name_type" : "generic",
    "languageset" : ["english"]
  }

"Бам!"

Выглядит как настоящее приключение, не правда ли? Давайте узнаем больше о том, как использовать Elasticsearch и Spring Boot для разработки решений в следующих разделах статьи.

Приступим к коду

Давайте немного отвлечемся от поиска Александров и перейдем непосредственно к реализации нашего проекта. Я возьму за основу следующую бизнес-модель - поиск пассажиров в контексте авиаперевозок.

Наша задача:

  • Подключиться к основной базе продукта и актуализировать базу Elasticsearch на ее основе.

  • Интегрировать Elasticsearch с Spring Boot.

  • Разработать API (REST, UI) для поиска пассажиров по имени.

Задач не так уж много, но выполнение их поможет нам научиться взаимодействовать с Elasticsearch.

В проекте будут использоваться следующие технологии:

  • Java 11

  • Spring Boot 2.3.6

  • Elasticsearch 7.10.1

  • PostgreSQL 12.16

  • Kibana 7.10.1

  • Logstash-OSS 7.9.1

  • Maven

  • Docker

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

Настройка окружения

Интегрировать наше Spring Boot приложение с Elasticsearch не получится без самого Elasticsearch (логично). Поэтому получим мы его в видео образа с помощью Docker, а настроим все необходимое с помощью docker-compose:

version: '3.3'
services:
  db:
    image: postgres:12.16
    container_name: postgres
    ports:
      - "5433:5432"
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=demo
      - config.support_escapes=true
    volumes:
      - ./pg_data:/var/lib/postgresql/data
      - ./dump:/docker-entrypoint-initdb.d
  odfe-node:
    image: elasticsearch:7.10.1
    logging:
      driver: "json-file"
      options:
        max-size: "1000m"
        max-file: "10"
    container_name: odfe-node
    environment:
      - discovery.type=single-node
      - node.name=odfe-node
      - discovery.seed_hosts=odfe-node
      - bootstrap.memory_lock=true
      - xpack.security.enabled=false
      - "ES_JAVA_OPTS=-Xms4096m -Xmx4096m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
      nofile:
        soft: 65536
        hard: 65536
    volumes:
      - ./elasticsearch_data:/usr/share/elasticsearch/data
    ports:
      - "9200:9200"
      - "9600:9600"
    networks:
      - odfe-net
  kibana:
      image: kibana:7.10.1
      logging:
        driver: "json-file"
        options:
          max-size: "100m"
          max-file: "3"
      container_name: odfe-kibana
      ports:
        - "5601:5601"
      expose:
        - "5601"
      environment:
        ELASTICSEARCH_URL: http://odfe-node:9200
        ELASTICSEARCH_HOSTS: http://odfe-node:9200
      networks:
        - odfe-net
  logstash:
    user: root
    image: docker.elastic.co/logstash/logstash-oss:7.9.1
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "3"
    ports:
      - "5044:5044"
    depends_on:
      - db
      - odfe-node
    environment:
      - PIPELINE_WORKERS=1
      - PIPELINE_BATCH_SIZE=125
      - PIPELINE_BATCH_DELAY=50
    volumes:
      - ./conf/logstash.conf:/usr/share/logstash/pipeline/logstash.conf
      - ./logstash_data:/usr/share/logstash/data
      - ./conf/postgresql-42.6.0.jar:/usr/share/logstash/postgresql-42.6.0.jar
    networks:
      - odfe-net
networks:
  odfe-net:
volumes:
  odfe-data:
  db:
  logstash:

Круто? Круто! А что здесь вообще происходит?

Давайте кратко:

docker-compose.yml файл описывает среду, необходимую для запуска приложения.

Среда включает в себя:

  • PostgreSQL контейнер для хранения данных

  • Elasticsearch контейнер для поиска и хранения данных

  • Kibana контейнер для визуализации Elasticsearch данных

  • Logstash контейнер для сбора, обработки и отправки данных в Elasticsearch (в нашем конкретном примере мы используем данный инструмент для актуализации индекса Elasticsearch нашими данными из PostgreSQL)

Описание сервисов

PostgreSQL

  • image: postgres:12.16 - используемый образ для контейнера PostgreSQL

  • container_name: postgres - имя контейнера

  • ports: - "5433:5432" - прокси-порт, который привязывает порт на хостовой машине к порту внутри контейнера

  • environment - переменные окружения, необходимые для настройки контейнера

  • volumes - подключенные директории на хостовой машине

    • /pg_data - директория для хранения данных PostgreSQL

    • /dump - директория, которая используется для инициализации базы данных при запуске контейнера

Elasticsearch

  • image: elasticsearch:7.10.1 - используемый образ для контейнера Elasticsearch

  • container_name: odfe-node - имя контейнера

  • logging - настройки логгирования контейнера

  • environment - переменные окружения, необходимые для настройки контейнера

  • ulimits - ограничения ресурсов для контейнера, настройка системных ограничений на контейнер Elasticsearch

  • volumes - подключенные директории на хостовой машине

    • /elasticsearch_data - директория для хранения данных Elasticsearch

  • ports - прокси-порт, который привязывает порт на хостовой машине к порту внутри контейнера

  • networks - подключенные сети

Kibana

  • image: kibana:7.10.1 - используемый образ для контейнера Kibana

  • container_name: odfe-kibana - имя контейнера

  • logging - настройки логгирования контейнера

  • ports - прокси-порт, который привязывает порт на хостовой машине к порту внутри контейнера

  • expose - соотносит порты и IP-адреса, чтобы они стали доступными другим контейнерам

  • environment - переменные окружения, необходимые для настройки контейнера

  • networks - подключенные сети

Logstash

  • image: docker.elastic.co/logstash/logstash-oss:7.9.1 - используемый образ для контейнера Logstash

  • logging - настройки логгирования контейнера

  • ports - прокси-порт, который привязывает порт на хостовой машине к порту внутри контейнера

Отлично, с этим разобрались. Теперь двигаемся непосредственно к проекту.

Как уже ранее говорилось, Logstash нам нужен для актуализации индекса Elasticsearch нашими данными из PostgreSQL (но его возможности нам этом не заканчиваются).

input {
  jdbc {
    jdbc_driver_library => "/usr/share/logstash/postgresql-42.6.0.jar"
    jdbc_driver_class => "org.postgresql.Driver"
    jdbc_connection_string => "jdbc:postgresql://host.docker.internal:5433/demo"
    jdbc_user => "postgres"
    jdbc_password => "postgres"
    schedule => "*/10 * * * *"
    statement => "SELECT ticket_no, book_ref, passenger_id, passenger_name FROM bookings.tickets"
  }
}

output {
  stdout {
    codec => rubydebug
  }
}

output {
    elasticsearch {
        hosts => ["odfe-node:9200"]
        index => "tickets"
        doc_as_upsert => true
        action => "update"
        document_id => "%{ticket_no}"
    }
}

Этот файл конфигурации Logstash позволяет осуществлять периодический импорт данных из PostgreSQL в Elasticsearch.

В блоке "input" мы используем JDBC input plugin, который позволяет нам осуществлять чтение данных из базы данных. Мы указываем путь к драйверу JDBC, который нужен для подключения к PostgreSQL, а также указываем параметры подключения - имя базы данных, имя пользователя и пароль. Затем мы указываем расписание выполнения запроса (каждые 10 минут) и запрос на чтение данных из таблицы "bookings.tickets".

В блоке "output" мы указываем два выхода для сохранения полученных данных. Первый блок позволяет выводить полученные данные на консоль Logstash для отладки, используя codec "rubydebug". Второй блок позволяет отправлять полученные данные в Elasticsearch для дальнейшего поиска и анализа. Мы указываем хосты Elasticsearch и индекс, в который мы будем сохранять данные. Кроме того, мы используем параметры "doc_as_upsert", "action" и "document_id", чтобы Elasticsearch корректно обрабатывал документы при выполнении индексации.

Реализация приложения

Зависимости

Не забываем, что для взаимодействия нам необходимо подтянуть зависимости. В моем случае я использую maven. Перечислим основные:

Сам Spring Boot:

  <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
  </parent>

Интеграция с Elasticsearch:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
        </dependency>

Инструменты для предоставления API:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

Держим связь с Postgresql:

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

Движок шаблонов(UI):

       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

Конфигурация

@Configuration
@EnableElasticsearchRepositories(basePackages
        = "com.example.elasticsearch.repository")
@ComponentScan(basePackages = { "com.example.elasticsearch" })
public class ElasticsearchClientConfig extends
        AbstractElasticsearchConfiguration {

    @Override
    @Bean
    public RestHighLevelClient elasticsearchClient() {

        RestClientBuilder builder = RestClient.builder(
                        new HttpHost("localhost", 9200))
                .setRequestConfigCallback(
                        requestConfigBuilder -> requestConfigBuilder
                                .setConnectionRequestTimeout(0));

        return new RestHighLevelClient(builder);
    }

}

Данный класс представляет собой конфигурацию для подключения и использования Elasticsearch в приложении на Spring Boot.

Аннотация @Configuration сообщает Spring о том, что данный класс представляет конфигурации для приложения.

Аннотация @EnableElasticsearchRepositories сообщает Spring о том, что данный класс будет использоваться для настройки репозиториев Elasticsearch, в которых мы будем хранить и извлекать данные из нашего индекса.

Аннотация @ComponentScan сообщает Spring о том, какие компоненты нужно сканировать в поисках бинов. В данном случае Spring будет сканировать пакет "com.example.elasticsearch".

Класс ElasticsearchClientConfig также наследует AbstractElasticsearchConfiguration, который предоставляет базовую конфигурацию для подключения к Elasticsearch. Метод elasticsearchClient() переопределяет эту базовую конфигурацию и создает экземпляр клиента RestHighLevelClient для общения с Elasticsearch.

В методе elasticsearchClient() мы создаем объект RestClientBuilder, который используется для создания RestHighLevelClient. Мы указываем HttpHost и порт, на котором работает Elasticsearch.

В дополнение к этому, мы указываем setRequestConfigCallback, чтобы сконфигурировать тайм-аут для соединения. В данном случае мы устанавливаем его в 0, что означает, что тайм-аут для соединения не ограничен.

Маппим данные

@Getter
@Setter
@Document(indexName = "tickets")
@ToString
public class Ticket {
    @Id
    private String ticket_no;
    @Field(type = FieldType.Text, name = "book_ref")
    private String bookRef;
    @Field(type = FieldType.Text, name = "passenger_id")
    private String passengerId;
    @Field(type = FieldType.Text, name = "passenger_name")
    private String passengerName;
}

Этот класс представляет модель данных билета на авиарейс в контексте использования Elasticsearch для индексации и поиска данных.

Аннотация @Document указывает, что объекты класса Ticket будут храниться в формате документов в Elasticsearch в индексе "tickets".

Аннотация @Getter и @Setter генерируют для каждого поля класса соответствующие методы get и set, что позволяет обращаться к полям объекта через методы, а не напрямую к полям класса.

Аннотация @Id указывает, что поле "ticket_no" является уникальным идентификатором документа в Elasticsearch.

Аннотация @Field определяет свойства поля, которые будут использоваться при индексации документов в Elasticsearch. Здесь мы используем тип FieldType.Text для индексации поля в виде текста и указываем имя этого поля в Elasticsearch с помощью параметра name. Например, поле passenger_id будет индексироваться в Elasticsearch с именем passenger_id. Это позволяет нам более гибко управлять, как поля будут индексироваться в Elasticsearch.

Таким образом, этот класс Ticket представляет модель данных, которую мы будем использовать для индексации билетов в Elasticsearch. Он содержит поля для хранения информации о билете, аннотации для управления индексацией полей в Elasticsearch и методы для удобства работы с объектами этого класса.

Сервисы по поиску

В целых ознакомления я реализовал 2 простых сервиса, чья задача проста - искать пользователя по имени\фамилии. Различие сервисов состоит лишь в подходах:

TicketServiceWithRepo - используем для поиска репозитории (ElasticsearchRepository);

TicketServiceWithNativeQuery - конструируем запрос самостоятельно.

TicketServiceWithRepo

@Service
@RequiredArgsConstructor
public class TicketServiceWithRepo {

    private final TicketRepository ticketRepository;
    private final TicketMapper ticketMapper;

    public Ticket saveIndex(SaveTicketIndexRequest request){
        return ticketRepository.save(ticketMapper.ticketDtoToTicket(request.getTicketDto()));
    }

    public List<Ticket> saveBulkIndex(SaveTicketBulkIndexRequest request) {
        List<Ticket> tickets = request
                .getTickets()
                .stream()
                .map(ticketDto -> ticketMapper.ticketDtoToTicket(ticketDto))
                .collect(Collectors.toList());
        return (List<Ticket>) ticketRepository.saveAll(tickets);
    }

    public FindByNameContainingResponse findByNameContaining(String name){
        List<TicketDto> ticketDtos = ticketRepository
                        .findByPassengerNameContaining(name)
                        .stream()
                        .map(ticket -> ticketMapper.ticketToTicketDto(ticket))
                        .collect(Collectors.toList());
        return FindByNameContainingResponse
                .builder()
                .tickets(ticketDtos)
                .build();

    }
}

Поля ticketRepository и ticketMapper инициализируются через конструктор. ticketRepository это интерфейс, реализующий Spring Data Elasticsearch CRUD-операции. ticketMapper это отдельный класс, который используется для маппинга объектов между DTO и сущностями, чтобы передать их в репозиторий или вернуть в ответ на запрос от клиента.

Метод saveIndex сохраняет билет в Elasticsearch. Он принимает объект SaveTicketIndexRequest и после маппинга через ticketMapper сохраняет результат с помощью ticketRepository. Данный метод возвращает объект Ticket, который соответствует сохраненному документу в Elasticsearch.

Метод saveBulkIndex сохраняет несколько билетов в Elasticsearch одновременно. Он принимает объект SaveTicketBulkIndexRequest, извлекает список билетов, формирует список объектов Ticket при помощи маппера и сохраняет все билеты в Elasticsearch через ticketRepository. Этот метод также возвращает список объектов Ticket, которые соответствуют сохраненным документам в Elasticsearch.

Метод findByNameContaining ищет билеты в Elasticsearch по имени пассажира, содержащему данную строку name. Он обращается к репозиторию Elasticsearch, используя метод findByPassengerNameContaining, который возвращает список документов, удовлетворяющих этому условию. Затем результат преобразуется в список объектов TicketDto через маппер ticketMapper и возвращается как часть объекта типа FindByNameContainingResponse, который содержит в себе список найденных билетов.

Таким образом, класс TicketServiceWithRepo представляет собой сервисный слой приложения для выполнения операций индексации и поиска данных в Elasticsearch с использованием Spring Data Elasticsearch. Он использует Spring IoC-контейнер для инъекции зависимостей, маппер для преобразования между различными типами объектов, и репозиторий Elasticsearch для выполнения CRUD-операций.

TicketRepository

Класс репозитория, тут Spring все сделает за нас

@Repository
public interface TicketRepository extends ElasticsearchRepository<Ticket, String> {
    List<Ticket> findByPassengerNameContaining(String name);
}

TicketServiceWithNativeQuery

@Service
@RequiredArgsConstructor
public class TicketServiceWithNativeQuery {

    private static final String TICKET_INDEX = "tickets";
    private final ElasticsearchOperations elasticsearchOperations;

    public List<Ticket> processSearch(String passengerName) {
        QueryBuilder queryBuilder =
                QueryBuilders
                        .matchQuery("passenger_name", passengerName)
                        .fuzziness(Fuzziness.AUTO);

        Query searchQuery = new NativeSearchQueryBuilder()
                .withFilter(queryBuilder)
                .withPageable(PageRequest.of(0, 5))
                .build();

        SearchHits<Ticket> productHits =
                elasticsearchOperations
                        .search(searchQuery,
                                Ticket.class,
                                IndexCoordinates.of(TICKET_INDEX));
        List<Ticket> ticketMatches = new ArrayList<>();
        productHits.forEach(searchHit -> {
            ticketMatches.add(searchHit.getContent());
        });
        return ticketMatches;
    }

    public List<String> fetchSuggestions(String query) {
        QueryBuilder queryBuilder = QueryBuilders
                .wildcardQuery("passenger_name", query+"*");

        Query searchQuery = new NativeSearchQueryBuilder()
                .withFilter(queryBuilder)
                .withPageable(PageRequest.of(0, 5))
                .build();

        SearchHits<Ticket> searchSuggestions =
                elasticsearchOperations.search(searchQuery,
                        Ticket.class,
                        IndexCoordinates.of(TICKET_INDEX));

        List<String> suggestions = new ArrayList<>();

        searchSuggestions.getSearchHits().forEach(searchHit->{
            suggestions.add(searchHit.getContent().getPassengerName());
        });
        return suggestions;
    }
}

Класс TicketServiceWithNativeQuery является сервисом для работы с Elasticsearch в проекте. Он содержит два метода: processSearch и fetchSuggestions, которые выполняют поиск и предложения в индексе Elasticsearch для заданного запроса в соответствии с предоставленными параметрами.

Метод processSearch принимает параметр passengerName, который является строкой, содержащей имя пассажира. Затем метод конструирует объект QueryBuilder, который соответствует поисковому запросу Elasticsearch, с использованием matchQuery для настройки поиска с опечатками, и fuzziness для задания автоматической настройки опечаток. Затем метод инициирует запрос к Elasticsearch с использованием объекта NativeSearchQueryBuilder и передает созданный QueryBuilder в фильтр для выполнения поиска. Метод возвращает список типа Ticket, который содержит все найденные билеты.

Метод fetchSuggestions принимает параметр query, который является строкой запроса для предложений. Затем метод создает объект QueryBuilder, который соответствует поисковому запросу Elasticsearch, с использованием wildcardQuery для настройки поиска Elasticsearch с применением маски и PageRequest для выполнения запроса первых пяти результатов. Затем метод инициирует запрос к Elasticsearch с использованием объекта NativeSearchQueryBuilder и передает созданный QueryBuilder в фильтр для выполнения поиска. Метод возвращает список String, который содержит все найденные предложения.

Контроллеры

наше приложение предоставляет 2 интерфейса: UI и REST

UIController

@Controller
public class UIController {

    @GetMapping("/search")
    public String home(Model model) {
        return "search";
    }
}

тут мы отдаем по эндпоитну /search заготовленную страницу search.html

TicketController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/tickets")
public class TicketController {

    private final TicketServiceWithRepo ticketServiceWithRepo;
    private final TicketServiceWithNativeQuery ticketServiceWithNativeQuery;

    @PostMapping("/repo")
    public ResponseEntity saveIndexWithRepo(@RequestBody SaveTicketIndexRequest saveTicketIndexRequest) {
        ticketServiceWithRepo.saveIndex(saveTicketIndexRequest);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/repo/bulk")
    public ResponseEntity saveIndexWithRepo(@RequestBody SaveTicketBulkIndexRequest saveTicketBulkIndexRequest) {
        ticketServiceWithRepo.saveBulkIndex(saveTicketBulkIndexRequest);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/repo")
    public ResponseEntity<FindByNameContainingResponse> findByNameContainingWithRepo
            (@RequestParam("name") String name) {
        FindByNameContainingResponse findByNameContainingResponse =
                ticketServiceWithRepo.findByNameContaining(name);
        return ResponseEntity.ok().body(findByNameContainingResponse);
    }

    @GetMapping("/native")
    @ResponseBody
    public List<Ticket> fetchByNameOrDesc(@RequestParam(value = "q", required = false)
                                                      String query) {
        List<Ticket> tickets = ticketServiceWithNativeQuery.processSearch(query) ;
        return tickets;
    }

    @GetMapping("/native/suggestions")
    @ResponseBody
    public List<String> fetchSuggestions(@RequestParam(value = "q", required = false) String query) {
        List<String> suggests = ticketServiceWithNativeQuery.fetchSuggestions(query);
        return suggests;
    }
}

Данный класс TicketController представляет собой контроллер API, который реализует RESTful запросы HTTP для работы с объектами Ticket. Контроллер использует Spring Framework для управления запросами и ответами, а также для взаимодействия с сервисами для обработки запросов.

Аннотация @RestController описывает класс как контроллер, который работает с RESTful API, а конкретно – контроллер, который всегда возвращает ответ в формате JSON.

Аннотация @RequiredArgsConstructor сгенерирует конструктор для зависимостей класса. Это означает, что поля ticketServiceWithRepo и ticketServiceWithNativeQuery будут автоматически инициализироваться с помощью конструктора, без необходимости явно его задавать.

Аннотация @RequestMapping определяет базовый URI, который будет использоваться для обработки всех запросов в контроллере. URI в данном случае - "/api/v1/tickets".

Методы saveIndexWithRepo и saveIndexWithRepo реализуют POST-запросы в API и соответственно сохраняют индексы последовательно и в булковом режиме, используя сервисы ticketServiceWithRepo. Они принимают GET-запрос для поиска объектов Ticket, содержащих заданный текст в имени используя сервис ticketServiceWithRepo.

Методы fetchByNameOrDesc и fetchSuggestions реализуют GET-запросы в API и получают все объекты Ticket из индекса, соответствующие заданному запросу, используя сервис ticketServiceWithNativeQuery. Метод fetchSuggestions возвращает список предложений, основанных на введенном пользователем запросе.

Поехали!

Теперь, когда у нас готова инфраструктура и само приложение, мы можем запустить все элементы и посмотреть к чему пришли.

главное без пробуксовки
главное без пробуксовки

Запускаем контейнеры:

*Если вы из России, то тут может понадобиться включить vpn из-за региональных ограничений у некоторых образов

docker-compose up -d

Проверяем:

docker-compose ps

Если все отлично, то увидим:

        Name                      Command               State           Ports
----------------------------------------------------------------------------------------
elasticsearch-spring-demo_db_1      docker-entrypoint.sh postgres    Up      0.0.0.0:5433->5432/tcp
elasticsearch-spring-demo_kibana_1  /usr/local/bin/dumb-init  ...   Up      0.0.0.0:5601->5601/tcp
elasticsearch-spring-demo_logstash_1 /usr/local/bin/docker-entr ...   Up      0.0.0.0:5044->5044/tcp
elasticsearch-spring-demo_odfe-node_1 /usr/local/bin/docker-entr ...   Up      0.0.0.0:9200->9200/tcp, 0.0.0.0:9600->9600/tcp

Что происходит после запуска:

  • PostrgreSql наполняется данными из дампа

  • Logstash переносит необходимые данные в Elasticsearch

  • Kibana отображает данные, составляет графики и дашборды (если у вас есть необходимость в этом)

Проверим наполнение данных:

отображение данных в Kibana
отображение данных в Kibana

Поиграемся с диаграммами и сделаем, например, пончик:

Ура! пончик
Ура! пончик

Запускаем само приложение:

mvn spring-boot:run

Перейдем на эндпоинт UI и найдем пассажира:

Поиск пассажира с автодополнением поля
Поиск пассажира с автодополнением поля

Отлично! Теперь проверим REST API:

###our requst to add element to ticket index (repo)
POST http://localhost:8080/api/v1/tickets/repo
Content-Type: application/json

{
  "ticketDto": {
    "ticket_no": "12345",
    "bookRef": "ABC123",
    "passengerId": "P123",
    "passengerName": "John Doe"
  }
}
Джон найден!
Джон найден!

Теперь проверим поиск:

Запрос:

###our requst to get tickets by containing name (repo)
GET http://localhost:8080/api/v1/tickets/repo?name=MAKSIM
Content-Type: application/json

Ответ:

{
  "tickets": [
    {
      "ticket_no": "0005432384059",
      "bookRef": "3873F4",
      "passengerId": "0917 258591",
      "passengerName": "MAKSIM PAVLOV"
    },
    {
      "ticket_no": "0005432384107",
      "bookRef": "519855",
      "passengerId": "4591 778259",
      "passengerName": "VALENTINA MAKSIMOVA"
    },
    {
      "ticket_no": "0005432384206",
      "bookRef": "3AAC78",
      "passengerId": "2284 578245",
      "passengerName": "MAKSIM MEDVEDEV"
    },......

Как отлично, когда все Максимы на месте!

Заключение

В этой статье мы рассмотрели интеграцию Elasticsearch и Spring Boot в проектах Java в приложении для работы с данными авиарейсов.

Мы начали с разбора архитектуры Elasticsearch и его ключевых компонентов, в том числе индексов, типов документов, запросов и агрегаций.

Затем мы обсудили различные подходы к интеграции Elasticsearch и Spring Boot. Мы рассмотрели использование Spring Data Elasticsearch для выполнения CRUD-операций и Spring Elasticsearch для определения запросов в стиле Спринг и выполнения агрегационных запросов.

Мы также рассмотрели использование нативного API Elasticsearch для выполнения запросов в Spring Boot с помощью класса ElasticsearchOperations.

В качестве примера мы построили простенькое приложение на Spring Boot, которое использует Elasticsearch для индексации и поиска билетов на авиарейсы.

Надеюсь, данная статья помогла вам разобраться в интеграции Elasticsearch и Spring Boot, а также облегчит процесс разработки Java-приложений, связанных с работой с данными. Благодарю вас за прочтение до конца! Пожалуйста, оставляйте свои комментарии, пожелания и возражения - это будет большим опытом для меня!

Спасибо!
Спасибо!

С исходным кодом можно ознакомиться тут.

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


  1. artemnekrasov18
    11.10.2023 09:29
    -2

    Кайфанул, спасибо????


  1. ciplenok57
    11.10.2023 09:29

    Крутой материал, спасибо!
    Если собирать контейнеры из России, то сборка упадет на образе logstash, так как docker.elastic.co блочит подключения. Либо это локальная проблема

    Хотелось бы подобнее узнать про связку pg + elastic. Исходя из описания мы записываем в pg, потом у нас идет перенос данных в elstic. Но в примере из гита мы пишем напрямую в elastic. В целом, нужен ли тут pg, почему бы не писать данные сразу в elastic?


    1. IrlkKvch Автор
      11.10.2023 09:29

      да, подключение действительно блочит, для подгрузки можно воспользоваться vpn (забыл указать это в статье, вскоре дополню. Спасибо за замечание!)

      "Хотелось бы подобнее узнать про связку pg + elastic. Исходя из описания мы записываем в pg, потом у нас идет перенос данных в elstic. Но в примере из гита мы пишем напрямую в elastic. В целом, нужен ли тут pg, почему бы не писать данные сразу в elastic? " - в конкретном примере у нас происходит обновление данных в соответствии с pg (например , если у нас cqrs: pg - основная база, elastic - база для чтения (засовываем и компонуем туда все самое необходимое)). В примере из гита импорт данных в эластик идет из дампа и обновляется по крону - это лишь абстрактный пример для понимания принципов взаимодействия и возможностей плавной интеграции с pg


      1. ciplenok57
        11.10.2023 09:29

        Спасибо!

        Чтобы не включать VPN, можно воспользоваться образом из докерхаба
        https://hub.docker.com/r/opensearchproject/logstash-oss-with-opensearch-output-plugin


        1. DonAlPAtino
          11.10.2023 09:29

          А что надо в docker-compose поменять чтобы он завелся? Конфиг у него где-то в другом месте лежит?


          1. ciplenok57
            11.10.2023 09:29
            +1

            Можно просто склонить репу с гита и в композе поменять образ logstash на
            image: opensearchproject/logstash-oss-with-opensearch-output-plugin:latest

            В гите есть все конфиги


            1. DonAlPAtino
              11.10.2023 09:29

              Собственно не завелось. Падало с can't connect to postgress. Вот я и спросил... Скаченный через VPN logtash сработал без вопросов


          1. IrlkKvch Автор
            11.10.2023 09:29

            обновил статью и гит


            1. DonAlPAtino
              11.10.2023 09:29

              2023-10-12T12:32:44,758][ERROR][logstash.inputs.jdbc ][main] Unable to connect to database. Tried 1 times {:message=>"Java::OrgPostgresqlUtil::PSQLException: The connection attempt failed.", :exception=>Sequel::DatabaseConnectionError, :cause=>#<Java::OrgPostgresqlUtil::PSQLException: The connection attempt failed.>, :backtrace=>["org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(org/postgresql/core/v3/ConnectionFactoryImpl.java:354)", "org.postgresql.core.ConnectionFactory.openConnection(org/postgresql/core/ConnectionFactory.java:54)", "org.postgresql.jdbc.PgConnection.(org/postgresql/jdbc/PgConnection.java:263)", "org.postgresql.Driver.makeConnection(org/postgresql/Driver.java:443)", "org.postgresql.Driver.connect(org/postgresql/Driver.java:297)

              c docker.elastic.co/logstash/logstash-oss:7.9.1 все работает. Винда, docker desktop


  1. makzub
    11.10.2023 09:29
    -1

    Ну что могу сказать, автор гений.


  1. makasin4ik
    11.10.2023 09:29

    Почему не взяли мантикор?


  1. Ales911
    11.10.2023 09:29

    Thanks. @RequiredArgsConstructor из UIController наверное можно убрать


    1. IrlkKvch Автор
      11.10.2023 09:29

      Благодарю! В статье поправил