Sådan tilføjes en kraftfuld søgemaskine til din Rails-backend

I min erfaring som Ruby on Rails Developer havde jeg ofte at gøre med at tilføje søgefunktionalitet til webapplikationer. Faktisk havde næsten alle applikationer, som jeg arbejdede på på et eller andet tidspunkt, brug for søgemaskinfunktioner, mens mange af dem havde en søgemaskine som den vigtigste kernefunktionalitet.

Mange applikationer, vi bruger hver dag, ville være ubrugelige uden en god søgemaskine i deres kerne. For eksempel på Amazon kan du finde et bestemt produkt blandt de mere end 550 millioner produkter, der er tilgængelige på webstedet i løbet af få sekunder - alt sammen takket være en fuldtekstsøgning kombineret med kategorifiltre, facetter og et anbefalingssystem.

På Airbnb kan du søge efter en lejlighed ved at kombinere en geospatial søgning med filtre på huskarakteristika, som dimension, pris, tilgængelige datoer osv.

Og Spotify, Netflix, Ebay, Youtube ... alle er stærkt afhængige af en søgemaskine.

I denne artikel vil jeg beskrive, hvordan man udvikler en Ruby on Rails 5 API-backend med Elasticsearch. Ifølge DB Engines Ranking er Elasticsearch i øjeblikket den mest populære open source-søgeplatform.

Denne artikel vil ikke gå i detaljer med Elasticsearch og hvordan den sammenlignes med sine konkurrenter som Sphinx og Solr. I stedet vil det være en trinvis vejledning i, hvordan man implementerer en JSON API Backend med Ruby on Rails og Elasticsearch ved hjælp af en testdrevet udviklingsmetode.

Denne artikel dækker:

  1. Elasticsearch Setup til test-, udviklings- og produktionsmiljøer
  2. Ruby on Rails Test Miljøopsætning
  3. Modelindeksering med Elasticsearch
  4. Søg API-slutpunkt

Som i min tidligere artikel, Sådan øger du din ydeevne med serverløs arkitektur, vil jeg dække alt i en trinvis vejledning. Derefter kan du prøve det selv og have et simpelt arbejdseksempel, som du kan bygge noget mere komplekst på.

Eksemplet på applikationen vil være en filmsøgemaskine. Det vil have et enkelt JSON API-slutpunkt, der giver dig mulighed for at foretage en fuldtekstsøgning på filmtitler og oversigter.

1. Opsætning af elastiksøgning

Elasticsearch er en distribueret, RESTful søgemaskine og analysemaskine, der kan løse et stigende antal brugssager. Som hjertet i Elastic Stack gemmer den dine data centralt, så du kan opdage det forventede og afdække det uventede. - www.elastic.co/products/elasticsearch

I henhold til DB-Engines 'rangordning af søgemaskiner er Elasticsearch langt den mest populære søgemaskineplatform i dag (fra april 2018). Og det har været siden slutningen af ​​2015, da Amazon annoncerede lanceringen af ​​AWS Elasticsearch Service, en måde at starte en Elasticsearch-klynge på fra AWS Management-konsollen.

Elasticsearch er opensource. Du kan downloade din foretrukne version fra deres websted og køre den, hvor du vil. Mens jeg foreslår at bruge AWS Elasticsearch Service til produktionsmiljøer, foretrækker jeg, at Elasticsearch kører på min lokale maskine til test og udvikling.

Lad os begynde med at downloade den (i øjeblikket) seneste Elasticsearch-version (6.2.3) og pakke den ud. Åbn en terminal og kør

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

Alternativt kan du downloade Elasticsearch fra din browser her og pakke det ud med dit foretrukne program.

2. Test miljøopsætning

Vi skal bygge en backend-applikation med Ruby on Rails 5 API. Det vil have en model, der repræsenterer film. Elasticsearch indekserer det, og det kan søges gennem et API-slutpunkt.

Lad os først og fremmest oprette en ny skinne applikation. I den samme mappe, som du tidligere har downloadet Elasticsearch, skal du køre kommandoen til generering af en ny rails-app. Hvis du er ny med Ruby on Rails, skal du se denne startvejledning for at opsætte dit miljø først.

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

Når du bruger indstillingen "api", er al den middleware, der primært bruges til browserapplikationer, ikke inkluderet. Præcis hvad vi vil have. Læs mere om det direkte på Ruby on Rails-guiden.

Lad os nu tilføje alle de perler, vi har brug for. Åbn din Gemfile, og tilføj følgende kode:

# 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 ...

Vi tilføjer to Elasticsearch Gems, der giver alle nødvendige metoder til at indeksere vores model og køre søgeforespørgsler på den. rspec, rspec-rails, database_cleaner og elasticsearch-extensions bruges til test.

Når du har gemt din Gemfile, løb bundle installere for at installere alle tilføjede Gems.

Lad os nu konfigurere Rspec ved at køre følgende kommando:

rails generate rspec:install

Denne kommando opretter en spec- mappe og tilføjer spec_helper.rb og rails_helper.rb til den. De kan bruges til at tilpasse rspec til dine applikationsbehov.

I dette tilfælde tilføjer vi en DatabaseCleaner-blok til rails_helper.rb, så hver test kører i en tom database. Desuden vil vi ændre spec_helper.rb for at starte en Elasticsearch testserver hver gang testpakken startes og lukke den igen, når testpakken er færdig.

Denne løsning er baseret på Rowan Oultons artikel Testing Elasticsearch in Rails. Mange klapper for ham!

Lad os starte med DatabaseCleaner. Inde i spec / rails_helper.rb tilføj følgende kode:

# 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

Lad os derefter tænke på opsætningen af ​​Elasticsearch-testserveren. Vi er nødt til at tilføje nogle konfigurationsfiler, så Rails ved, hvor vi kan finde vores Elasticsearch eksekverbar. Det fortæller det også, hvilken port vi vil have den til at køre, baseret på det nuværende miljø. For at gøre dette skal du tilføje en ny konfigurationsyaml til din konfigurationsmappe:

# 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'

Hvis du ikke oprettede rails-applikationen i den samme mappe, hvor du downloadede Elasticsearch, eller hvis du bruger en anden version af Elasticsearch, skal du justere stien es_bin her.

Føj nu en ny fil til din initialiseringsmappe , der læser fra den konfiguration, vi lige har tilføjet:

# 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

Og endelig lad os ændre spec_helper.rb for at inkludere testopsætningen Elasticsearch . Dette betyder at starte og stoppe en Elasticsearch-testserver og oprette / slette Elasticsearch-indekser til vores Rails-model.

# 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

Vi har defineret fire blokke:

  1. en før (: alle) blok, der starter en Elasticsearch testserver, medmindre den allerede kører
  2. en efter (: suite) blok, der stopper Elasticsearch testserveren, hvis den kører
  3. en før (: hver) blok, der opretter et nyt Elasticsearch-indeks for hver model, der er konfigureret med Elasticsearch
  4. en efter (: hver) blok, der sletter alle Elasticsearch-indekser

Tilføjelse af elasticsearch: sand sikrer, at kun tests, der er mærket med elasticsearch , kører disse blokke.

Jeg finder ud af, at denne opsætning fungerer godt, når du kører alle dine tests en gang, for eksempel før en implementering. På den anden side, hvis du bruger en testdrevet udviklingsmetode, og du kører dine tests meget ofte, så bliver du sandsynligvis nødt til at ændre denne konfiguration lidt. Du ønsker ikke at starte og stoppe din Elasticsearch-testserver ved hver testkørsel.

I dette tilfælde kan du kommentere efterblokken (: suite), hvor testserveren stoppes. Du kan lukke det manuelt eller ved hjælp af et script, når du ikke længere har brug for det.

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. Modelindeksering med Elasticsearch

Nu begynder vi at implementere vores filmmodel med søgefunktioner. Vi bruger en testdrevet udviklingsmetode. Dette betyder, at vi først skriver test, ser dem mislykkes og derefter skriver kode for at få dem til at bestå.

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
En controller-test skal teste controllerens adfærd. En controller-test bør ikke mislykkes på grund af problemer i modellen.

(Recept 20 - Rails 4 Testrecepter. Noel Rappin)

Lad os omdanne dette til kode. Inde i spec / controllere / api / v1 / films_spec.rb tilføj følgende kode:

# 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

Testen mislykkes straks, fordi Api :: V1 :: MoviesController ikke er defineret, så lad os gøre det først. Opret mappestrukturen som før, og tilføj filmcontrolleren.

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

Tilføj nu følgende kode til app / controllere / api / v1 / films_controller.rb :

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

Det er tid til at køre vores test og se, at den mislykkes.

Alle test mislykkes, fordi vi stadig skal tilføje en rute til slutpunktet. Inde i config / routes.rb tilføj følgende kode:

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

Kør dine test igen, og se hvad der sker.

Den første fejl fortæller os, at vi skal tilføje et opkald til Movie.search inde i vores controller. Den anden klager over svaret. Lad os tilføje den manglende kode til movie_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

Kør testen og se om vi er færdige.

Jep. Det er alt. Vi har gennemført en virkelig grundlæggende backend-applikation, der giver brugerne mulighed for at søge på en model via API.

Du kan finde den komplette kode på min GitHub repo her. Du kan udfylde din filmtabel med nogle data ved at køre skinner db: seed, så du kan se applikationen i aktion. Dette importerer cirka 45.000 film fra et datasæt, der er downloadet fra Kaggle. Se Readme for flere detaljer.

Hvis du kunne lide denne artikel, skal du dele den på sociale medier. Tak skal du have!