CRUDing danych NoSQL za pomocą Quarkus, część druga: Elasticsearch

W Część 1 tej serii przyjrzeliśmy się MongoDB, jednej z najbardziej niezawodnych i solidnych baz danych NoSQL zorientowanych na dokumenty. W części 2 przyjrzymy się innej, dość nieuniknionej bazie danych NoSQL: Elasticsearch.

Więcej niż tylko popularna i potężna rozproszona baza danych NoSQL typu open source, Elasticsearch to przede wszystkim silnik wyszukiwania i analizy. Jest on zbudowany na bazie Apache Lucene, najbardziej znana wyszukiwarka Java i jest w stanie wykonywać operacje wyszukiwania i analizy w czasie rzeczywistym na ustrukturyzowanych i nieustrukturyzowanych danych. Został zaprojektowany do wydajnej obsługi dużych ilości danych.

Po raz kolejny musimy zastrzec, że ten krótki post nie jest w żadnym wypadku samouczkiem Elasticsearch. W związku z tym cierpliwym czytelnikom zdecydowanie zaleca się obszerne korzystanie z oficjalnej dokumentacji, a także doskonałej książki “Elasticsearch w akcji” autorstwa Madhusudhan Konda (Manning, 2023), aby dowiedzieć się więcej o architekturze i działaniu produktu. Tutaj po prostu ponownie wdrażamy ten sam przypadek użycia, co poprzednio, ale tym razem używamy Elasticsearch zamiast MongoDB.

No to zaczynamy!

Model domeny

Poniższy diagram przedstawia nasz *customer-order-product*. model domeny:

*customer-order-product* model domeny

Ten diagram jest taki sam jak ten przedstawiony w części 1. Podobnie jak MongoDB, Elasticsearch jest również magazynem danych dokumentów i jako taki oczekuje, że dokumenty będą prezentowane w formacie JSON notacja. Jedyną różnicą jest to, że aby obsłużyć dane, Elasticsearch musi je zindeksować.

Istnieje kilka sposobów indeksowania danych w magazynie danych Elasticsearch; na przykład potokowanie ich z relacyjnej bazy danych, wyodrębnianie ich z systemu plików, przesyłanie strumieniowe ze źródła w czasie rzeczywistym itp. Niezależnie jednak od metody pozyskiwania danych, ostatecznie polega ona na wywołaniu interfejsu API RESTful Elasticsearch za pośrednictwem dedykowanego klienta. Istnieją dwie kategorie takich dedykowanych klientów:

  1. Klienty oparte na REST jak curl, Postman, moduły HTTP dla Java, JavaScript, Node.js itp.
  2. Zestawy SDK dla języków programowania (Software Development Kit): Elasticsearch zapewnia zestawy SDK dla wszystkich najczęściej używanych języków programowania, w tym między innymi Java, Python itp.

Indeksowanie nowego dokumentu za pomocą Elasticsearch oznacza utworzenie go przy użyciu pliku POST w specjalnym punkcie końcowym RESTful API o nazwie _doc. Na przykład poniższe żądanie utworzy nowy indeks Elasticsearch i zapisze w nim nową instancję klienta.

    POST customers/_doc/
    {
      "id": 10,
      "firstName": "John",
      "lastName": "Doe",
      "email": {
        "address": "john.doe@gmail.com",
        "personal": "John Doe",
        "encodedPersonal": "John Doe",
        "type": "personal",
        "simple": true,
        "group": true
      },
      "addresses": [
        {
          "street": "75, rue Véronique Coulon",
          "city": "Coste",
          "country": "France"
        },
        {
          "street": "Wulfweg 827",
          "city": "Bautzen",
          "country": "Germany"
        }
      ]
    }

Uruchomienie powyższego żądania przy użyciu curl lub konsoli Kibana (jak zobaczymy później) da następujący wynik:

    {
      "_index": "customers",
      "_id": "ZEQsJI4BbwDzNcFB0ubC",
      "_version": 1,
      "result": "created",
      "_shards": {
        "total": 2,
        "successful": 1,
        "failed": 0
      },
      "_seq_no": 1,
      "_primary_term": 1
    }

Jest to standardowa odpowiedź Elasticsearch na zapytanie POST żądanie. Potwierdza ona utworzenie indeksu o nazwie customersi utworzenie nowego indeksu customer dokument, identyfikowany przez automatycznie wygenerowany identyfikator ( w tym przypadku, ZEQsJI4BbwDzNcFB0ubC).

Pojawiają się tutaj również inne ciekawe parametry, takie jak _version a w szczególności _shards. Bez wchodzenia w zbyt wiele szczegółów, Elasticsearch tworzy indeksy jako logiczne kolekcje dokumentów. Podobnie jak przechowywanie dokumentów papierowych w szafce na dokumenty, Elasticsearch przechowuje dokumenty w indeksie. Każdy indeks składa się z odłamków, które są fizycznymi instancjami Apache Lucene, silnika za kulisami odpowiedzialnego za pobieranie danych do lub z pamięci masowej. Mogą to być podstawoweprzechowywanie dokumentów lub repliki, przechowujące, jak sama nazwa wskazuje, kopie podstawowych shardów. Więcej na ten temat w dokumentacji Elasticsearch – na razie musimy zauważyć, że nasz indeks o nazwie customers składa się z dwóch shardów, z których jeden jest oczywiście podstawowy.

Ostatnia uwaga: plik POST nie wspomina o wartości ID, ponieważ jest ona generowana automatycznie. Chociaż jest to prawdopodobnie najczęstszy przypadek użycia, mogliśmy podać własną wartość ID. W każdym przypadku żądanie HTTP do użycia nie jest POST ale PUT.

Wracając do naszego diagramu modelu domeny, jak Państwo widzą, jego centralnym dokumentem jest Orderprzechowywany w dedykowanej kolekcji o nazwie Orders. An Order jest agregatem OrderItem dokumentów, z których każdy wskazuje na powiązany z nim Product. An Order dokument odnosi się również do Customer który go umieścił. W Javie jest to zaimplementowane w następujący sposób:

    public class Customer
    {
      private Long id;
      private String firstName, lastName;
      private InternetAddress email;
      private Set<Address> addresses;
      ...
    }

Powyższy kod pokazuje fragment kodu Customer . Jest to prosty obiekt POJO (Plain Old Java Object) posiadający właściwości takie jak identyfikator klienta, imię i nazwisko, adres e-mail oraz zestaw adresów pocztowych.

Przyjrzyjmy się teraz klasie Order dokument.

    public class Order
    {
      private Long id;
      private String customerId;
      private Address shippingAddress;
      private Address billingAddress;
      private Set<String> orderItemSet = new HashSet<>()
      ...
    }

Tutaj można zauważyć pewne różnice w porównaniu do wersji MongoDB. W rzeczywistości w MongoDB używaliśmy odniesienia do instancji klienta powiązanej z tym zamówieniem. To pojęcie referencji nie istnieje w Elasticsearch i dlatego używamy tego identyfikatora dokumentu, aby utworzyć powiązanie między zamówieniem a klientem, który je złożył. To samo odnosi się do orderItemSet która tworzy powiązanie między zamówieniem a jego pozycjami.

Reszta naszego modelu domeny jest dość podobna i oparta na tych samych pomysłach normalizacji. Na przykład OrderItem document:

    public class OrderItem
    {
      private String id;
      private String productId;
      private BigDecimal price;
      private int amount;
      ...
    }

Tutaj musimy skojarzyć produkt, który tworzy obiekt bieżącej pozycji zamówienia. Wreszcie, co nie mniej ważne, mamy Product document:

    public class Product
    {
      private String id;
      private String name, description;
      private BigDecimal price;
      private Map<String, String> attributes = new HashMap<>();
      ...
    }

Repozytoria danych

Quarkus Panache znacznie upraszcza trwałość danych poprzez wspieranie zarówno procesu aktywny rekord oraz repozytorium wzorce projektowe. W części 1 użyliśmy rozszerzenia Quarkus Panache dla MongoDB do wdrożenia naszych repozytoriów danych, ale nie ma jeszcze odpowiednika rozszerzenia Quarkus Panache dla Elasticsearch. W związku z tym, czekając na możliwe przyszłe rozszerzenie Quarkus dla Elasticsearch, musimy ręcznie zaimplementować nasze repozytoria danych za pomocą dedykowanego klienta Elasticsearch.

Elasticsearch jest napisany w języku Java, a zatem nie jest zaskoczeniem, że oferuje natywne wsparcie dla wywoływania interfejsu API Elasticsearch za pomocą biblioteki klienta Java. Biblioteka ta oparta jest na płynnych wzorcach projektowych API Builder i zapewnia zarówno synchroniczne, jak i asynchroniczne modele przetwarzania. Wymaga ona co najmniej Java 8.

Jak zatem wyglądają nasze repozytoria danych oparte na fluent API builder? Poniżej znajduje się fragment CustomerServiceImpl która działa jako repozytorium danych dla klasy Customer dokumentu.

    @ApplicationScoped
    public class CustomerServiceImpl implements CustomerService
    {
      private static final String INDEX = "customers";

      @Inject
      ElasticsearchClient client;

      @Override
      public String doIndex(Customer customer) throws IOException
      {
        return client.index(IndexRequest.of(ir -> ir.index(INDEX).document(customer))).id();
      }
      ...

Jak widzimy, nasza implementacja repozytorium danych musi być fasolą CDI posiadającą zakres aplikacji. Klient Elasticsearch Java jest po prostu wstrzykiwany dzięki funkcji quarkus-elasticsearch-java-client Quarkus extension. W ten sposób unikamy wielu dzwonków i gwizdków, których musielibyśmy użyć w innym przypadku. Jedyną rzeczą, której potrzebujemy, aby móc wstrzyknąć klienta, jest zadeklarowanie następującej właściwości:

quarkus.elasticsearch.hosts = elasticsearch:9200

Proszę, elasticsearch to nazwa DNS (Domain Name Server), którą kojarzymy z serwerem bazy danych Elastic search w pliku docker-compose.yaml . 9200 to numer portu TCP używanego przez serwer do nasłuchiwania połączeń.

Metoda doIndex() powyżej tworzy nowy indeks o nazwie customers jeśli nie istnieje i indeksuje (przechowuje) w nim nowy dokument reprezentujący instancję klasy Customer. Proces indeksowania jest wykonywany na podstawie IndexRequest przyjmując jako argumenty wejściowe nazwę indeksu i treść dokumentu. Jeśli chodzi o identyfikator dokumentu, jest on generowany automatycznie i zwracany do wywołującego w celu dalszego odniesienia.

Poniższa metoda umożliwia pobranie klienta zidentyfikowanego przez ID podane jako argument wejściowy:

      ...
      @Override
      public Customer getCustomer(String id) throws IOException
      {
        GetResponse<Customer> getResponse = client.get(GetRequest.of(gr -> gr.index(INDEX).id(id)), Customer.class);
        return getResponse.found() ? getResponse.source() : null;
      }
      ...

Zasada jest taka sama: używając tej metody fluent API builder, konstruujemy wzorzec GetRequest w podobny sposób jak w przypadku wzorca IndexRequesti uruchamiamy ją przeciwko klientowi Elasticsearch Java. Pozostałe punkty końcowe naszego repozytorium danych, umożliwiające nam wykonywanie pełnych operacji wyszukiwania lub aktualizowanie i usuwanie klientów, są zaprojektowane w ten sam sposób.

Proszę poświęcić trochę czasu na przyjrzenie się kodowi, aby zrozumieć, jak to działa.

API REST

Nasz interfejs API REST MongoDB był prosty do wdrożenia dzięki funkcji quarkus-mongodb-rest-data-panache w którym procesor adnotacji automatycznie wygenerował wszystkie wymagane punkty końcowe. W przypadku Elasticsearch nie korzystamy jeszcze z tego samego komfortu, a zatem musimy go ręcznie wdrożyć. Nie jest to duży problem, ponieważ możemy wstrzyknąć poprzednie repozytoria danych, pokazane poniżej:

    @Path("customers")
    @Produces(APPLICATION_JSON)
    @Consumes(APPLICATION_JSON)
    public class CustomerResourceImpl implements CustomerResource
    {
      @Inject
      CustomerService customerService;

      @Override
      public Response createCustomer(Customer customer, @Context UriInfo uriInfo) throws IOException
      {
        return Response.accepted(customerService.doIndex(customer)).build();
      }

      @Override
      public Response findCustomerById(String id) throws IOException
      {
        return Response.ok().entity(customerService.getCustomer(id)).build();
      }

      @Override
      public Response updateCustomer(Customer customer) throws IOException
      {
        customerService.modifyCustomer(customer);
        return Response.noContent().build();
      }

      @Override
      public Response deleteCustomerById(String id) throws IOException
      {
        customerService.removeCustomerById(id);
        return Response.noContent().build();
      }
    }

Jest to implementacja REST API klienta. Pozostałe repozytoria związane z zamówieniami, pozycjami zamówień i produktami są podobne.

Zobaczmy teraz, jak uruchomić i przetestować całość.

Uruchamianie i testowanie naszych mikrousług

Teraz, gdy przyjrzeliśmy się szczegółom naszej implementacji, zobaczmy, jak ją uruchomić i przetestować. Zdecydowaliśmy się zrobić to w imieniu docker-compose utility. Oto powiązane docker-compose.yml plik:

    version: "3.7"
    services:
      elasticsearch:
        image: elasticsearch:8.12.2
        environment:
          node.name: node1
          cluster.name: elasticsearch
          discovery.type: single-node
          bootstrap.memory_lock: "true"
          xpack.security.enabled: "false"
          path.repo: /usr/share/elasticsearch/backups
          ES_JAVA_OPTS: -Xms512m -Xmx512m
        hostname: elasticsearch
        container_name: elasticsearch
        ports:
          - "9200:9200"
          - "9300:9300"
        ulimits:
        memlock:
          soft: -1
          hard: -1
        volumes:
          - node1-data:/usr/share/elasticsearch/data
        networks:
          - elasticsearch
      kibana:
        image: docker.elastic.co/kibana/kibana:8.6.2
        hostname: kibana
        container_name: kibana
        environment:
          - elasticsearch.url=http://elasticsearch:9200
          - csp.strict=false
        ulimits:
          memlock:
            soft: -1
            hard: -1
        ports:
          - 5601:5601
        networks:
          - elasticsearch
        depends_on:
          - elasticsearch
        links:
          - elasticsearch:elasticsearch
      docstore:
        image: quarkus-nosql-tests/docstore-elasticsearch:1.0-SNAPSHOT
        depends_on:
          - elasticsearch
          - kibana
        hostname: docstore
        container_name: docstore
        links:
          - elasticsearch:elasticsearch
          - kibana:kibana
        ports:
          - "8080:8080"
           - "5005:5005"
        networks:
          - elasticsearch
        environment:
          JAVA_DEBUG: "true"
          JAVA_APP_DIR: /home/jboss
          JAVA_APP_JAR: quarkus-run.jar
    volumes:
      node1-data:
      driver: local
    networks:
      elasticsearch:

Ten plik instruuje aplikację docker-compose uruchomienie trzech usług:

  • Usługa o nazwie elasticsearch uruchamiająca bazę danych Elasticsearch 8.6.2
  • Usługa o nazwie kibana uruchamiająca wielofunkcyjną konsolę internetową zapewniającą różne opcje, takie jak wykonywanie zapytań, tworzenie agregacji oraz opracowywanie pulpitów nawigacyjnych i wykresów
  • Usługa o nazwie docstore uruchamiająca nasz mikroserwis Quarkus

Teraz mogą Państwo sprawdzić, czy wszystkie wymagane procesy są uruchomione:

    $ docker ps
    CONTAINER ID   IMAGE                                                     COMMAND                  CREATED      STATUS      PORTS                                                                                            NAMES
    005ab8ebf6c0   quarkus-nosql-tests/docstore-elasticsearch:1.0-SNAPSHOT   "/opt/jboss/containe…"   3 days ago   Up 3 days   0.0.0.0:5005->5005/tcp, :::5005->5005/tcp, 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 8443/tcp   docstore
    9678c0a04307   docker.elastic.co/kibana/kibana:8.6.2                     "/bin/tini -- /usr/l…"   3 days ago   Up 3 days   0.0.0.0:5601->5601/tcp, :::5601->5601/tcp                                                        kibana
    805eba38ff6c   elasticsearch:8.12.2                                      "/bin/tini -- /usr/l…"   3 days ago   Up 3 days   0.0.0.0:9200->9200/tcp, :::9200->9200/tcp, 0.0.0.0:9300->9300/tcp, :::9300->9300/tcp             elasticsearch
    $

Aby potwierdzić, że serwer Elasticsearch jest dostępny i może uruchamiać zapytania, można połączyć się z Kibaną pod adresem http://localhost:601. Po przewinięciu strony w dół i wybraniu opcji Dev Tools w menu preferencji można uruchamiać zapytania, jak pokazano poniżej:

Uruchamianie zapytań w Elasticsearch

Aby przetestować mikrousługi, proszę postępować w następujący sposób:

1. Proszę sklonować powiązane repozytorium GitHub:

$ git clone https://github.com/nicolasduminil/docstore.git

2. Proszę przejść do projektu:

3. Proszę sprawdzić właściwą gałąź:

$ git checkout elastic-search

4. Proszę zbudować:

5. Proszę uruchomić testy integracyjne:

$ mvn -DskipTests=false failsafe:integration-test

To ostatnie polecenie uruchomi 17 dostarczonych testów integracyjnych, które powinny zakończyć się sukcesem. Mogą Państwo również użyć interfejsu Swagger UI do celów testowych, uruchamiając preferowaną przeglądarkę pod adresem http://localhost:8080/q:swagger-ui. Następnie, aby przetestować punkty końcowe, można użyć ładunku w plikach JSON znajdujących się w folderze src/resources/data katalogu docstore-api projektu.

Proszę się cieszyć!