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

Foto af Simon Abrams på Unsplash

Efter min erfaring som Ruby on Rails-udvikler måtte jeg ofte beskæftige mig med at tilføje søgefunktionalitet til webapplikationer. Faktisk krævede næsten alle applikationer, jeg arbejdede på et tidspunkt, søgemaskinefunktioner, 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. På Amazon kan du f.eks. Finde et bestemt produkt blandt de mere end 550 millioner produkter, der er tilgængelige på webstedet i løbet af få sekunder - alt 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 om husets egenskaber, som dimension, pris, tilgængelige datoer og så videre.

Og Spotify, Netflix, Ebay, Youtube… alle er meget 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å nærmere ind på detaljerne i Elasticsearch, og hvordan den kan sammenlignes med sine konkurrenter som Sphinx og Solr. I stedet vil det være en trin-for-trin guide til, hvordan man implementerer en JSON API Backend med Ruby on Rails og Elasticsearch ved hjælp af en Test Driven Development-tilgang.

Denne artikel vil dække:

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

Som i min tidligere artikel, Hvordan du øger din ydelse med serverløs arkitektur, vil jeg dække alt i en trinvis vejledning. Derefter kan du prøve det selv og få et simpelt arbejdseksempel til at bygge noget mere komplekst.

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

1. Elasticsearch Setup

Elasticsearch er en distribueret, RESTful søgning og analysemotor, der er i stand til at løse et stigende antal brugssager. Som hjertet i Elastic Stack gemmer den dine data centralt, så du kan opdage det forventede og afsløre det uventede. - www.elastic.co/products/elasticsearch

I henhold til DB-Engines 'ranking af søgemaskiner er Elasticsearch den langt 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 fra AWS Management-konsollen.

DB motorer Trendmotor til søgemaskine

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 https://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. Opsætning af testmiljø

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

Lad os først oprette en ny skinnerapplikation. I den samme mappe, som du downloadede Elasticsearch før, 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 til opsætning af dit miljø først.

$ skinner ny film-søgning --api; cd-film-søgning

Når du bruger "api" -indstillingen, er al den mellemware, der primært bruges til browserapplikationer, ikke inkluderet. Præcis hvad vi ønsker. Læs mere om det direkte i rubin on rails guide.

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

# Gemfile
...
# Elasticsearch-integration
perle 'elasticsearch-model'
perle 'elasticsearch-rails'
gruppe: udvikling,: test do
  ...
  # Testramme
  perle 'rspec'
  perle 'rspec-rails'
ende
gruppe: test do
  ...
  # Rengør database mellem test
  perle 'database_cleaner'
  # Programmatisk start og stop ES til test
  perle 'elasticsearch-extensions'
ende
...

Vi tilføjer to Elasticsearch-perler, 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, skal du køre bundle-installation for at installere alle tilføjede perler.

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

skinner genererer rspec: installere

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 testes i en tom database. Desuden vil vi ændre spec_helper.rb for at starte en Elasticsearch-testserver, hver gang testpakken startes, og lukke den ned igen, når testpakken er afsluttet.

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

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

# spec / rails_helper.rb
...
RSpec.configure do | config |
  ...
config.before (: suite) do
    DatabaseCleaner.strategy =: transaktion
    DatabaseCleaner.clean_with (: trunkering)
  ende
config.around (: each) do | eksempel |
    DatabaseCleaner.cleaning gør
      example.run
    ende
  ende
ende

Lad os derefter tænke på Elasticsearch testserveropsætningen. Vi er nødt til at tilføje nogle konfigurationsfiler, så Rails ved, hvor vi kan finde vores Elasticsearch-eksekverbare. Den fortæller den også, på hvilken port vi ønsker, at den skal køre, baseret på det aktuelle miljø. For at gøre det, tilføj en ny konfigurationsyaml til din konfigurationsmappe:

# config / elasticsearch.yml
udvikling: & standard
  es_bin: '../elasticsearch-6.2.3/bin/elasticsearch'
  vært: 'http: // localhost: 9200'
  port: '9200'
prøve:
  es_bin: '../elasticsearch-6.2.3/bin/elasticsearch'
  vært: 'http: // localhost: 9250'
  port: '9250'
iscenesættelse:
  <<: * standard
produktion:
  es_bin: '../elasticsearch-6.2.3/bin/elasticsearch'
  vært: 'http: // localhost: 9400'
  port: '9400'

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

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

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

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

# spec / spec_helper.rb
kræver 'elasticsearch / extensions / test / cluster'
kræver 'yaml'
RSpec.configure do | config |
  ...
  # Start en hukommelse-klynge til Elasticsearch efter behov
  es_config = YAML.load_file ("config / elasticsearch.yml") ["test"]
  ES_BIN = es_config ["es_bin"]
  ES_PORT = es_config ["port"]
config.before: alle, elasticsearch: true do
    Elasticsearch :: Extensions :: Test :: Cluster.start (kommando: ES_BIN, port: ES_PORT.to_i, noder: 1, timeout: 120) medmindre Elasticsearch :: Extensions :: Test :: Cluster.running? (Kommando: ES_BIN, på: ES_PORT.to_i)
  ende
# Stop elasticsearch-klynge efter testkørsel
  config.after: suite do
    Elasticsearch :: Extensions :: Test :: Cluster.stop (kommando: ES_BIN, port: ES_PORT.to_i, noder: 1) hvis Elasticsearch :: Extensions :: Test :: Cluster.running? (Kommando: ES_BIN, på: ES_PORT. to_i)
  ende
# Opret indekser for alle elastiske søgbare modeller
  config.before: hver, elasticsearch: true do
    ActiveRecord :: Base.descendants.each do | model |
      hvis model.respond_to? (: __ elasticsearch__)
        begynde
          model .__ elasticsearch __. create_index!
          model .__ elasticsearch __. refresh_index!
        redning => Elasticsearch :: Transport :: Transport :: Fejl :: NotFound
          # Dette dræber "Indeks findes ikke" -fejl, der skrives til konsol
        redning => e
          STDERR.puts "Der opstod en fejl ved oprettelse af elasticsearch-indekset for # {model.name}: # {e.inspect}"
        ende
      ende
    ende
  ende
# Slet indekser for alle elastiske søgbare modeller for at sikre ren tilstand mellem testene
  config.after: hver, elasticsearch: true do
    ActiveRecord :: Base.descendants.each do | model |
      hvis model.respond_to? (: __ elasticsearch__)
        begynde
          model .__ elasticsearch __. delete_index!
        redning => Elasticsearch :: Transport :: Transport :: Fejl :: NotFound
          # Dette dræber "Indeks findes ikke" -fejl, der skrives til konsol
        redning => e
          STDERR.puts "Der opstod en fejl ved fjernelse af elasticsearch-indekset for # {model.name}: # {e.inspect}"
        ende
      ende
    ende
  ende
ende

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: true sikrer, at kun test, 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 test én gang, for eksempel før en implementering. På den anden side, hvis du bruger en testdrevet udviklingsmetode, og du kører dine test meget ofte, 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 blokken efter (: suite), hvor testserveren er stoppet. Du kan lukke det manuelt eller ved hjælp af et script, når du ikke har brug for det mere.

kræver '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 (kommando: ES_BIN, port: ES_PORT.to_i, noder: 1)

3. Modelindeksering med Elasticsearch

Nu begynder vi at implementere vores filmmodel med søgefunktioner. Vi bruger en Test Driven Development-tilgang. Det betyder, at vi først skriver test, ser dem mislykkes og derefter skriver kode for at få dem til at bestå.

Først skal vi tilføje filmmodellen, der har fire attributter: en titel (streng), en oversigt (tekst), en image_url (streng) og en gennemsnitlig stemmeværdi (Float).

$ rails g model Filmtitel: strengoversigt: tekst image_url: string vote_average: float
$ rails db: migrere

Nu er det tid til at tilføje Elasticsearch til vores model. Lad os skrive en test, der kontrollerer, at vores model er indekseret.

# spec / models / movie_spec.rb
kræver 'rails_helper'
RSpec.describe Film, elasticsearch: true,: type =>: model do
  det 'skal indekseres' gør
     forventer (Movie .__ elasticsearch __. index_exists?). til at være_ret
  ende
ende

Denne test vil kontrollere, om der blev oprettet et elasticsearch-indeks til film. Husk, at før test begynder, opretter vi automatisk et elasticsearch-indeks for alle modeller, der reagerer på __elasticsearch__-metoden. Det betyder for alle modeller, der inkluderer elasticsearch-modulerne.

Kør testen for at se, at den mislykkes.

bundle exec rspec spec / models / movie_spec.rb

Første gang du kører denne test, skal du se, at Elasticsearch Test Server starter. Testen mislykkes, fordi vi ikke tilføjede noget Elasticsearch-modul til vores filmmodel. Lad os ordne det nu. Åbn modellen, og tilføj følgende Elasticsearch for at inkludere:

# app / models / movie.rb
klasse Film 

Dette tilføjer nogle Elasticsearch-metoder til vores filmmodel, som den manglende __elasticsearch__-metode (som genererede fejlen i den forrige testkørsel) og søgemetoden, vi vil bruge senere.

Kør testen igen og se den passere.

bundle exec rspec spec / models / movie_spec.rb

Store. Vi har en indekseret filmmodel.

Som standard opsætter Elasticsearch :: Model et indeks med alle attributter for modellen og udleder automatisk deres typer. Normalt er det ikke det, vi ønsker. Vi tilpasser nu modelindekset, så det har følgende opførsel:

  1. Kun titel og oversigt skal indekseres
  2. Stemming bør bruges (hvilket betyder, at søgning efter "skuespillere" også skal returnere film, der indeholder teksten "skuespiller," og vice versa)

Vi ønsker også, at vores indeks skal opdateres, hver gang en film tilføjes, opdateres eller slettes.

Lad os oversætte dette til test ved at tilføje følgende kode til movie_spec.rb

# spec / models / movie_spec.rb
RSpec.describe Film, elasticsearch: true,: type =>: model do
  ...
beskriv '#search' gør
    før (: hver) gør
      Movie.create (
        titel: "Romersk ferie",
        oversigt: "En amerikansk romantisk komediefilm fra 1953 ...",
        image_url: "wikimedia.com/Roman_holiday.jpg",
        vote_average: 4.0
      )
      Film .__ elasticsearch __. Refresh_index!
    ende
    det "skal indeks titel" gøre
      forvente (Movie.search ("Holiday"). records.length). til ækv. (1)
    ende
    det "bør indeksoversigt" gøre
      forvente (Movie.search ("komedie"). records.length). til ækv. (1)
    ende
    det "skal ikke indeksere image_path" gøre
      forvente (Movie.search ("Roman_holiday.jpg"). records.length). til ækv. (0)
    ende
    det "skal ikke indeksere vote_average" gøre
      forvente (Movie.search ("4.0"). records.length). til ækv. (0)
    ende
  ende
ende

Vi opretter en film før hver test, fordi vi konfigurerede DatabaseCleaner, så hver test er isoleret. Film .__ elasticsearch __. Refresh_index! er nødvendigt for at være sikker på, at den nye filmrekord straks er tilgængelig til søgning.

Kør testen som før, og se den mislykkes.

Synes, at vores film ikke indekseres. Det skyldes, at vi endnu ikke har fortalt vores model, hvad vi skal gøre, når filmdataene ændres. Heldigvis kan dette rettes ved at tilføje et andet modul til vores filmmodel:

klasse Film 

Med Elasticsearch :: Model :: tilbagekald, når en film tilføjes, ændres eller slettes, opdateres dens dokument på Elasticsearch også.

Lad os se, hvordan testoutputet ændres.

Okay. Nu er problemet, at vores søgemetode også returnerer forespørgsler, der matcher attributterne vote_average og image_url. For at rette op på det er vi nødt til at konfigurere Elasticsearch-indeksmapping. Så vi er nødt til at fortælle Elasticsearch specifikt, hvilken model der tilskrives indeks.

# app / models / movie.rb
klasse Film 
# ElasticSearch-indeks
  indstillingsindeks: {number_of_shards: 1} do
    kortlægning dynamisk: 'falsk' gør
      indekser: titel
      indekser: oversigt
    ende
  ende
ende

Kør testen igen og se den passere.

Fedt nok. Lad os nu tilføje en stemmer, så der ikke er nogen forskel mellem "skuespiller" og "skuespillere." Som altid vil vi først skrive testen og se den mislykkes.

beskriv '#search' gør
    før (: hver) gør
      Movie.create (
        titel: "Romersk ferie",
        oversigt: "En amerikansk romantisk komediefilm fra 1953 ...",
        image_url: "wikimedia.com/Roman_holiday.jpg",
        vote_average: 4.0
      )
      Film .__ elasticsearch __. Refresh_index!
    ende
...
det "skal gælde, der stammer til titel" do
      forvente (Movie.search ("Ferier"). records.length). til ækv. (1)
    ende
det "skal gælde stammende til oversigt" gør
      forvente (Movie.search ("film"). records.length). til ækv. (1)
    ende
ende

Bemærk, at vi tester begge måder: Ferie skal også vende tilbage til ferie, og film skal også returnere film.

For at få disse test til at gå igen, er vi nødt til at ændre indeksmapping. Det gør vi denne gang ved at tilføje en engelsk analysator til begge felter:

klasse Film 
# ElasticSearch-indeks
  indstillingsindeks: {number_of_shards: 1} do
    kortlægning dynamisk: 'falsk' gør
      indekser: titel, analysator: 'engelsk'
      indekser: oversigt, analysator: 'engelsk'
    ende
  ende
ende

Kør dine test igen for at se dem passere.

Elasticsearch er en meget kraftig søgeplatform, og vi kunne tilføje en masse funktionaliteter til vores søgemetode. Men dette er ikke inden for anvendelsesområdet for denne artikel. Så vi vil stoppe her og gå videre til at opbygge en controller-del af JSON API, gennem hvilken søgemetoden er tilgængelig.

4. Søg API-slutpunkt

Det søge-API, vi bygger, skal give brugerne mulighed for at foretage en fuldtekstsøgning på filmtabellen. Vores API har et enkelt slutpunkt defineret som følger:

url:
 GET / api / v1 / film
Parametre:
 * q = [streng] påkrævet
Eksempel url:
 GET / api / v1 / film? Q = roma
Eksempel svar:
[{ "_Index": "film", "_ type": "film", "_ id": "95.088", "_ score": 11,549209, "_ kilde": { "id": 95.088, "title": "Roma" , "oversigt": "Et næsten plottløst, glorent, impressionistisk portræt af Rom gennem en af ​​dens mest berømte borgere.", "image_url": "https://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"}}, ... ]

Her definerer vi vores slutpunkt i henhold til nogle af de bedste fremgangsmåder RESTful API Design:

  1. URL'en skal kode objektet eller ressourcen, mens handlingen, der skal udføres, skal kodes ved hjælp af HTTP-metoden. I dette tilfælde er ressourcen filmene (samling), og vi bruger HTTP-metoden GET (fordi vi anmoder om data fra ressourcen uden at give nogen bivirkning). Vi bruger URL-parametre til yderligere at definere, hvordan disse data skal opnås. I dette eksempel er q = [streng], der specificerer en søgeforespørgsel. Du kan læse mere om, hvordan du designer RESTful API'er i Mahesh Haldars artikel RESTful API Designing guidelines - The best practices.
  2. Vi tilføjer også versionering til vores API ved at tilføje v1 til vores endpoint URL. Versionering af din API er meget vigtig, fordi det giver dig mulighed for at introducere nye funktioner, der ikke er kompatible med tidligere udgivelser uden at bryde alle klienter, der blev udviklet til tidligere versioner af din API.

Okay. Lad os begynde at implementere.

Som altid begynder vi med fejlagtige test. Inde i spec-mappen opretter vi den mappestruktur, der reflekterer vores API-endpoint URL-struktur. Dette betyder controllere → api → v1 → films_spec.rb

Du kan gøre dette manuelt eller fra din terminal kører:

mkdir -p spec / controllere / api / v1 &&
berør spec / controllere / api / v1 / films_spec.rb

Testene, vi skal skrive her, er controller-tests. De behøver ikke at kontrollere den søgelogik, der er defineret i modellen. I stedet tester vi tre ting:

  1. En GET-anmodning til / api / v1 / film? Q = [streng] kalder Movie.search med [streng] som parameter
  2. Outputet fra Movie.search returneres i JSON-format
  3. En successtatus returneres
En controller-test skal teste controller-opførsel. 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 / controllere / api / v1 / films_spec.rb
kræver 'rails_helper'
RSpec.describe Api :: V1 :: MoviesController, type:: request do
  # Søg efter film med tekstfilm-titel
  beskriv "GET / api / v1 / film? q =" gør
    let (: title) {"movie-title"}
    let (: url) {"/ api / v1 / film? q = # {title}"}
det "kalder Movie.search med korrekte parametre" gør
      forvente (Film) .til modtage (: søgning) .med (titel)
      få url
    ende
det "returnerer output fra Movie.search" do
      tillad (Film) .til modtage (: søgning) .og_return ({})
      få url
      forventer (respons.body). til eq ({}. to_json)
    ende
det 'returnerer en successtatus' do
      tillad (Film) .til modtage (: søgning) .med (titel)
      få url
      forventer (svar) .for at være succesrig
    ende
  ende
ende

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

mkdir -p app / controllere / api / v1 &&
berør app / controllere / api / v1 / films_controller.rb

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

# app / controllere / api / v1 / films_controller.rb
modul Api
  modul V1
    klasse MoviesController 

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
  navneområde: api do
    navneområde: v1 do
      ressourcer: film, kun: [: index]
    ende
  ende
ende

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

Den første fejl fortæller os, at vi er nødt til at tilføje et opkald til Movie.search i vores controller. Den anden klager over svaret. Lad os tilføje den manglende kode til films_controller:

# app / controllere / api / v1 / films_controller.rb
modul Api
  modul V1
    klasse MoviesController 

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

Jep. Det er alt. Vi har afsluttet en virkelig grundlæggende backend-applikation, der giver brugerne mulighed for at søge i en model gennem API.

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

Hvis du nød denne artikel, kan du anbefale den ved at trykke på klappeikonet, som du finder nederst på denne side, så flere kan se den på Medium.