Indexando dados no Elasticsearch

Indexando dados no Elasticsearch

Neste artigo será mostrado como interagir com o Elasticsearch a partir de uma aplicação capaz de criar e indexar dados através de chamadas HTTP. De forma que o exemplo seja de simples reprodução em outras linguagens uma vez que,  nosso exemplo será feito com base em uma aplicação Ruby on Rails.

Iniciaremos com a criação de uma aplicação web Ruby on Rails que indexa dados de seu banco de dados local PostgreSQL em um índice Elasticsearch. Na sequência, iremos gerar um volume de dados que, ao final, será indexado.

Preparação do ambiente

Iniciaremos criando nossa aplicação web, que irá interagir com o Elasticsearch. Esta aplicação será uma API feita em Ruby on Rails, porém as interações com o Elasticsearch serão feitas via chamada HTTP permitindo que seja reproduzido, de forma simples, em sua linguagem favorita.

Como pré requisitos para acompanhar o passo a passo, você deve ter o Docker e docker-compose instalados e configurados em seu ambiente local. Uma vez que os pré-requisitos estejam cumpridos, podemos dar início criando nossa aplicação a partir de um container Ruby para que não haja a necessidade de que você tenha o Ruby e o Ruby on Rails instalados em seu ambiente local.

Os comandos a seguir, iniciam um container Docker a partir da imagem ruby:3.2.2. Na sequência, devemos instalar a gem Ruby on Rails que usaremos para gerar nosso projeto e, por fim, executar o comando utilizado para gerar um novo projeto rails:

# Inicia o container Ruby
docker run --rm -v $(pwd):/usr/src -w /usr/src -it ruby:3.2.2 bash
# instala o Ruby on Rails
gem install rails
# Cria um novo projeto rails
rails new reindex-blue-green --skip-test --skip-bundle --database=postgresql --api

Com nosso projeto criado, chegou a hora de configurarmos nossos containers e nossa conexão com o banco de dados para termos nosso ambiente de desenvolvimento funcional. Iremos iniciar criando um arquivo chamado Dockerfile na raiz do projeto com as seguintes configurações:

# Dockerfile
FROM ruby:3.2.2
# Instala nossas dependências
RUN apt-get update && apt-get install -qq -y --no-install-recommends build-essential \
libpq-dev git-all
# env var com o path da aplicação
ENV INSTALL_PATH /reindex-blue-green
# cria o diretório que será utilizado como WORKDIR
RUN mkdir -p $INSTALL_PATH
# define a localização inicial do container
WORKDIR $INSTALL_PATH
# copia Gemfile para o container
COPY Gemfile ./
# gems path
ENV BUNDLE_PATH /gems
# copia o diretório com o código da aplicação para nosso container
COPY . .

Agora, iremos adicionar um arquivo chamado docker-compose.yml também na raiz do projeto. Neste arquivo são definidos os seguintes serviços e volumes que iremos utilizar:

- Postgres: será o banco de dados relacional da aplicação;

- Web: aplicação web, no caso uma API Ruby on Rails;

- Elasticsearch: um container elasticsearch que usaremos para indexar nossos dados;

- Kibana: interface para interagir com o elasticsearch de forma simples.

version: "3"
services:
  postgres:
    image: "postgres:13"
    volumes:
     - postgres:/var/lib/postgresql/data
    environment:
      - POSTGRES_HOST_AUTH_METHOD: trust
    ports:
      - "5432:5432"

  web:
    depends_on:
      - "postgres"
      - "elasticsearch"
    build: .
    command: bash -c "(bundle check || bundle install) && bundle exec puma -C config/puma.rb"
    ports:
      - "3000:3000"
    volumes:
      - .:/reindex-blue-green
      - gems:/gems
   
   elasticsearch:
     image: elasticsearch:7.17.9
     depends_on:
       - postgres
     ports:
       - 9200:9200
       - 9300:9300
     volumes:
       - elasticsearch:/usr/share/elasticsearch/data
     environment:
       - ES_JAVA_OPTS: -Xms512m -Xmx512m
       - discovery.type: single-node
  
  kibana:
    image: kibana:7.17.9
    depends_on:
      - elasticsearch
    ports:
      - 5601:5601
    environment:
      - ELASTICSEARCH_HOSTS: http://elasticsearch:9200

volumes:
  postgres:
  gems:
  elasticsearch:

Após adicionar o arquivo docker-compose.yml, faremos o build e iniciaremos nossos serviços executando os seguintes comandos, a partir do diretório da aplicação:

docker build .
docker-compose up

Para concluir nosso setup, iremos configurar a conexão de nossa aplicação Ruby on Rails com nosso banco de dados PostgreSQL. Editamos o arquivo config/database.yml com o conteúdo a seguir e, na sequência, reiniciaremos nossos containers:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: postgres
  user: postgres
  port: 5432

development:
  <<: *default
  database: reindex_blue_green_development

test:
  <<: *default
  database: reindex_blue_green_test

Uma vez configurada e reiniciada a aplicação, acessaremos o container web e criaremos nosso banco de dados:

# Comando que acessa o container em que está sendo executado o serviço web
docker-compose exec web bash 
# Comando rails que cria o banco de dados
bundle exec rails db:create

Caso tudo tenha corrido bem ao acessar localhost:3000 veremos a seguinte tela:


Indexação

Adicionando dados para serem indexados

Nesta etapa,  criaremos um modelo de dados chamado Candidate que irá armazenar na tabela candidates os registros que iremos indexar na sequência. Nosso modelo possui os atributos name (String), email (String), experience_time(Integer) e focus (String). Para gerar nosso model e executar a migração que cria a tabela candidates, você deve executar os seguintes comandos a partir do container web:

# Cria o model Candidate
bundle exec rails g model candidate name email experience_time:integer focus
# Executa a migration pendente
bundle exec rails db:migrate

Como pretendemos gerar muitos candidatos com dados distintos, podemos utilizar a gem faker para obtermos valores "aleatórios" e que façam sentido como nomes e emails válidos. Para adicionar a gem faker adicionaremos gem 'faker' ao arquivo Gemfile no grupo development, :test e executaremos o comando bundle para que seja feita a instalação.

Com nosso modelo criado e a gem faker adicionada ao projeto, chegou o momento de popularmos nossa tabela. Faremos isso a partir de uma rake task  que criaremos em  lib/tasks/candidates.rake. Essa task faz um loop que itera cem vezes e a cada iteração um novo candidato é criado em nosso banco de dados. Note que estamos utilizando valores vindos da gem faker para que nossos candidatos tenham name e email "únicos". Também definimos que o valor de experience time será um valor entre um e dez, e o foco será um valor aleatório entre as opções back-end, front-end e full-stack. Para executar a task e criar os candidatos executaremos bundle exec rails candidates:seed.

# frozen_string_literal: true

namespace :candidates do
  desc 'Create candidates'
  task seed: :environment do
    100.times do |index|
      Candidate.create!(
        name: Faker::Name.name,
        email: Faker::Internet.email,
        experience_time: rand(1..10),
        focus: %w[backend frontend fullstack].sample
      )
      puts "#{index + 1} of 100"
    end
  end
end

Indexando dados

Agora que temos candidatos cadastrados em nossa base, chegou o momento de interagirmos com o Elasticsearch e indexá-los. Inicialmente, iremos acessar localhost:9200 e verificar se nosso container elasticsearch está funcionando. Caso esteja funcionando conforme o esperado, será exibida uma mensagem parecida com o exemplo:


Com a confirmação de que o container está funcionando corretamente, podemos dar início ao processo de indexação. Esse será feito a partir de chamadas a API que o Elasticsearch expõe para que sejam feitas operações de leitura e escrita de registros.

Apesar de o Elasticsearch possuir bibliotecas que ajudem na integração, neste artigo não será utilizada nenhuma gem para facilitar a replicação do projeto em outras linguagens.

Em um projeto real, é recomendado utilizar a biblioteca da sua linguagem favorita para realizar a integração com o Elasticsearch, uma vez que tais bibliotecas são extensivamente aplicadas em projetos reais.

Para simplificar o código que faz as chamadas HTTP, iremos utilizar a gem faraday:

# Adicionar ao arquivo Gemfile
gem "faraday"
# Comando que instala a gem
bundle

Para isolar o código que irá fazer as requests HTTP ao Elasticsearch, iremos criar uma nova classe em app/repositories/candidates_repository.rb. Esta classe seguirá o pattern Repository de forma que todas as chamadas ao Elasticsearch, relacionadas ao contexto de Candidate, serão feitas por esta classe.

Inicialmente, iremos implementar os métodos create_index que cria um novo índice com o nome recebido. Um índice pode ser interpretado como algo próximo a uma tabela em um banco de dados relacional.  Por isso, antes de adicionar registros precisamos criar um índice. Note que o Elasticsearch espera receber o parâmetro  mappings que define as propriedades e atributos do nosso índice. O outro método que iremos implementar chama-se index_candidate, que irá receber o nome de um índice e um objeto Candidate para indexá-lo.

# frozen_string_literal: true

class CandidateRepository
  # NOTE: defines the host of elasticsearch, we use elasticsearch instead of localhost due to docker network.
  ELASTICSEARCH_HOST = 'http://elasticsearch:9200/'.freeze

  # NOTE: defines the mapping with candidate attributes and its types.
  INDEX_MAPPING = {
    mappings: {
      properties: {
        name: { type: "text" },
        email: { type: "text" },
        experience_time: { type: "integer" },
        focus: { type: "text" }
      }
    }
  }.freeze

  # Create an index with received name.
  #
  # @param index_name [String] index_name
  def create_index(index_name)
    response = conn.put(index_name) do |req|
      req.body = INDEX_MAPPING.to_json
    end
    return 'Index created' if response.status == 200
    response.body
  end

  # Index candidate on received index.
  #
  # @param candidate [Candidate] candidate object.
  # @param index_name [String] index which received candidate will be indexed.
  def index_candidate(candidate, index_name)
    response = conn.put("#{index_name}/_doc/#{candidate.id}") do |req|
      req.body = {
        name: candidate.name,
        email: candidate.email,
        experience_time: candidate.experience_time,
        focus: candidate.focus
      }.to_json
    end
    return 'Candidate indexed' if response.status == 201 || response.status == 200
    response.body
  end

  private

  def conn
    Faraday.new(
      url: ELASTICSEARCH_HOST,
      headers: {'Content-Type' => 'application/json'}
    )
  end
end

Por fim, iremos adicionar uma nova rake task no arquivo lib/tasks/candidates.rake chamada candidates:mass_indexation que irá criar um índice e iterar sobre cada um de nossos candidatos, indexando-os no Elasticsearch a partir de chamadas da classe CandidateRepository.

desc 'Index candidates'
  task mass_indexation: :environment do
    index_name = 'candidates'
    puts 'Creating index ...'
    CandidateRepository.new.create_index(index_name)
    puts 'Index created'
    Candidate.all.find_each.with_index do |candidate, index|
    CandidateRepository.new.index_candidate(candidate, index_name)
    puts "#{index + 1} of #{Candidate.count}"
  end
end

Ao executar nossa nova task a partir de bundle exec rails candidates:mass_indexation podemos acessar o Kibana em http://localhost:5601 para ver os dados indexados. O Kibana é uma ferramenta utilizada para interagir com o Elasticsearch a partir de uma interface web, semelhando ao que o pgAdmin é para o PostgreSQL. Acessando http://localhost:5601/app/dev_tools#/console, você verá o editor onde podemos escrever queries para buscar dados, criar ou remover índices e mais.


As queries que iremos escrever no console do Kibana seguem a sintaxe do Elasticsearch e por isso se parecem muito com requests HTTP. A seguir, iremos executar a query GET candidates que irá nos retornar um resumo sobre o índice candidates. Note que na sessão mappings:properties temos os atributos definidos em CandidatesRepository:

{
  "candidates": {
    "aliases": {},
    "mappings": {
      "properties": {
        "email": {
          "type": "text"
        },
        "experience_time": {
          "type": "integer"
        },
        "focus": {
          "type": "text"
        },
        "name": {
          "type": "text"
        }
      }
    },
    "settings": {
      "index": {
        "routing": {
          "allocation": {
            "include": {
              "_tier_preference": "data_content"
            }
          }
        },
        "number_of_shards": "1",
        "provided_name": "candidates",
        "creation_date": "1680446848009",
        "number_of_replicas": "1",
        "uuid": "TPOOYBdMRSuRHSsGHkh0Ag",
        "version": {
          "created": "7170999"
        }
      }
    }
  }
}

Também podemos ver quantos registros temos em um índice executando a query GET candidates/_count.

{
  "count" : 100,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  }
}

E por fim, para visualizarmos os dados de candidatos indexados iremos executar a seguinte query (que é análoga a uma operação SELECT * FROM em SQL). Note que estamos utilizando o parâmetro size para que sejam retornados 100 registros uma vez que, por padrão, o Elasticsearch retorna apenas 10 registros. Com isso vemos que os dados dos nossos candidatos foram indexados corretamente!

GET candidates/_search?size=100
{ "query": { "match_all": {}} }


Conclusão

Assim chegamos ao final deste artigo, no qual vimos como criar um índice e indexar registros de forma simples no Elasticsearch, através de requests HTTP quebrando um pouco da mística envolvendo este processo.  

O código da aplicação até a conclusão deste artigo está disponível em GitHub.

Porém nem tudo são flores. Você deve ter percebido o tempo que demorou para indexar apenas cem registros e deve estar se perguntando se isso é escalável. Te trago aqui algumas observações: em projetos reais utilize a biblioteca mais ativa da sua linguagem; é possível indexar diversos registros de uma só vez, porém a indexação de uma base completa pode ser um processo demorado.

Em um segundo artigo, apresentarei uma abordagem semelhante a um deploy blue-green, que indexará nossos candidatos em um segundo índice e, ao final, efetuará a troca (switch) dos índices de forma rápida, com mínimo impacto ao usuário.

Sucesso!

💡
As opiniões e comentários expressos neste artigo são de propriedade exclusiva de seu autor e não representam necessariamente o ponto de vista da Revelo.

A Revelo Content Network acolhe todas as raças, etnias, nacionalidades, credos, gêneros, orientações, pontos de vista e ideologias, desde que promovam diversidade, equidade, inclusão e crescimento na carreira dos profissionais de tecnologia.