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

В этой статье мы представим базовое веб-приложение, которое демонстрирует возможности индексирования и поиска Elasticsearch с использованием Spring Data Elasticsearch. В конце статьи вы найдете ссылку на репозиторий GitHub, содержащий функциональный код для справки.

Давайте начнем! 💪

Хранение данных в Elasticsearch

Документы

Данные в Elasticsearch хранятся в виде документов. Документ — это объект JSON, представляющий единую единицу информации, такую ​​как продукт, клиент или любой другой объект.

Индекс

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

Поля

Документы содержат поля, которые представляют определенные атрибуты или свойства представляемого объекта. Каждое поле имеет имя и соответствующее значение, которое содержит данные.

Для получения дополнительной информации о хранении данных в Elasticsearch вы можете перейти по этой ссылке: индексы документов

Настроить эластичный поиск

Для продолжения давайте настроим и запустим Elasticsearch. Существуют различные способы выполнения этой задачи, вы можете обратиться к официальной документации по настройке Elasticsearch здесь: Настройка Elasticsearch

Для простоты в этой статье мы будем устанавливать Elasticsearch с помощью Docker. Следуйте приведенной ниже команде, чтобы инициировать кластер с одним узлом с помощью Docker.

docker run -p 9200:9200 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  docker.elastic.co/elasticsearch/elasticsearch:8.8.1

Elasticsearch 8 поставляется с SSL/TLS включенным по умолчанию, я отключил безопасность с помощью переменной среды xpack.security.enabled=false. Если безопасность остается включенной, для настройки клиента Elasticsearch потребуется установить правильное соединение SSL. Я поставлю это задание в качестве домашнего задания для вас, просто чтобы вам было интересно! 😆

При переходе по этому URL-адресу http://localhost:9200/ результат должен быть таким, как показано ниже.

{
  "name": "992e6b8bf7a5",
  "cluster_name": "docker-cluster",
  "cluster_uuid": "RxXotwWrTd2lzQBJmQ5gqA",
  "version": {
    "number": "8.8.1",
    "build_flavor": "default",
    "build_type": "docker",
    "build_hash": "f8edfccba429b6477927a7c1ce1bc6729521305e",
    "build_date": "2023-06-05T21:32:25.188464208Z",
    "build_snapshot": false,
    "lucene_version": "9.6.0",
    "minimum_wire_compatibility_version": "7.17.0",
    "minimum_index_compatibility_version": "7.0.0"
  },
  "tagline": "You Know, for Search"
}

Демонстрация приложения

Наше приложение имеет 3 API.

  1. Поиск предметов по названию
  2. Поиск товаров по категориям
  3. Поиск товаров по ценовому диапазону

Создайте наше приложение как проект Spring Boot с зависимостями, указанными в файле POM ниже. Я назвал его elasticsearch.

https://github.com/buingoctruong/springboot3-elasticsearch8/blob/master/pom.xml

Конфигурация клиента Elasticsearch

Spring Data Elasticsearch работает с клиентом Elasticsearch (предоставляемым клиентскими библиотеками Elasticsearch), который подключен к одному узлу Elasticsearch или кластеру. В этой статье мы установим соединение с Elasticsearch с помощью императивного (нереактивного) клиента.

@Configuration
@EnableElasticsearchRepositories(basePackages = "github.io.truongbn.elasticsearch.repository")
public class ClientConfig extends ElasticsearchConfiguration {
    @Override
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
                .connectedTo("localhost:9200").build();
    }
}

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

Отображение объектов

Сопоставление объектов Spring Data Elasticsearch — это процесс, который сопоставляет объект Java с представлением JSON, хранящимся в Elasticsearch, и обратно.

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

@Data
@Document(indexName = "itemindex")
public class Item {
    @Id
    private int id;
    @Field(type = FieldType.Text, name = "name")
    private String name;
    @Field(type = FieldType.Double, name = "price")
    private Double price;
    @Field(type = FieldType.Keyword, name = "brand")
    private String brand;
    @Field(type = FieldType.Keyword, name = "category")
    private String category;
}
  • @Document: указывает, что этот класс является кандидатом на сохранение в индексе с именем «itemindex».
  • @Id: аннотированное поле обеспечивает уникальность документа в индексе.
  • @Field: определяет свойства поля (имя, тип, формат и т. д.).

Дополнительные сведения о сопоставлении объектов можно найти по этой ссылке: Сопоставление объектов Elasticsearch

Манипуляция данными

Spring Data Elasticsearch предлагает два подхода к доступу к данным и управлению ими: Репозитории Elasticsearch и Операции Elasticsearch. В нашем приложении мы сосредоточимся на использовании подхода Spring Data Repository. Однако для целей этой статьи мы рассмотрим каждый метод отдельно, чтобы понять, как Elasticsearch их обрабатывает.

Elasticsearch Репозитории

Используя подход репозиториев, запросы Elasticsearch строятся на основе имен методов. Давайте сначала создадим наш интерфейс репозитория, расширив ElasticsearchRepository.

public interface ItemRepository 
        extends ElasticsearchRepository<Item, Integer> {
}

Текущий репозиторий интерфейса наследует различные методы от ElasticsearchRepository, включая save(), saveAll(), findAll() и т. д. Эти унаследованные методы можно легко использовать в нашем приложении.

Внедрение 3 дополнительных методов для удовлетворения наших требований к поиску.

public interface ItemRepository
         extends ElasticsearchRepository<Item, Integer> {
    List<Item> findByName(String name);

    List<Item> findByCategory(String category);

    List<Item> findByPriceBetween(Double low, Double high);
}

Как упоминалось ранее, запросы Elasticsearch генерируются на основе имен методов. Например, метод с именем findByPriceBetween будет преобразован в следующий запрос Elasticsearch JSON.

{
  "query": {
    "bool": {
      "must": [
        { "range": { "price": { "from": ?, "to": ?,  "include_lower": true, "include_upper": true } } }
      ]
    }
  }
}

Существует список шаблонов именования методов для Elasticsearch, вы можете найти его здесь: Создание запроса

Операции Elasticsearch

Elasticsearch Operations предоставляет широкий набор операционных интерфейсов для взаимодействия с Elasticsearch, включая операции CRUD, управление индексами и т. д.

  • IndexOperations: определяет действия на уровне индекса, такие как создание и удаление.
  • DocumentOperations: определяет действия для конкретных объектов для хранения, обновления и извлечения объектов на основе их идентификаторов.
  • SearchOperations: определяет действия на уровне объекта, включая поиск нескольких объектов с помощью запросов.
  • ElasticsearchOperations: объединяет интерфейсы DocumentOperations и SearchOperations.

Чтобы лучше понять их использование, давайте рассмотрим следующий пример с ElasticsearchOperations.

@Service
@RequiredArgsConstructor
public class ItemService {
    // Injecting ElasticsearchOperations Bean
    private final ElasticsearchOperations elasticsearchOperations;
    /**
     * Persist the individual item entity in the Elasticsearch cluster
     */
    public int saveIndex(Item item) {
        Item itemEntity = elasticsearchOperations.save(item);
        return itemEntity.getId();
    }

    /**
     * Bulk-save the items in the Elasticsearch cluster
     */
    public List<Integer> saveIndexBulk(List<Item> itemList) {
        List<Integer> itemIds = new ArrayList<>();
        elasticsearchOperations.save(itemList).forEach(item -> itemIds.add(item.getId()));
        return itemIds;
    }

    /**
     * Remove a single item from the Elasticsearch cluster
     */
    public String findByCategory(Item item) {
        return elasticsearchOperations.delete(item);
    }
}

Для получения более подробной информации см. официальную документацию Elasticsearch Operations здесь: Elasticsearch Operations

Кроме того, Elasticsearch Operations имеет интерфейс запросов, который предлагает мощные возможности для создания и выполнения различных типов поисковых запросов, применения фильтров, сортировки, разбивки на страницы и агрегирования. Это позволяет вам создавать сложную логику поиска, используя запросы Elasticsearch DSL (Domain-Specific Language) или запросы на основе JSON.

Критерии запроса

CriteriaQuery позволяет создавать поисковые запросы для данных, не зная синтаксиса или основ запросов Elasticsearch. Пользователи могут создавать запросы, связывая и комбинируя объекты Criteria, где каждый объект определяет определенные критерии для поиска документов.

Рассмотрим следующий пример.

@Service
@RequiredArgsConstructor
public class ItemService {
    private final ElasticsearchOperations elasticsearchOperations;

    public SearchHits<Item> search(String name) {
        // Get all item with given name
        Criteria criteria = new Criteria("name").is(name);
        Query searchQuery = new CriteriaQuery(criteria);
        return elasticsearchOperations.search(searchQuery, Item.class);
    }
}

Для получения более подробной информации см. здесь: CriteriaQuery

Строковый запрос

Эта функция позволяет создавать запросы Elasticsearch в виде строки JSON. С помощью StringQuery вы можете писать запросы Elasticsearch, используя синтаксис запросов Elasticsearch DSL (Domain-Specific Language) непосредственно в форме строки. Он обеспечивает гибкость, когда вам нужно динамически создавать сложные запросы или когда у вас есть существующие запросы в строковом формате.

Вот пример использования StringQuery.

@Service
@RequiredArgsConstructor
public class ItemService {
    private final ElasticsearchOperations elasticsearchOperations;

    public SearchHits<Item> search(String name) {
        // Get all item with given name
        Query query = new StringQuery(
                "{ \"match\": { \"name\": { \"query\": \"" + name + " \" } } } ");
        return elasticsearchOperations.search(query, Item.class);
    }
}

Для получения более подробной информации см. здесь: StringQuery

Нативный запрос

Эта функция позволяет выполнять собственные запросы к Elasticsearch. Это обеспечивает максимальную гибкость построения запросов.

Вот пример использования NativeQuery.

@Service
@RequiredArgsConstructor
public class ItemService {
    private final ElasticsearchOperations elasticsearchOperations;

    public SearchHits<Item> search(String name) {
        // Get all item with given name
        Query query = NativeQuery.builder()
                .withQuery(q -> q.match(m -> m.field("name").query(name))).build();
        return elasticsearchOperations.search(query, Item.class);
    }
}

Для получения более подробной информации см. здесь: NativeQuery

Сервисный уровень

public interface ItemService {
    List<Item> findByName(String itemName);

    List<Item> findByCategory(String category);

    List<Item> findByPriceBetween(double low, double high);
}
@Service
@RequiredArgsConstructor
public class ItemServiceImpl implements ItemService {
    private final ItemRepository itemRepository;
    @Override
    public List<Item> findByName(String itemName) {
        return itemRepository.findByName(itemName);
    }

    @Override
    public List<Item> findByCategory(String category) {
        return itemRepository.findByCategory(category);
    }

    @Override
    public List<Item> findByPriceBetween(double low, double high) {
        return itemRepository.findByPriceBetween(low, high);
    }
}

Уровень контроллера

@RestController
@RequestMapping("/api/v1/items")
@RequiredArgsConstructor
public class ItemController {
    private final ItemService itemService;
    @GetMapping("/{name}")
    public List<Item> getItemByName(@PathVariable("name") String name) {
        return itemService.findByName(name);
    }

    @GetMapping("/category/{category}")
    public List<Item> getItemsByCategory(@PathVariable("category") String category) {
        return itemService.findByCategory(category);
    }

    @GetMapping("/prices/{low}/{high}")
    public List<Item> getItemsByPriceRange(@PathVariable("low") double low,
            @PathVariable("high") double high) {
        return itemService.findByPriceBetween(low, high);
    }
}

Настройка данных

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

@Configuration
@RequiredArgsConstructor
public class DataSetup {
    private final ItemRepository itemRepository;
    private final CSVParser csvParser;
    @PostConstruct
    public void setupData() {
        // items.csv is inside resources folder
        List<Item> itemList = csvParser.csvParser("items.csv");
        itemRepository.saveAll(itemList);
    }
}
@Service
public class CSVParser {
    public List<Item> csvParser(String filePath) {
        List<Item> itemList = new ArrayList<>();
        try {
            InputStream inputStream = getClass().getClassLoader().getResourceAsStream(filePath);
            InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
            CSVReader reader = new CSVReader(inputStreamReader);
            String[] headers = reader.readNext();
            String[] row;
            while ((row = reader.readNext()) != null) {
                Item item = new Item();
                for (int i = 0; i < headers.length; i++) {
                    String header = headers[i];
                    String value = row[i];
                    if ("id".equalsIgnoreCase(header)) {
                        item.setId(Integer.parseInt(value));
                    }
                    if ("name".equalsIgnoreCase(header)) {
                        item.setName(value);
                    }
                    if ("price".equalsIgnoreCase(header)) {
                        item.setPrice(Double.valueOf(value));
                    }
                    if ("brand".equalsIgnoreCase(header)) {
                        item.setBrand(value);
                    }
                    if ("category".equalsIgnoreCase(header)) {
                        item.setCategory(value);
                    }
                }
                itemList.add(item);
            }
        } catch (CsvValidationException | IOException e) {
            throw new RuntimeException(e);
        }
        return itemList;
    }
}

Время поиграть с Elasticserach

Теперь все готово! 😎

Чтобы запустить приложение, запустите метод ElasticsearchApplication.main(), он должен успешно работать на порту 8080.

  • Попробуйте выполнить POST: «/api/v1/items/{name}», выполните поиск по имени «Smart». вы должны получить список предметов, в названии которых содержится «Smart».

Пожалуйста, протестируйте 2 оставшихся API самостоятельно 😀

Статья получилась длинной LOL 😂 Это из-за подробных объяснений.

Мы только что изучили концепции Elasticsearch и продемонстрировали краткую демонстрацию того, как взаимодействовать с Elasticsearch 8 с помощью Spring Data Elasticsearch.

Надеюсь, вы нашли эту информацию ценной!

Готовый исходный код можно найти в этом репозитории GitHub: https://github.com/buingoctruong/springboot3-elasticsearch8/tree/master.

Не стесняйтесь делиться своими мыслями. Спасибо и до свидания!