Elasticsearch w projektach Java – indeksowanie i odczyt dokumentów

W dzisiejszych czasach rynek stawia ogromne wymagania wobec projektów dotyczących wydajnego wyszukiwania i analizowania dużych zbiorów danych. Odpowiedzią na te potrzeby jest użycie Elasticsearch, który można łatwo zintegrować z projektami Java. W serii artykułów chciałbym przedstawić, jak Elasticsearch może być używany w praktyce, rozwijając prosty projekt demonstracyjny, który krok po kroku będzie korzystał z funkcji Elasticsearch.

W pierwszym artykule tej serii przedstawię, jak indeksować lub odczytywać dokumenty za pomocą Java High Level Rest Client. Jest to oficjalny klient, napisany i wspierany przez Elasticsearch.

Czym jest Elasticsearch?

Elasticsearch to open-source’owa wyszukiwarka rozwinięta w języku Java, zbudowana na bazie Apache Lucene, biblioteki wyszukiwania pełnotekstowego o wysokiej wydajności. Pozwala użytkownikom na efektywne przechowywanie, wyszukiwanie i analizowanie danych oraz zwraca odpowiedzi w formacie JSON w niemal rzeczywistym czasie. Przez lata Elasticsearch, głównie dzięki swojej wydajności, stał się najpopularniejszą wyszukiwarką. Jest powszechnie używany nie tylko przez wielkie firmy, takie jak Wikipedia, Netflix czy Stack Overflow, ale również przez małe startupy.

Elasticsearch organizuje dane wyrażone w formacie JSON, zwane dokumentami, w indeksach, które są kolekcją dokumentów tego samego typu. Każdy dokument jest identyfikowany przez unikalny ID, natomiast indeks identyfikowany jest przez swoją nazwę.

Wymagania potrzebne do uruchomienia

  1. Java 8 lub wyższa
  2. Zainstalowany i działający Elasticsearch (patrz oficjalny przewodnik użytkownika)

Konfiguracja projektu

Idea polega na opracowaniu prostego serwisu backend-for-frontend, który dostarcza dane o kierowcach Formuły 1. Serwis udostępnia REST API, dzięki któremu zindeksowane dane mogą być odświeżane lub pobierane. Na początku indeksowane i pobierane będą obiekty Driver przedstawione poniżej.

class Driver {
  String driverId;
  String code;
  String givenName;
  String familyName;
  LocalDate dateOfBirth;
  String nationality;
  boolean active;
  Integer permanentNumber;
}

Konfiguracja Maven

Gdy Elasticsearch już działa, należy dodać wymagane zależności do pliku pom.xml. W tym projekcie wybrano wersję 7.15.2.

    <elasticsearch.version>7.15.2</elasticsearch.version>

    <dependency>
        <groupid>org.elasticsearch.client</groupid>
        <artifactid>elasticsearch-rest-high-level-client</artifactid>
        <version>${elasticsearch.version}</version>
    </dependency>
    <dependency>
        <groupid>org.elasticsearch</groupid>
        <artifactid>elasticsearch</artifactid>
        <version>${elasticsearch.version}</version>
    </dependency>

Właściwości projektu

Dla wygody, niektóre właściwości związane z Elasticsearch oraz projektem zostały dodane do pliku application.yml. Zadeklarowano, że dokumenty będą przechowywane w indeksie db-drivers.

elasticsearch:
host: localhost
port: 9200
index:
name:
drivers: "db-drivers"

Java API

Istnieje wiele sposobów komunikacji z Elasticsearch. Oprócz REST API, wiele języków programowania ma swoich oficjalnych klientów, dostarczanych przez Elasticsearch. Dla Javy mamy High Level REST Client.

High Level REST Client

High Level REST Client przyjmuje obiekty żądań i zwraca obiekty odpowiedzi dla najważniejszych API, takich jak: info, get, index, delete, update, bulk czy search. RestHighLevelClient jest zbudowany na bazie REST low-level client builder. Poniższy kod pokazuje, jak zainicjować RestHighLevelClient w aplikacjach Spring przy użyciu wcześniej stworzonego RestClientBuilder. Każde API w High Level REST Client może być wywoływane synchronicznie lub asynchronicznie. W tym artykule skupiono się na wywołaniach synchronicznych, które zwracają obiekt odpowiedzi lub w przypadku błędu rzucają IOException.

@Configuration
public class ElasticsearchConfiguration {
  @Value("${elasticsearch.host}")
  private String elasticsearchHost;

  @Value("${elasticsearch.port}")
  private int elasticsearchPort;

  @Bean
  RestHighLevelClient restHighLevelClient() {
    RestClientBuilder restClientBuilder = RestClient.builder(new HttpHost(elasticsearchHost, elasticsearchPort));

    return new RestHighLevelClient(restClientBuilder);
  }
}

Indeksowanie dokumentów

W zależności od wymagań projektu dokumenty mogą być przechowywane na różne sposoby. Z jednej strony można używać jednego indeksu stworzonego na początku, gdzie nowe dokumenty są dodawane lub aktualizowane po ich zmianie. Jest to przydatne w przypadku, gdy nie jest wgrywany pełny zakres danych. Z drugiej strony, gdy scenariusz wymaga wgrywania pełnych danych za każdym razem, można tworzyć nowy indeks, aby zapewnić możliwość audytu. Każde z tych rozwiązań ma swoje zalety i wady, a wybór może być podyktowany wymaganiami projektu.

W aplikacji demo wybrano drugie podejście. Za każdym razem tworzony będzie nowy indeks, którego nazwa będzie wzbogacona o sufiks czasowy, np. db-drivers-20211222-122343.

Proces indeksowania dokumentów jest podzielony na trzy kroki:

  1. Tworzenie indeksu

Tworzenie nowego indeksu wymaga jedynie zbudowania obiektu CreateIndexRequest z dodatkowymi ustawieniami, jeśli są potrzebne. Przygotowane żądanie jest następnie używane przez klienta przy wywołaniu metody create. Poniżej można zobaczyć ustawienie liczby shardów i replik.

Krótko mówiąc, shard to indeks Lucene, który zawiera podzbiór dokumentów przechowywanych w indeksie Elasticsearch. Liczba shardów zależy od wielu czynników, takich jak ilość danych, zapytania itp. Istnieją dwa rodzaje shardów: primary shard i replica (kopie). Każda replika znajduje się na innym nodzie, co zapewnia dostępność danych w przypadku awarii innego noda. Domyślnie liczba shardów i replik wynosi 1.

CreateIndexRequest createIndexRequest = new CreateIndexRequest(indexName)
    .settings(Settings.builder()
        .put("index.number_of_shards", NO_OF_SHARDS)
        .put("index.number_of_replicas", NO_OF_REPLICAS)
        .build());
client.indices().create(createIndexRequest, DEFAULT);
  1. Indeksowanie dokumentów

Gdy indeks istnieje, dokumenty mogą być przechowywane. Dla każdego obiektu danych tworzony jest IndexRequest z podaną nazwą indeksu, a następnie aktualne dane są przekazywane do niego jako mapa. Aby uczynić to efektywnym, za pomocą tylko jednego wywołania do Elasticsearch, używany jest BulkRequest. Na początku każde IndexRequest jest dodawane do instancji BulkRequest, a gdy żądanie jest gotowe, wywoływana jest metoda client.bulk().

try {
  BulkRequest bulkRequest = new BulkRequest().setRefreshPolicy(WAIT_UNTIL);
  data.forEach(it -> {
    // It converts an instance of data class to map because IndexRequest accepts a map as a source.
    Map<string, object=""> source = objectMapper.convertValue(it, new TypeReference<>() {
    });
    bulkRequest.add(new IndexRequest(indexName).source(source));
  });
  BulkResponse bulkResponse = client.bulk(bulkRequest, DEFAULT);
  if (bulkResponse.hasFailures()) {
    throw new ElasticsearchStoreException(bulkResponse.buildFailureMessage());
  }
} catch (IOException e) {
  throw new ElasticsearchStoreException(e);
}
  1. Przypisywanie Aliasu

Alias to rodzaj drugorzędnej nazwy, która może być używana do odniesienia się do indeksu lub wielu indeksów. Oznacza to, że zawsze odnosimy się do indeksu przypisanego do aliasu, niezależnie od tego, jaka jest jego rzeczywista nazwa. Ta funkcjonalność jest realizowana przez przenoszenie aliasu z już przypisanych indeksów na nowy. Pierwszym krokiem jest znalezienie wszystkich istniejących indeksów pod określonym aliasem za pomocą GetAliasRequest. Następnie wszystkie wcześniej znalezione indeksy są odłączane od aliasu. Na koniec alias jest przypisywany do nowego indeksu. Każde z tych działań jest definiowane w AliasAction, które jest dodawane do IndicesAliasesRequest. Na końcu wywoływana jest metoda updatedAliases() klienta z przygotowanym już żądaniem aliasu.

IndicesAliasesRequest indicesAliasesRequest = new IndicesAliasesRequest();

// find all existing indices under specified alias
String[] assignedIndicesUnderAlias = client.indices().getAlias(new GetAliasesRequest(indexNameAsAlias), DEFAULT)
    .getAliases().keySet().toArray(String[]::new);

// unassign all previously found indices
if (assignedIndicesUnderAlias.length > 0) {
  AliasActions unassignAction = new AliasActions(REMOVE).indices(assignedIndicesUnderAlias).alias(indexNameAsAlias);
  indicesAliasesRequest.addAliasAction(unassignAction);
}

// assign the newly created index to specified alias
AliasActions assignAction = new AliasActions(ADD).index(indexName).alias(indexNameAsAlias);
indicesAliasesRequest.addAliasAction(assignAction);

// invoke client command
client.indices().updateAliases(indicesAliasesRequest, DEFAULT);

W kolejnym akapicie dokumenty zostaną pobrane z indeksu, aby zweryfikować działanie.

Odczytywanie dokumentów

Gdy wszystkie dokumenty znajdują się już w indeksie, czas na ich odczytanie. Najpierw tworzony jest SearchSourceBuilder, gdzie ważne jest zwiększenie parametru size. Domyślna wartość to 10, co oznacza, że zwracanych jest tylko pierwszych 10 dokumentów. Następnie searchSourceBuilder jest przekazywany do instancji SearchRequest wraz z nazwą indeksu źródłowego, aby zdefiniować, skąd dokumenty są pobierane.

try {
  // Prepare search request
  SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
  searchSourceBuilder.size(1000); // by default query returns 10

  SearchRequest searchRequest = new SearchRequest();
  searchRequest.indices(indexName);
  searchRequest.source(searchSourceBuilder);

  // invoke clint command to get search response
  SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
  throw new ElasticsearchReadException(e);
}

Na koniec, trafienia pobrane z odpowiedzi wyszukiwania, będącej wynikiem wywołania metody search() klienta, są parsowane do obiektów wyjściowych.

List<t> fetchedList = stream(searchResponse.getHits().getHits())
  .map(it -> toOutputDocument(it, typeReference))
  .collect(toList());

Korzystając z REST API /db-drivers/_search, można uzyskać rezultat pokazany jak poniżej.

Podsumowanie

Jak pokazano, integracja Elasticsearch z projektem jest stosunkowo prosta. Wymaga tylko niewielkiej ilości konfiguracji, a dzięki użyciu Java API Client indeksowanie i pobieranie dokumentów również nie sprawia trudności.

Linki

https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/java-rest-high-java-builders.html

demo application repository

https://www.elastic.co/guide/en/elasticsearch/client/java-rest/current/java-rest-high.html

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami