Cómo agregar un potente motor de búsqueda a su backend de Rails

En mi experiencia como desarrollador de Ruby on Rails, a menudo tuve que lidiar con agregar funciones de búsqueda a las aplicaciones web. De hecho, casi todas las aplicaciones en las que trabajé en algún momento necesitaban capacidades de motor de búsqueda, mientras que muchas de ellas tenían un motor de búsqueda como la funcionalidad principal más importante.

Muchas aplicaciones que usamos todos los días serían inútiles sin un buen motor de búsqueda en su núcleo. Por ejemplo, en Amazon, puede encontrar un producto en particular entre los más de 550 millones de productos disponibles en el sitio en cuestión de unos segundos, todo gracias a una búsqueda de texto completo combinada con filtros de categoría, facetas y un sistema de recomendación.

En Airbnb, puede buscar un apartamento combinando una búsqueda geoespacial con filtros sobre las características de la casa, como dimensión, precio, fechas disponibles, etc.

Y Spotify, Netflix, Ebay, Youtube… todos dependen en gran medida de un motor de búsqueda.

En este artículo, describiré cómo desarrollar un backend de API de Ruby on Rails 5 con Elasticsearch. Según DB Engines Ranking, Elasticsearch es actualmente la plataforma de búsqueda de código abierto más popular.

Este artículo no entrará en detalles sobre Elasticsearch y cómo se compara con sus competidores como Sphinx y Solr. En cambio, será una guía paso a paso sobre cómo implementar un backend de API JSON con Ruby on Rails y Elasticsearch, utilizando un enfoque de desarrollo basado en pruebas.

Este artículo cubrirá:

  1. Configuración de Elasticsearch para entornos de prueba, desarrollo y producción
  2. Configuración del entorno de prueba de Ruby on Rails
  3. Indexación de modelos con Elasticsearch
  4. Punto final de la API de búsqueda

Como en mi artículo anterior, Cómo mejorar su rendimiento con una arquitectura sin servidor, lo cubriré todo en un tutorial paso a paso. Luego, puede probarlo usted mismo y tener un ejemplo de trabajo simple en el que construir algo más complejo.

La aplicación de ejemplo será un motor de búsqueda de películas. Tendrá un único punto final de la API JSON que le permitirá realizar una búsqueda de texto completo en títulos de películas y descripciones generales.

1. Configuración de Elasticsearch

Elasticsearch es un motor de análisis y búsqueda RESTful distribuido capaz de resolver un número creciente de casos de uso. Como corazón del Elastic Stack, almacena de forma centralizada sus datos para que pueda descubrir lo esperado y descubrir lo inesperado. - www.elastic.co/products/elasticsearch

Según el Ranking de motores de búsqueda de DB-Engines, Elasticsearch es, con mucho, la plataforma de motores de búsqueda más popular en la actualidad (en abril de 2018). Y lo ha sido desde finales de 2015, cuando Amazon anunció el lanzamiento de AWS Elasticsearch Service, una forma de iniciar un clúster de Elasticsearch desde la consola de administración de AWS.

Elasticsearch es de código abierto. Puede descargar su versión preferida de su sitio web y ejecutarla donde desee. Si bien sugiero usar AWS Elasticsearch Service para entornos de producción, prefiero tener Elasticsearch ejecutándose en mi máquina local para probar y desarrollar.

Comencemos por descargar la versión de Elasticsearch (6.2.3) más reciente (actualmente) y descomprimirla. Abra una terminal y ejecute

$ wget //artifacts.elastic.co/downloads/elasticsearch/elasticsearch-6.2.3.zip $ unzip elasticsearch-6.2.3.zip

Alternativamente, puede descargar Elasticsearch desde su navegador aquí y descomprimirlo con su programa preferido.

2. Configuración del entorno de prueba

Vamos a construir una aplicación backend con Ruby on Rails 5 API. Tendrá un modelo que represente películas. Elasticsearch lo indexará y se podrá buscar a través de un punto final de API.

En primer lugar, creemos una nueva aplicación de rieles. En la misma carpeta que descargó Elasticsearch antes, ejecute el comando para generar una nueva aplicación de rieles. Si es nuevo en Ruby on Rails, consulte esta guía de inicio para configurar su entorno primero.

$ rails new movies-search --api; cd movies-search

Cuando se usa la opción "api", no se incluye todo el middleware que se usa principalmente para las aplicaciones del navegador. Exactamente lo que queremos. Lea más sobre esto directamente en la guía de ruby ​​on rails.

Ahora agreguemos todas las gemas que necesitaremos. Abra su Gemfile y agregue el siguiente código:

# Gemfile ... # Elasticsearch integration gem 'elasticsearch-model' gem 'elasticsearch-rails' group :development, :test do ... # Test Framework gem 'rspec' gem 'rspec-rails' end group :test do ... # Clean Database between tests gem 'database_cleaner' # Programmatically start and stop ES for tests gem 'elasticsearch-extensions' end ...

Estamos agregando dos Elasticsearch Gems que proporcionarán todos los métodos necesarios para indexar nuestro modelo y ejecutar consultas de búsqueda en él. rspec, rspec-rails, database_cleaner y elasticsearch-extensions se utilizan para las pruebas.

Después de guardar su Gemfile, ejecute la instalación del paquete para instalar todas las Gemas agregadas.

Ahora configuremos Rspec ejecutando el siguiente comando:

rails generate rspec:install

Este comando creará una carpeta de especificaciones y le agregará spec_helper.rb y rails_helper.rb . Se pueden utilizar para personalizar rspec según las necesidades de su aplicación.

En este caso, agregaremos un bloque DatabaseCleaner a rails_helper.rb para que cada prueba se ejecute en una base de datos vacía. Además, modificaremos spec_helper.rb para iniciar un servidor de prueba de Elasticsearch cada vez que se inicie la suite de pruebas, y lo apagaremos nuevamente una vez que la suite de pruebas haya terminado.

Esta solución se basa en el artículo de Rowan Oulton Testing Elasticsearch in Rails. ¡Muchos aplausos para él!

Comencemos con DatabaseCleaner. Dentro de spec / rails_helper.rb agregue el siguiente código:

# spec/rails_helper.rb ... RSpec.configure do |config| ... config.before(:suite) do DatabaseCleaner.strategy = :transaction DatabaseCleaner.clean_with(:truncation) end config.around(:each) do |example| DatabaseCleaner.cleaning do example.run end end end

A continuación, pensemos en la configuración del servidor de prueba de Elasticsearch. Necesitamos agregar algunos archivos de configuración para que Rails sepa dónde encontrar nuestro ejecutable Elasticsearch. También le dirá en qué puerto queremos que se ejecute, según el entorno actual. Para hacerlo, agregue un nuevo yaml de configuración a su carpeta de configuración:

# config/elasticsearch.yml development: &default es_bin: '../elasticsearch-6.2.3/bin/elasticsearch' host: '//localhost:9200' port: '9200' test: es_bin: '../elasticsearch-6.2.3/bin/elasticsearch' host: '//localhost:9250' port: '9250' staging: <<: *default production: es_bin: '../elasticsearch-6.2.3/bin/elasticsearch' host: '//localhost:9400' port: '9400'

Si no creó la aplicación rails en la misma carpeta donde descargó Elasticsearch, o si está usando una versión diferente de Elasticsearch, deberá ajustar la ruta es_bin aquí.

Ahora agregue un nuevo archivo a su carpeta de inicializadores que leerá de la configuración que acabamos de agregar:

# config/initializers/elasticsearch.rb if File.exists?("config/elasticsearch.yml") config = YAML.load_file("config/elasticsearch.yml")[Rails.env].symbolize_keys Elasticsearch::Model.client = Elasticsearch::Client.new(config) end

Y finalmente cambiemos spec_helper.rb para incluir la configuración de prueba de Elasticsearch . Esto significa iniciar y detener un servidor de prueba de Elasticsearch y crear / eliminar índices de Elasticsearch para nuestro modelo Rails.

# spec/spec_helper.rb require 'elasticsearch/extensions/test/cluster' require 'yaml' RSpec.configure do |config| ... # Start an in-memory cluster for Elasticsearch as needed es_config = YAML.load_file("config/elasticsearch.yml")["test"] ES_BIN = es_config["es_bin"] ES_PORT = es_config["port"] config.before :all, elasticsearch: true do Elasticsearch::Extensions::Test::Cluster.start(command: ES_BIN, port: ES_PORT.to_i, nodes: 1, timeout: 120) unless Elasticsearch::Extensions::Test::Cluster.running?(command: ES_BIN, on: ES_PORT.to_i) end # Stop elasticsearch cluster after test run config.after :suite do Elasticsearch::Extensions::Test::Cluster.stop(command: ES_BIN, port: ES_PORT.to_i, nodes: 1) if Elasticsearch::Extensions::Test::Cluster.running?(command: ES_BIN, on: ES_PORT.to_i) end # Create indexes for all elastic searchable models config.before :each, elasticsearch: true do ActiveRecord::Base.descendants.each do |model| if model.respond_to?(:__elasticsearch__) begin model.__elasticsearch__.create_index! model.__elasticsearch__.refresh_index! rescue => Elasticsearch::Transport::Transport::Errors::NotFound # This kills "Index does not exist" errors being written to console rescue => e STDERR.puts "There was an error creating the elasticsearch index for #{model.name}: #{e.inspect}" end end end end # Delete indexes for all elastic searchable models to ensure clean state between tests config.after :each, elasticsearch: true do ActiveRecord::Base.descendants.each do |model| if model.respond_to?(:__elasticsearch__) begin model.__elasticsearch__.delete_index! rescue => Elasticsearch::Transport::Transport::Errors::NotFound # This kills "Index does not exist" errors being written to console rescue => e STDERR.puts "There was an error removing the elasticsearch index for #{model.name}: #{e.inspect}" end end end end end

Hemos definido cuatro bloques:

  1. un bloque before (: all) que inicia un servidor de prueba de Elasticsearch, a menos que ya se esté ejecutando
  2. un bloque after (: suite) que detiene el servidor de prueba Elasticsearch, si se está ejecutando
  3. un bloque antes (: cada) que crea un nuevo índice de Elasticsearch para cada modelo que se configura con Elasticsearch
  4. un bloque after (: each) que elimina todos los índices de Elasticsearch

Agregar elasticsearch: true asegura que solo las pruebas etiquetadas con elasticsearch ejecutarán estos bloques.

Encuentro que esta configuración funciona muy bien cuando ejecuta todas sus pruebas una vez, por ejemplo, antes de una implementación. Por otro lado, si está utilizando un enfoque de desarrollo impulsado por pruebas y ejecuta sus pruebas con mucha frecuencia, es probable que deba modificar ligeramente esta configuración. No desea iniciar y detener su servidor de prueba Elasticsearch en cada ejecución de prueba.

En este caso, puede comentar el bloque after (: suite) donde se detiene el servidor de prueba. Puede apagarlo manualmente o mediante un script, siempre que ya no lo necesite.

require 'elasticsearch/extensions/test/cluster' es_config = YAML.load_file("config/elasticsearch.yml")["test"] ES_BIN = es_config["es_bin"] ES_PORT = es_config["port"] Elasticsearch::Extensions::Test::Cluster.stop(command: ES_BIN, port: ES_PORT.to_i, nodes: 1)

3. Indexación de modelos con Elasticsearch

Ahora comenzamos a implementar nuestro modelo de película con capacidades de búsqueda. Utilizamos un enfoque de desarrollo basado en pruebas. Esto significa que primero escribimos las pruebas, las vemos fallar y luego escribimos el código para que pasen.

First we need to add the movie model which has four attributes: a title (String), an overview (Text), an image_url(String), and an average vote value (Float).

$ rails g model Movie title:string overview:text image_url:string vote_average:float $ rails db:migrate

Now it’s time to add Elasticsearch to our model. Let’s write a test that checks that our model is indexed.

# spec/models/movie_spec.rb require 'rails_helper' RSpec.describe Movie, elasticsearch: true, :type => :model do it 'should be indexed' do expect(Movie.__elasticsearch__.index_exists?).to be_truthy end end

This test will check if an elasticsearch index was created for Movie. Remember that before tests begin, we automatically create an elasticsearch index for all models that respond to the __elasticsearch__ method. That means for all models that include the elasticsearch modules.

Run the test to see it fail.

bundle exec rspec spec/models/movie_spec.rb

The first time you run this test, you should see that the Elasticsearch Test Server is starting. The test fails, because we didn’t add any Elasticsearch module to our Movie model. Let’s fix that now. Open the model and add the following Elasticsearch to include:

# app/models/movie.rb class Movie < ApplicationRecord include Elasticsearch::Model end

This will add some Elasticsearch methods to our Movie model, like the missing __elasticsearch__ method (which generated the error in the previous test run) and the search method we will use later.

Run the test again and see it pass.

bundle exec rspec spec/models/movie_spec.rb

Great. We have an indexed movie model.

By default, Elasticsearch::Model will setup an index with all attributes of the model, automatically inferring their types. Usually this is not what we want. We are now going customize the model index so that it has the following behavior:

  1. Only title and overview should be indexed
  2. Stemming should be used (which means that searching for “actors” should also return movies that contain the text “actor,” and vice-versa)

We also want our index to be updated each time a Movie is added, updated, or deleted.

Let’s translate this into tests by adding the following code to movie_spec.rb

# spec/models/movie_spec.rb RSpec.describe Movie, elasticsearch: true, :type => :model do ... describe '#search' do before(:each) do Movie.create( title: "Roman Holiday", overview: "A 1953 American romantic comedy films ...", image_url: "wikimedia.com/Roman_holiday.jpg", vote_average: 4.0 ) Movie.__elasticsearch__.refresh_index! end it "should index title" do expect(Movie.search("Holiday").records.length).to eq(1) end it "should index overview" do expect(Movie.search("comedy").records.length).to eq(1) end it "should not index image_path" do expect(Movie.search("Roman_holiday.jpg").records.length).to eq(0) end it "should not index vote_average" do expect(Movie.search("4.0").records.length).to eq(0) end end end

We create a Movie before each test, because we configured DatabaseCleaner so that each test is isolated. Movie.__elasticsearch__.refresh_index! is needed to be sure that the new movie record is immediately available for search.

As before, run the test and see it fail.

Seems that our movie is not being indexed. That’s because we didn’t yet tell our model what to do when the movie data changes. Thankfully, this can be fixed by adding another module to our Movie model:

class Movie < ApplicationRecord include Elasticsearch::Model include Elasticsearch::Model::Callbacks end

With Elasticsearch::Model::Callbacks, whenever a movie is added, modified, or deleted, its document on Elasticsearch is also updated.

Let’s see how the test output changes.

Ok. Now the problem is that our search method also returns queries that match on the attributes vote_average and image_url. To fix that we need to configure the Elasticsearch index mapping. So we need to tell Elasticsearch specifically which model attributes to index.

# app/models/movie.rb class Movie < ApplicationRecord include Elasticsearch::Model include Elasticsearch::Model::Callbacks # ElasticSearch Index settings index: { number_of_shards: 1 } do mappings dynamic: 'false' do indexes :title indexes :overview end end end

Run the test again and see it pass.

Cool. Now let’s add a stemmer so that there is no difference between “actor” and “actors.” As always, we will first write the test and see it fail.

describe '#search' do before(:each) do Movie.create( title: "Roman Holiday", overview: "A 1953 American romantic comedy films ...", image_url: "wikimedia.com/Roman_holiday.jpg", vote_average: 4.0 ) Movie.__elasticsearch__.refresh_index! end ... it "should apply stemming to title" do expect(Movie.search("Holidays").records.length).to eq(1) end it "should apply stemming to overview" do expect(Movie.search("film").records.length).to eq(1) end end

Note that we are testing both ways: Holidays should return also Holiday, and Film should also return Films.

To make these tests pass again, we need to modify the index mapping. We’ll do that this time by adding an English analyzer to both fields:

class Movie < ApplicationRecord include Elasticsearch::Model include Elasticsearch::Model::Callbacks # ElasticSearch Index settings index: { number_of_shards: 1 } do mappings dynamic: 'false' do indexes :title, analyzer: 'english' indexes :overview, analyzer: 'english' end end end

Run your tests again to see them pass.

Elasticsearch is a very powerful search platform, and we could add a lot of functionalities to our search method. But this is not within the scope of this article. So we will stop here and move on to building the controller part of the JSON API through which the search method is accessed.

4. Search API endpoint

The Search API we are building should allow users to make a fulltext search on the Movies Table. Our API has a single endpoint defined as follows:

Url: GET /api/v1/movies Params: * q=[string] required Example url: GET /api/v1/movies?q=Roma Example response: [{"_index":"movies","_type":"movie","_id":"95088","_score":11.549209,"_source":{"id":95088,"title":"Roma","overview":"A virtually plotless, gaudy, impressionistic portrait of Rome through the eyes of one of its most famous citizens.", "image_url":"//image.tmdb.org/t/p/w300/rqK75R3tTz2iWU0AQ6tLz3KMOU1.jpg","vote_average":6.6,"created_at":"2018-04-14T10:30:49.110Z","updated_at":"2018-04-14T10:30:49.110Z"}},...]

Here we are defining our endpoint according to some best practices RESTful API Design:

  1. The URL should encode the object or resource, while the action to take should be encoded by the HTTP method. In this case, the resource is the movies (collection) and we are using the HTTP method GET (because we are requesting data from the resource without producing any side effect). We use URL parameters to further define how this data should be obtained. In this example, q=[string], which specifies a search query. You can read more about how to design RESTful APIs on Mahesh Haldar’s article RESTful API Designing guidelines — The best practices.
  2. We also add versioning to our API by adding v1to our endpoint URL. Versioning your API is very important, because it allows you to introduce new features that are not compatible with previous releases without breaking all clients that were developed for previous versions of your API.

Ok. Let’s start implementing.

As always, we begin with failing tests. Inside the spec folder, we will create the folder structure that reflects our API endpoint URL structure. This means controllers →api →v1 →movies_spec.rb

You can do this manually or from your terminal running:

mkdir -p spec/controllers/api/v1 && touch spec/controllers/api/v1/movies_spec.rb

The tests we are going to write here are controller tests. They do not need to check the search logic defined in the model. Instead we will test three things:

  1. A GET request to /api/v1/movies?q=[string] will call Movie.search with [string] as parameter
  2. The output of Movie.search is returned in JSON format
  3. A success status is returned
Una prueba de controlador debe probar el comportamiento del controlador. Una prueba de controlador no debería fallar debido a problemas en el modelo.

(Receta 20 - Recetas de prueba de Rails 4. Noel Rappin)

Transformemos esto en código. Dentro de spec / controllers / api / v1 / movies_spec.rb agregue el siguiente código:

# spec/controllers/api/v1/movies_spec.rb require 'rails_helper' RSpec.describe Api::V1::MoviesController, type: :request do # Search for movie with text movie-title describe "GET /api/v1/movies?q=" do let(:title) { "movie-title"} let(:url) { "/api/v1/movies?q=#{title}"} it "calls Movie.search with correct parameters" do expect(Movie).to receive(:search).with(title) get url end it "returns the output of Movie.search" do allow(Movie).to receive(:search).and_return({}) get url expect(response.body).to eq({}.to_json) end it 'returns a success status' do allow(Movie).to receive(:search).with(title) get url expect(response).to be_successful end end end

La prueba fallará inmediatamente porque Api :: V1 :: MoviesController no está definido, así que hagámoslo primero. Cree la estructura de carpetas como antes y agregue el controlador de películas.

mkdir -p app/controllers/api/v1 && touch app/controllers/api/v1/movies_controller.rb

Ahora agregue el siguiente código a app / controllers / api / v1 / movies_controller.rb :

# app/controllers/api/v1/movies_controller.rb module Api module V1 class MoviesController < ApplicationController def index;end end end end

Es hora de ejecutar nuestra prueba y verla fallar.

Todas las pruebas fallan porque aún necesitamos agregar una ruta para el punto final. Dentro de config / routes.rb agregue el siguiente código:

# config/routes.rb Rails.application.routes.draw do namespace :api do namespace :v1 do resources :movies, only: [:index] end end end

Vuelva a ejecutar sus pruebas y vea qué sucede.

The first error tells us we need to add a call to Movie.search inside our controller. The second one complains about the response. Let’s add the missing code to the movies_controller:

# app/controllers/api/v1/movies_controller.rb module Api module V1 class MoviesController < ApplicationController def index response = Movie.search params[:q] render json: response end end end end

Run the test and see if we are done.

Yup. That’s all. We have completed a really basic backend application that allows users to search a model through API.

You can find the complete code on my GitHub repo here. You can populate your Movie table with some data by running rails db:seed so that you can see the application in action. This will import circa 45k Movies from a Dataset downloaded from Kaggle. Take a look at the Readme for more details.

If you enjoyed this article, please share it on social media. Thank you!