CRUDing Dados NoSQL com Quarkus, Parte Dois: Elasticsearch

Em Parte 1 desta série, analisamos o MongoDB, uma das bases de dados NoSQL orientadas a documentos mais confiáveis e robustas. Aqui na Parte 2, vamos examinar outra base de dados NoSQL inevitável: Elasticsearch.

Mais do que apenas uma base de dados distribuída NoSQL popular e poderosa, o Elasticsearch é antes de mais nada um motor de busca e análise. É construído sobre o Apache Lucene, a biblioteca de busca Java mais famosa, e é capaz de realizar operações de busca e análise em tempo real em dados estruturados e não estruturados. Ele é projetado para lidar eficientemente com grandes volumes de dados.

No entanto, precisamos declarar novamente que este post curto não é de forma alguma um tutorial de Elasticsearch. Consequentemente, o leitor é fortemente aconselhado a usar amplamente a documentação oficial, bem como o excelente livro, “Elasticsearch in Action” de Madhusudhan Konda (Manning, 2023) para aprender mais sobre a arquitetura e operações do produto. Aqui, estamos apenas reimplantando o mesmo caso de uso como antes, mas dessa vez, usando Elasticsearch em vez de MongoDB.

Então, vamos lá!

O Modelo de Domínio

Diagrama abaixo mostra nosso modelo de domínio *customer-order-product*:

Este diagrama é o mesmo que o apresentado na Parte 1. Assim como MongoDB, Elasticsearch também é um armazenamento de dados em documentos e, como tal, espera que os documentos sejam apresentados em JSON. A única diferença é que, para lidar com seus dados, o Elasticsearch precisa indexá-los.

Existem várias maneiras de dados serem indexados em um armazenamento de dados Elasticsearch; por exemplo, redirecionando-os de um banco de dados relacional, extraí-los de um sistema de arquivos, transmiti-los de uma fonte em tempo real, etc. Mas independentemente do método de ingestão, ele finalmente consiste em invocar a API RESTful do Elasticsearch por meio de um cliente dedicado. Existem duas categorias de tais clientes dedicados:

  1. clientes baseados em REST como curl, Postman, módulos HTTP para Java, JavaScript, Node.js, etc.
  2. SDKs de linguagens de programação (Software Development Kit): o Elasticsearch fornece SDKs para todas as linguagens de programação mais utilizadas, incluindo, mas não se limitando a Java, Python, etc.

Indexar um novo documento com Elasticsearch significa criá-lo usando uma solicitação POST contra um endpoint RESTful especial chamado _doc. Por exemplo, a seguinte solicitação criará um novo índice Elasticsearch e armazenará uma nova instância de cliente nele.

Plain Text

 

    POST customers/_doc/
    {
      "id": 10,
      "firstName": "John",
      "lastName": "Doe",
      "email": {
        "address": "[email protected]",
        "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"
        }
      ]
    }

Executar a solicitação acima usando curl ou o console Kibana (como veremos mais tarde) produzirá o seguinte resultado:

Plain Text

 

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

Esta é a resposta padrão do Elasticsearch para uma solicitação POST. Confirma que criou o índice chamado customers, tendo um novo documento customer, identificado por um ID gerado automaticamente (neste caso, ZEQsJI4BbwDzNcFB0ubC). 

Outros parâmetros interessantes aparecem aqui, como _version e especialmente _shards. Sem entrar em muitos detalhes, o Elasticsearch cria índices como coleções lógicas de documentos. Assim como manter documentos em papel em um arquivo, o Elasticsearch mantém documentos em um índice. Cada índice é composto por shards, que são instâncias físicas do Apache Lucene, o motor por trás que é responsável por colocar os dados no armazenamento ou retirá-los. Podem ser primárias, armazenando documentos, ou replicadas, armazenando, como o nome sugere, cópias dos shards primários. Mais sobre isso na documentação do Elasticsearch – por ora, precisamos notar que nosso índice chamado customers é composto por dois shards: um deles, obviamente, é primário.

A final notice: the POST request above doesn’t mention the ID value as it is automatically generated. While this is probably the most common use case, we could have provided our own ID value. In each case, the HTTP request to be used isn’t POST anymore, but PUT.

Para voltar ao nosso diagrama de modelo de domínio, como você pode ver, seu documento central é Order, armazenado em uma coleção dedicada chamada Orders. Uma Order é um agregado de documentos OrderItem, cada um dos quais aponta para seu Product associado. Um documento Order também faz referência ao Customer que a fez. Em Java, isso é implementado da seguinte forma:

Java

 

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

O código acima mostra um fragmento da classe Customer. Esta é uma simples POJO (Plain Old Java Object) que possui propriedades como o ID do cliente, nome e sobrenome, endereço de e-mail e um conjunto de endereços postais.

Vamos agora olhar para o documento Order.

Java

 

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

Aqui você pode notar algumas diferenças em comparação com a versão MongoDB. Na verdade, com MongoDB, estávamos usando uma referência à instância do cliente associada a este pedido. Esta noção de referência não existe com Elasticsearch, e portanto, estamos usando este ID de documento para criar uma associação entre o pedido e o cliente que o realizou. O mesmo se aplica à propriedade orderItemSet, que cria uma associação entre o pedido e seus itens.

O restante do nosso modelo de domínio é bastante semelhante e baseado nas mesmas ideias de normalização. Por exemplo, o documento OrderItem:

Java

 

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

Aqui, precisamos associar o produto que constitui o objeto do item de pedido atual. Por último, mas não menos importante, temos o documento Product:

Java

 

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

O Data Repositories

Quarkus Panache极大地简化了数据持久化过程,支持活动记录仓库设计模式。在第一部分中,我们使用Quarkus Panache扩展来为MongoDB实现数据仓库,但目前还没有相应的Quarkus Panache扩展用于Elasticsearch。因此,在等待可能未来的Quarkus Elasticsearch扩展的同时,我们必须手动使用Elasticsearch专用客户端来实现我们的数据仓库。

Elasticsearch是用Java编写的,因此,它提供原生支持,通过Java客户端库调用Elasticsearch API并不令人惊讶。这个库基于流式API构建器设计模式,并提供了同步和异步处理模型。它至少需要Java 8。

那么,基于流式API构建器的数据仓库看起来是什么样的呢?下面是CustomerServiceImpl类的一个摘录,该类作为Customer文档的数据仓库。

Java

 

    @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();
      }
      ...

正如我们所看到的,我们的数据仓库实现必须是一个应用程序作用域的CDI bean。Elasticsearch Java客户端只是通过Quarkus扩展quarkus-elasticsearch-java-client注入的。这种方式避免了我们否则必须使用的大量复杂配置。我们需要做的唯一事情是声明以下属性才能注入客户端:

Properties files

 

quarkus.elasticsearch.hosts = elasticsearch:9200

Aqui, elasticsearch é o nome DNS (Domain Name Server) que associamos ao servidor de banco de dados Elastic search no arquivo docker-compose.yaml. 9200 é o número da porta TCP utilizada pelo servidor para ouvir conexões.

O método doIndex() acima cria um novo índice chamado customers se ele não existir e.indexa (armazena) nele um novo documento representando uma instância da classe Customer. O processo de indexação é realizado com base em um IndexRequest que aceita como argumentos de entrada o nome do índice e o corpo do documento. Quanto ao ID do documento, ele é gerado automaticamente e retornado ao chamador para referência futura.

O método a seguir permite recuperar o cliente identificado pelo ID fornecido como argumento de entrada:

Java

 

      ...
      @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;
      }
      ...

O princípio é o mesmo: usando este padrão de construtor de API fluente, construímos uma instância de GetRequest de maneira semelhante ao que fizemos com o IndexRequest, e a executamos contra o cliente Java do Elasticsearch. Os outros endpoints do nosso repositório de dados, que permitem realizar operações de busca completa ou atualizar e excluir clientes, são projetados da mesma forma.

Por favor, reserve um tempo para olhar o código para entender como as coisas estão funcionando.

A API REST

Nosso interface de API REST para MongoDB foi simples de implementar, graças à extensão quarkus-mongodb-rest-data-panache, na qual o processador de anotações gerou automaticamente todos os endpoints necessários. Com Elasticsearch, ainda não desfrutamos do mesmo conforto e, portanto, precisamos implementá-lo manualmente. Isso não é um grande problema, pois podemos injetar os repositórios de dados anteriores, como mostrado abaixo:

Java

 

    @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();
      }
    }

Esta é a implementação da API REST do cliente. As outras associadas a pedidos, itens de pedido e produtos são semelhantes.

Vamos agora ver como executar e testar tudo isso.

Executando e Testando Nossos Microservices

Agora que analisamos os detalhes da nossa implementação, vamos ver como executá-la e testá-la. Optamos por fazer isso em nome do utilitário docker-compose. Aqui está o arquivo associado docker-compose.yml:

YAML

 

    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:

Este arquivo instrui o utilitário docker-compose a executar três serviços:

  • A service named elasticsearch running the Elasticsearch 8.6.2 database
  • A service named kibana running the multipurpose web console providing different options such as executing queries, creating aggregations, and developing dashboards and graphs
  • A service named docstore running our Quarkus microservice

Agora, você pode verificar se todos os processos necessários estão em execução:

Shell

 

    $ 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
    $

Para confirmar que o servidor Elasticsearch está disponível e capaz de executar consultas, você pode se conectar ao Kibana em http://localhost:601. Após rolar a página para baixo e selecionar Dev Tools no menu de preferências, você pode executar consultas como mostrado abaixo:

Para testar os microservices, proceda da seguinte forma:

1. Clone o repositório GitHub associado:

Shell

 

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

2. Vá para o projeto:

Shell

 

$ cd docstore

3. Faça checkout da branch correta:

Shell

 

$ git checkout elastic-search

4. Construa:

Shell

 

$ mvn clean install

5. Execute os testes de integração:

Shell

 

$ mvn -DskipTests=false failsafe:integration-test

Este último comando executará os 17 testes de integração fornecidos, que devem todos ter sucesso. Você também pode usar a interface do Swagger UI para fins de teste, abrindo seu navegador preferido em http://localhost:8080/q:swagger-ui. Em seguida, para testar endpoints, você pode usar o payload nos arquivos JSON localizados no diretório src/resources/data do projeto docstore-api.

Aproveite!

Source:
https://dzone.com/articles/cruding-nosql-data-with-quarkus-part-two-elasticse