Hogyan adhatunk hozzá egy erős keresőmotort a Rails háttérképhez

A Ruby on Rails fejlesztőként szerzett tapasztalataim szerint gyakran kellett megkeresnem a keresési funkciók hozzáadását a webalkalmazásokhoz. Valójában szinte minden alkalmazáson, amelyen valamikor dolgoztam, keresőmotor-képességekre volt szükség, míg sokukban a keresőmotor volt a legfontosabb alapvető funkció.

Számos olyan alkalmazás, amelyet mindennap használunk, haszontalan lenne, ha nem lenne jó keresőmotor az alapjában. Például az Amazon-on néhány másodperc alatt megtalálható egy adott termék a webhelyen elérhető több mint 550 millió termék között - mindezt egy teljes szövegű keresésnek köszönhetően, amely kategóriaszűrőkkel, aspektusokkal és ajánlási rendszerrel van kombinálva.

Az Airbnb-n úgy kereshet egy lakást, hogy egyesíti a földrajzi keresést a ház jellemzőinek szűrőivel, például méret, ár, elérhető dátumok stb.

És a Spotify, a Netflix, az Ebay, a Youtube ... mindegyik nagyban támaszkodik egy keresőmotorra.

Ebben a cikkben leírom, hogyan lehet egy Ruby on Rails 5 API háttérprogramot fejleszteni az Elasticsearch segítségével. A DB Engines Ranking szerint az Elasticsearch jelenleg a legnépszerűbb nyílt forráskódú keresési platform.

Ez a cikk nem tér ki az Elasticsearch részleteire, és arra, hogy miként hasonlít versenytársaihoz, mint a Sphinx és a Solr. Ehelyett lépésről lépésre bemutatja, hogyan lehet megvalósítani a JSON API háttérrendszert a Ruby on Rails és az Elasticsearch szolgáltatással, tesztvezérelt fejlesztési megközelítéssel.

Ez a cikk a következőkről fog szólni:

  1. Elasticsearch Setup tesztelési, fejlesztési és gyártási környezetekhez
  2. A Ruby on Rails tesztkörnyezet beállítása
  3. Modellindexálás az Elasticsearch segítségével
  4. Keresési API végpont

Az előző cikkemhez hasonlóan, Hogyan javítsuk a teljesítményét szerver nélküli architektúrával, itt is mindent részletesen bemutatok. Ezután kipróbálhatja maga, és van egy egyszerű működő példája, amelyre építhet valami összetettebbet.

A példaalkalmazás egy Movie kereső lesz. Egyetlen JSON API-végpontja lesz, amely lehetővé teszi, hogy teljes szövegű keresést végezzen a filmcímek és áttekintések között.

1. Elasticsearch beállítás

Az Elasticsearch egy elosztott, RESTful kereső és elemző motor, amely egyre több felhasználási eset megoldására képes. Az Elastic Stack szíveként központilag tárolja az adatait, így felfedezheti a vártakat és feltárhatja a váratlanokat. - www.elastic.co/products/elasticsearch

A DB-Engines keresőmotorok rangsorolása szerint az Elasticsearch ma (2018 áprilisától) messze a legnépszerűbb keresőmotor-platform. És 2015 vége óta, amikor az Amazon bejelentette az AWS Elasticsearch Service elindítását, egy módszert az Elasticsearch fürt elindításához az AWS Management konzolról.

Az Elasticsearch egy nyílt forráskód. Letöltheti a kívánt verziót a webhelyükről, és futtathatja, ahol csak akarja. Bár azt javaslom, hogy az AWS Elasticsearch szolgáltatást használja termelési környezetekhez, inkább az Elasticsearch alkalmazást futtatom a helyi gépemen tesztelésre és fejlesztésre.

Kezdjük azzal, hogy letöltjük a (jelenleg) legfrissebb Elasticsearch verziót (6.2.3), és kicsomagoljuk. Nyisson meg egy terminált és futtassa

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

Alternatív megoldásként letöltheti az Elasticsearch alkalmazást a böngészőből itt, és kicsomagolhatja a kívánt programmal.

2. Tesztelje a Környezet beállítását

Felépítünk egy háttéralkalmazást a Ruby on Rails 5 API-val. Egy modellje lesz, amely a Filmek képviseletét képviseli. Az Elasticsearch indexeli, és az API-végponton keresztül kereshető lesz.

Először hozzunk létre egy új sínek alkalmazást. Ugyanabban a mappában, amelyet korábban letöltött az Elasticsearch alkalmazásból, futtassa az új sínalkalmazás létrehozásának parancsát. Ha még nem ismeri a Ruby on Rails alkalmazást, kérjük, olvassa el ezt a kezdő útmutatót a környezetének beállításához.

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

Az „api” opció használata esetén az elsődlegesen a böngészőalkalmazásokhoz használt összes köztes szoftvert nem tartalmazza. Pontosan azt, amire vágyunk. Tudjon meg többet erről közvetlenül a rubin on rail síneken.

Most tegyük hozzá az összes drágakövet, amire szükségünk lesz. Nyissa meg a Gemfile fájlt, és adja hozzá a következő kódot:

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

Két Elasticsearch Gem-et adunk hozzá, amelyek minden szükséges módszert megadnak a modellünk indexeléséhez és a keresési lekérdezések futtatásához. Az teszteléshez az rspec, az rspec-rails, az adatbázis_tisztító és az elasticsearch-kiterjesztéseket használják.

A Gemfile mentése után futtassa a csomag telepítését az összes hozzáadott Gems telepítéséhez.

Most konfiguráljuk az Rspec-et a következő parancs futtatásával:

rails generate rspec:install

Ez a parancs létrehoz egy spec mappát, és hozzáadja a spec_helper.rb és a rails_helper.rb fájlokat . Használhatók az rspec testreszabására az alkalmazás igényeinek megfelelően.

Ebben az esetben hozzáadunk egy DatabaseCleaner blokkot a rails_helper.rb fájlhoz, így minden egyes teszt üres adatbázisban fog futni. Ezenkívül módosítani fogjuk a spec_helper.rb fájlt annak érdekében, hogy az Elasticsearch tesztkiszolgálót elindítsuk minden alkalommal, amikor a tesztcsomag elindul, és a tesztcsomag befejezése után újra leállítjuk.

Ez a megoldás Rowan Oulton Testing Elasticsearch in Rails cikkén alapul. Sok taps érte!

Kezdjük a DatabaseCleaner-rel. A spec / rails_helper.rb oldalon írja be a következő kódot:

# 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

Gondoljunk ezután az Elasticsearch tesztkiszolgáló beállítására. Hozzá kell adnunk néhány konfigurációs fájlt, hogy a Rails tudja, hol található az Elasticsearch futtatható fájlunk. Azt is megmondja, hogy a jelenlegi környezet alapján melyik porton akarjuk futtatni. Ehhez adjon hozzá egy új konfigurációs yaml-t a konfigurációs mappához:

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

Ha nem a Rails alkalmazást hozta létre ugyanabban a mappában, ahová letöltötték az Elasticsearch alkalmazást, vagy ha az Elasticsearch más verzióját használja, akkor itt módosítania kell az es_bin elérési utat.

Most adjon hozzá egy új fájlt az inicializáló mappához, amely az imént hozzáadott konfigurációból fog olvasni:

# 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

Végül változtassuk meg a spec_helper.rb fájlt, hogy tartalmazza az Elasticsearch tesztbeállítást . Ez azt jelenti, hogy elindít és leállít egy Elasticsearch tesztkiszolgálót, és létrehozza / törli az Elasticsearch indexeket a Rails modellünkhöz.

# 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

Négy blokkot definiáltunk:

  1. egy előtti (: all) blokk, amely elindít egy Elasticsearch tesztkiszolgálót, hacsak az még nem fut
  2. egy after (: suite) blokk, amely leállítja az Elasticsearch tesztkiszolgálót, ha fut
  3. egy előtti (: mindegyik) blokk, amely új Elasticsearch indexet hoz létre az Elasticsearch alkalmazással konfigurált minden modellhez
  4. egy utáni (: mindegyik) blokk, amely törli az összes Elasticsearch indexet

Az elasticsearch: true hozzáadása biztosítja, hogy csak az elasticsearch címkével ellátott tesztek futtassák ezeket a blokkokat.

Megállapítottam, hogy ez a beállítás remekül működik, ha az összes tesztet egyszer futtatja, például a telepítés előtt. Másrészt, ha tesztvezérelt fejlesztési megközelítést használ, és nagyon gyakran futtatja a teszteket, akkor valószínűleg kissé módosítania kell ezt a konfigurációt. Nem akarja elindítani és leállítani az Elasticsearch tesztkiszolgálót minden tesztfutásnál.

Ebben az esetben megjegyzést fűzhet a (: suite) blokkhoz, ahol a tesztkiszolgáló le van állítva. Leállíthatja manuálisan, vagy egy szkript használatával, amikor már nincs rá szüksége.

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. Modellindexálás az Elasticsearch segítségével

Most elkezdjük a filmmodellünk megvalósítását keresési képességekkel. Tesztvezérelt fejlesztési megközelítést alkalmazunk. Ez azt jelenti, hogy először teszteket írunk, látjuk, hogy sikertelenek, majd kódot írunk, hogy sikeresek legyenek.

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
A kontroller tesztjének tesztelnie kell a vezérlő viselkedését. A kontroller tesztnek nem szabad kudarcot vallania a modell problémái miatt.

(20. recept - 4. sín tesztelőírások. Noel Rappin)

Átalakítsuk ezt kódgá. A spec / controllers / api / v1 / movies_spec.rb fájlba írja be a következő kódot:

# 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

A teszt azonnal meghiúsul, mert az Api :: V1 :: MoviesController nincs meghatározva, ezért először tegyük ezt meg. Hozza létre a mappaszerkezetet, mint korábban, és adja hozzá a filmek vezérlőjét.

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

Most adja hozzá a következő kódot az app / controllers / api / v1 / movies_controller.rb fájlhoz :

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

Itt az ideje, hogy lefuttassuk tesztünket, és lássuk, kudarcot vall.

Minden teszt sikertelen, mert még mindig hozzá kell adnunk egy útvonalat a végponthoz. A config / route.rb fájlban adja hozzá a következő kódot:

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

Futtassa újra a teszteket, és nézze meg, mi történik.

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!