Как добавить мощную поисковую систему в ваш сервер Rails

kak dobavit moshhnuyu poiskovuyu sistemu v vash server rails

Доменико Ангилетта

В своем опыте разработчика Ruby on Rails мне часто приходилось добавлять функции поиска в веб-приложения. Фактически, почти все программы, над которыми я когда-то работал, нуждались в возможностях поисковой системы, в то время как у многих из них поисковая система была важнейшей основной функциональностью.

Многие программы, которые мы используем каждый день, были бы бесполезными без хорошей поисковой системы. Например, на Amazon вы можете найти определенный продукт среди более чем 550 миллионов продуктов, доступных на сайте, в считанные секунды — и все благодаря полнотекстовому поиску в сочетании с фильтрами категорий, фасетами и системой рекомендаций.

На Airbnb вы можете искать квартиру, комбинируя геопространственный поиск с фильтрами по характеристикам дома, например размеры, цена, свободные даты и т.д.

И Spotify, Netflix, Ebay, Youtube… все они во многом полагаются на поисковую систему.

В этой статье я опишу, как создать серверный API Ruby on Rails 5 с помощью Elasticsearch. Согласно рейтингу DB Engines, Elasticsearch является наиболее популярной поисковой платформой с открытым кодом.

Эта статья не будет вдаваться в детали Elasticsearch и сравнить его с такими конкурентами, как Sphinx и Solr. Это будет пошаговая инструкция по реализации JSON API Backend из Ruby on Rails и Elasticsearch, используя подход разработки, управляемой тестированием.

Эта статья охватывает:

  1. Настройка Elasticsearch для тестирования, разработки и производственной среды
  2. Настройка тестовой среды Ruby on Rails
  3. Индексация модели с помощью Elasticsearch
  4. Конечная точка API поиска

Как и в моей предыдущей статье «Как повысить производительность с помощью бессерверной архитектуры», я расскажу обо всем в пошаговом руководстве. Затем вы можете попробовать самостоятельно и иметь простой рабочий пример, на основе которого можно построить что-то сложнее.

Примером программы будет поисковая система фильмов. У него будет единственная конечная точка JSON API, которая позволяет осуществлять полнотекстовый поиск по названиям и обзорам фильмов.

1. Настройка Elasticsearch

Elasticsearch – это распределенная поисковая и аналитическая система RESTfull, способная решать все большее количество случаев использования. Будучи сердцем Elastic Stack, он централизованно сохраняет ваши данные, чтобы вы могли открывать ожидаемое и обнаруживать неожиданное. — www.elastic.co/products/elasticsearch

Согласно рейтингу поисковых систем DB-Engines, Elasticsearch на сегодняшний день является самой популярной платформой поисковых систем (по состоянию на апрель 2018 года). И это было с конца 2015 года, когда Amazon объявила о запуске AWS Elasticsearch Service, способе запустить кластер Elasticsearch из консоли управления AWS.

изображение-28
Тенденция рейтинга поисковых систем DB Engines

Elasticsearch является открытым кодом. Вы можете загрузить желаемую версию с их веб-сайта и запустить ее где угодно. Хотя я предлагаю использовать службу AWS Elasticsearch для производственных сред, я предпочитаю запуск Elasticsearch на моей локальной машине для тестирования и разработки.

Давайте начнем с загрузки (на данный момент) последней версии Elasticsearch (6.2.3) и распакуем ее. Откройте терминал и запустите

$ wget 

$ unzip elasticsearch-6.2.3.zip

Кроме того, вы можете скачать Elasticsearch из своего браузера здесь и распаковать его с помощью нужного приложения.

2. Настройка тестовой среды

Мы собираемся создать бэкенд-приложение из Ruby on Rails 5 API. У него будет одна модель, представляющая фильмы. Elasticsearch проиндексирует его, и это будет доступно для поиска через конечную точку API.

Прежде всего, давайте создадим новую программу rails. В той же папке, которую вы скачали Elasticsearch ранее, запустите команду для создания новой программы rails. Если вы новичок в Ruby on Rails, сначала обратитесь к этому руководству, чтобы настроить вашу среду.

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

Если используется параметр «api», все промежуточное программное обеспечение, используемое в основном для браузера, не включается. Именно то, что мы хотим. Подробнее об этом читайте прямо в пособии ruby ​​on rails.

Теперь давайте добавим все драгоценные камни, которые нам пригодятся. Откройте свой Gemfile и добавьте следующий код:

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

Мы добавляем два Gems Elasticsearch, которые предоставят все необходимые методы индексирования нашей модели и выполнения поисковых запросов к ней. Для тестирования используются расширения rspec, rspec-rails, database_cleaner и elasticsearch-extensions.

После сохранения вашего Gemfile запустите установить пакет чтобы установить все добавленные драгоценные камни.

Теперь давайте настроим Rspec, выполнив следующую команду:

rails generate rspec:install

Эта команда создаст a спец папку и добавьте spec_helper.rb и rails_helper.rb к нему. Их можно использовать для настройки rspec в соответствии с потребностями программы.

В этом случае мы добавим блок DatabaseCleaner к rails_helper.rb поэтому каждый тест будет выполняться в пустой базе данных. Кроме того, мы будем модифицировать spec_helper.rb чтобы запускать тестовый сервер Elasticsearch всякий раз, когда запускается набор тестов, и выключать его снова, когда набор тестов завершится.

Это решение основано на статье Роуэна Оултона Тестирование Elasticsearch в Rails. Много хлопков ему!

Начнём с DatabaseCleaner. Внутри spec/rails_helper.rb добавьте следующий код:

# 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

Далее подумаем о настройках тестового сервера Elasticsearch. Нам нужно добавить несколько конфигурационных файлов, чтобы Rails знал, где найти наш исполняемый файл Elasticsearch. Он также сообщит, на каком порту мы хотим, чтобы он запускался в зависимости от текущей среды. Для этого добавьте новую конфигурацию yaml в папку config:

# config/elasticsearch.yml

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

Если вы не создали программу rails в той же папке, где загрузили Elasticsearch или если вы используете другую версию Elasticsearch, вам нужно будет настроить путь es_bin здесь.

Теперь добавьте новый файл в свой инициализаторы папка, которая будет читать из конфигурации, которую мы только что добавили:

# 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

И наконец давайте изменимся spec_helper.rb включить тестовую настройку Elasticsearch. Это означает запуск и остановку тестового сервера Elasticsearch и создание/удаление индексов Elasticsearch для нашей модели 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

Мы определили четыре блока:

  1. блок before(:all), запускающий тестовый сервер Elasticsearch, если он уже не запущен
  2. блок after(:suite), который останавливает тестовый сервер Elasticsearch, если он запущен
  3. блок before(:each), создающий новый индекс Elasticsearch для каждой модели, настроенной с помощью Elasticsearch
  4. блок after(:each), удаляющий все индексы Elasticsearch

Добавление elasticsearch: правда гарантирует, что только тесты, отмеченные тегами эластичный поиск будет запускать эти блоки.

Я считаю, что эта настройка отлично работает, когда вы запускаете все свои тесты один раз, например, перед развертыванием. С другой стороны, если вы используете подход к разработке, управляемый тестами, и вы запускаете свои тесты очень часто, то вам, вероятно, придется немного изменить эту конфигурацию. Вы не хотите запускать и останавливать тестовый сервер Elasticsearch во время каждого тестового запуска.

В этом случае вы можете закомментировать блок after(:suite), где остановлен тестовый сервер. Вы можете выключить его вручную или с помощью сценария, когда он вам больше не нужен.

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. Индексация модели с помощью Elasticsearch

Теперь мы начинаем реализовывать нашу модель фильма с возможностями поиска. Мы используем подход к разработке на основе тестирования. Это означает, что мы сначала пишем тесты, видим, как они проваливаются, а затем пишем код, чтобы сделать их успешными.

Сначала нам нужно добавить модель фильма, имеющую четыре атрибута: название (String), обзор (Text), image_url (String) и среднее значение голосования (Float).

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

$ rails db:migrate

Теперь пора добавить Elasticsearch к нашей модели. Давайте напишем тест, проверяющий, что наша модель индексирована.

# 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

Этот тест проверит, создан ли индекс эластичного поиска для Movie. Помните, что перед началом тестов, мы автоматически создаем индекс elasticsearch для всех моделей, соответствующих методу __elasticsearch__. Это означает для всех моделей, включающих модули elasticsearch.

Запустите тест, чтобы увидеть его неудачу.

bundle exec rspec spec/models/movie_spec.rb
изображение-29

При первом запуске этого теста вы должны увидеть, что запускается тестовый сервер Elasticsearch. Тест провалился, поскольку мы не добавили ни одного модуля Elasticsearch в нашу модель Movie. Давайте поправим это сейчас. Откройте модель и добавьте Elasticsearch, чтобы включить:

# app/models/movie.rb

class Movie < ApplicationRecord
  include Elasticsearch::Model
end

Это добавит некоторые методы Elasticsearch к нашей модели Movie, например, отсутствуют __elasticsearch__ метод (вызвавший ошибку во время предварительного тестового запуска) и Поиск метод, который мы используем позже.

Запустите тест снова и убедитесь, что он прошел.

bundle exec rspec spec/models/movie_spec.rb
изображение-30

Прекрасно. У нас есть индексированная модель фильма.

По умолчанию, Elasticsearch::Model настроит индекс со всеми атрибутами модели, автоматически определяя их типы. Обычно это не то, что мы хотим. Теперь мы настроим индекс модели так, чтобы он имел такое поведение:

  1. Индексировать следует только название и обзор
  2. Необходимо использовать производную форму (это означает, что поиск по слову «актеры» также должен возвращать фильмы, содержащие текст «актер» и наоборот)

Мы также хотим, чтобы наш указатель обновлялся всякий раз, когда фильм добавляется, обновляется или удаляется.

Давайте переведем это в тесты, добавив следующий код в 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

Мы создаем фильм перед каждым тестом, потому что мы настроили DatabaseCleaner так, чтобы каждый тест был изолирован. Movie.__elasticsearch__.refresh_index! нужен, чтобы убедиться, что новая запись фильма немедленно доступна для поиска.

По-прежнему запустите тест и убедитесь, что он провалился.

hBHAsDeOfCGgK1yL3h4Be9DJfM71fiyF4vBJ

Похоже, наш фильм не индексируется. Это потому, что мы еще не сказали нашей модели, что делать, когда данные фильма изменяются. К счастью, это исправимо, добавив другой модуль к нашей модели Movie:

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

С Elasticsearch::Модель::Обратные звонки, всякий раз, когда фильм добавляется, меняется или удаляется, его документ на Elasticsearch также обновляется.

Давайте посмотрим, как изменится результат теста.

gpShemjWeele16xaHXxR1NuEKbF9-86FXcUq

В порядке. Теперь проблема заключается в том, что наш метод поиска также возвращает совпадающие по атрибутам запросы. vote_verage и image_url. Чтобы исправить это, нужно настроить отображение индекса Elasticsearch. Поэтому нам необходимо указать Elasticsearch именно, какие атрибуты модели индексировать.

# 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

Запустите тест снова и убедитесь, что он прошел.

K4poOG6jNnEwWwXXAi8zeO1aLrvtlSz1FEZ9

Круто. Теперь давайте добавим стемер, чтобы не было разницы между «актером» и «актером». Как обычно, мы сначала напишем тест и увидим, что он провалился.

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

Обратите внимание, что мы проверяем оба способа: Holidays должен возвращать также Holiday, а Film также должен возвращать Films.

cH6SSsE20qS5FAQDPnahffP31Ezo6ULIhkhI

Чтобы эти тесты прошли снова, нам нужно изменить отображение индекса. На этот раз мы сделаем это, добавив анализатор английского языка к обоим полям:

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

Запустите тесты снова, чтобы убедиться, что они прошли.

qTHYsfwiB6X1LGOHatnN209o5oyfiI0p4Sdm

Elasticsearch – это очень мощная поисковая платформа, и мы могли бы добавить много функций к нашему методу поиска. Но это не входит в рамки данной статьи. Мы остановимся на этом и перейдем к созданию контроллерной части JSON API, через которую осуществляется доступ к методу поиска.

4. Конечная точка API поиска

API поиска, который мы создаем, должен разрешить пользователям осуществлять полнотекстовый поиск в таблице фильмов. Наш API имеет единственную конечную точку, определенную следующим образом:

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":"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"}},...]

Здесь мы определяем нашу конечную точку в соответствии с некоторыми лучшими практиками RESTful API Design:

  1. URL-адрес должен кодировать объект или ресурс, тогда как действие, которое требуется выполнить, должно быть закодировано методом HTTP. В этом случае ресурсом является фильмы (коллекция), и мы используем метод HTTP ПОЛУЧИТЬ (поскольку мы спрашиваем данные по ресурсу без каких-либо побочных эффектов). Мы используем параметры URL, чтобы дополнительно определить, как эти данные должны быть получены. В этом примере q=[string], определяющий поисковый запрос. Вы можете прочитать больше о том, как разработать RESTful API, в статье Mahesh Haldar RESTful API Designing guidelines — The best practices.
  2. Мы также добавляем управление версиями к нашему API, добавляя v1 на наш URL конечной точки. Версионность вашего API очень важна, поскольку она позволяет вводить новые функции, несовместимые с предыдущими выпусками, не нарушая работу всех клиентов, разработанных для предыдущих версий вашего API.

В порядке. Приступаем к реализации.

Как обычно, мы начинаем с неудачных тестов. Внутри папки spec мы создадим структуру папок, отображающую структуру URL-адресов конечной точки API. Это означает контроллеры →api →v1 →movies_spec.rb

UDUKtuyclu53rn5x7RkEvAD2XlAKYIboq4ZR

Вы можете сделать это вручную или с помощью терминала:

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

Тесты, которые мы собираемся написать здесь, являются тестами контроллера. Им не нужно проверять логику поиска, указанную в модели. Вместо этого мы проверим три вещи:

  1. Запрос GET в /api/v1/movies?q=[string] вызовет Movie.search с помощью [string] как параметр
  2. Результаты Movie.search возвращаются в формате JSON
  3. Статус успеха возвращается

Тест контроллера должен проверить поведение контроллера. Тест контроллера не должен быть неудачным из-за проблем в модели.

(Рецепт 20 — Рельсы 4 Тестовые рецепты. Ноэль Раппин)

Давайте превратим это в код. Внутри spec/controllers/api/v1/movies_spec.rb добавьте следующий код:

# 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

Тест сразу завершится неудачей, поскольку Api::V1::MoviesController не определен, так что сделаем это сначала. Создайте структуру папок по-прежнему и добавьте контроллер фильмов.

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

Теперь добавьте следующий код в 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

Пора запустить наш тест и увидеть его неудачу.

изображение-31

Все тесты проваливаются, потому что нам все равно нужно добавить маршрут для конечной точки. Внутри config/routes.rb добавьте следующий код:

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

Повторите свои тесты и посмотрите, что произойдет.

изображение-32

Первая ошибка говорит нам, что нам нужно добавить вызов в Movie.search в нашем контроллере. Второй жалуется на реакцию. Давайте добавим недостающий код к 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

Запустите тест и проверьте, закончили ли мы.

GqmPo8HFTdXbQj9qBP1bzwjtHV35WojIL6zA

Да. Это все. Мы разработали базовую программу, позволяющую пользователям искать модель через API.

Вы можете найти полный код в моем хранилище GitHub здесь. Вы можете заполнить таблицу Movie некоторыми данными, запустив rails db:seed, чтобы вы могли видеть приложение в действии. Это импортирует примерно 45 тысяч фильмов из набора данных, загруженного из Kaggle. Просмотрите файл Readme, чтобы узнать больше.

Если вам понравилась статья, поделитесь ею в социальных сетях. Спасибо!

Добавить комментарий

Ваш адрес email не будет опубликован.