Ruby on Rails için Elasticsearch: Chewy Gem için Bir Eğitim

Yayınlanan: 2022-03-11

Elasticsearch, Apache Lucene kitaplığının üzerine inşa edilmiş, verileri indekslemek ve sorgulamak için güçlü, RESTful HTTP arabirimi sağlar. Kutudan çıkar çıkmaz UTF-8 desteğiyle ölçeklenebilir, verimli ve sağlam arama sağlar. Büyük miktarlarda yapılandırılmış veriyi indekslemek ve sorgulamak için güçlü bir araçtır ve burada Toptal'da platform aramamıza güç sağlar ve yakında otomatik tamamlama için de kullanılacaktır. Biz büyük hayranlarız.

Chewy, Elasticsearch-Ruby istemcisini genişleterek daha güçlü hale getirir ve Rails ile daha sıkı entegrasyon sağlar.

Platformumuz Ruby on Rails kullanılarak oluşturulduğundan, Elasticsearch entegrasyonumuz elasticsearch-ruby projesinden (Elasticsearch kümesine bağlanmak için bir istemci sağlayan Elasticsearch için bir Ruby entegrasyon çerçevesi, Elasticsearch'ün REST API'si için bir Ruby API ve çeşitli uzantılar ve yardımcı programlar). Bu temele dayanarak, Chewy adını verdiğimiz bir Ruby mücevheri olarak paketlenmiş Elasticsearch uygulama arama mimarisinde kendi iyileştirmemizi (ve basitleştirmemizi) geliştirdik ve yayınladık (burada bir örnek uygulama mevcuttur).

Chewy, Elasticsearch-Ruby istemcisini genişleterek daha güçlü hale getirir ve Rails ile daha sıkı entegrasyon sağlar. Bu Elasticsearch kılavuzunda, uygulama sırasında ortaya çıkan teknik engeller de dahil olmak üzere bunu nasıl başardığımızı (kullanım örnekleri aracılığıyla) tartışıyorum.

Elasticsearch ve Ruby on Rails arasındaki ilişki bu görsel kılavuzda gösterilmektedir.

Kılavuza geçmeden önce birkaç kısa not:

  • Hem Chewy hem de Chewy demo uygulaması GitHub'da mevcuttur.
  • Elasticsearch hakkında daha fazla "kaputun altında" bilgi ile ilgilenenler için, bu yazıya Ek olarak kısa bir yazı ekledim.

Neden Chewy?

Elasticsearch'ün ölçeklenebilirliğine ve verimliliğine rağmen, onu Rails ile entegre etmek beklendiği kadar basit olmadı. Toptal'da, daha performanslı hale getirmek ve ek işlemleri desteklemek için temel Elasticsearch-Ruby istemcisini önemli ölçüde artırmamız gerektiğini gördük.

Elasticsearch'ün ölçeklenebilirliğine ve verimliliğine rağmen, onu Rails ile entegre etmek beklendiği kadar basit olmadı.

Ve böylece Chewy mücevheri doğdu.

Chewy'nin özellikle dikkate değer birkaç özelliği şunları içerir:

  1. Her indeks, ilgili tüm modeller tarafından gözlemlenebilir.

    İndekslenen modellerin çoğu birbiriyle ilişkilidir. Ve bazen, bu ilgili verileri denormalize etmek ve aynı nesneye bağlamak gerekir (örneğin, bir etiket dizisini ilişkili makaleleriyle birlikte indekslemek istiyorsanız). Chewy, her model için güncellenebilir bir dizin belirlemenize olanak tanır, böylece ilgili bir etiket güncellendiğinde ilgili makaleler yeniden dizine eklenir.

  2. İndeks sınıfları ORM/ODM modellerinden bağımsızdır.

    Bu geliştirmeyle, örneğin, modeller arası otomatik tamamlamayı uygulamak çok daha kolay. Sadece bir indeks tanımlayabilir ve onunla nesne yönelimli bir şekilde çalışabilirsiniz. Diğer istemcilerden farklı olarak Chewy gem, dizin sınıflarını, veri içe aktarma geri aramalarını ve diğer bileşenleri manuel olarak uygulama ihtiyacını ortadan kaldırır.

  3. Toplu ithalat her yerde .

    Chewy, tam yeniden dizin oluşturma ve dizin güncellemeleri için toplu Elasticsearch API'sini kullanır. Aynı zamanda, atomik güncellemeler kavramını kullanır, değişen nesneleri bir atomik blok içinde toplar ve hepsini bir kerede günceller.

  4. Chewy, AR tarzı bir sorgu DSL'si sağlar.

    Zincirlenebilir, birleştirilebilir ve tembel olan bu geliştirme, sorguların daha verimli bir şekilde üretilmesini sağlar.

Tamam, tüm bunların mücevherde nasıl oynandığını görelim…

Elasticsearch için temel kılavuz

Elasticsearch'ün belgeyle ilgili birkaç kavramı vardır. Birincisi, birkaç types olabilen (bir type bir tür RDBMS tablosudur) bir dizi documents oluşan bir dizindir ( index bir database analogu).

Her belgenin bir dizi fields vardır. Her alan bağımsız olarak analiz edilir ve analiz seçenekleri, türüne göre mapping saklanır. Chewy, bu yapıyı nesne modelinde "olduğu gibi" kullanır:

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { title: { tokenizer: 'standard', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ author.name } field :author_id, type: 'integer' field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do field :title, analyzer: 'title' field :year, type: 'integer' field :author, value: ->{ director.name } field :author_id, type: 'integer', value: ->{ director_id } field :description field :tags, index: 'not_analyzed', value: ->{ tags.map(&:name) } end end end

Yukarıda, entertainment adı verilen bir Elasticsearch dizini üç türle tanımladık: book , movie ve cartoon . Her tür için, tüm dizin için bazı alan eşlemeleri ve bir dizi ayar tanımladık.

Bu yüzden EntertainmentIndex tanımladık ve bazı sorguları yürütmek istiyoruz. İlk adım olarak, dizini oluşturmamız ve verilerimizi içe aktarmamız gerekiyor:

 EntertainmentIndex.create! EntertainmentIndex.import # EntertainmentIndex.reset! (which includes deletion, # creation, and import) could be used instead

.import yöntemi, türlerimizi tanımlarken kapsamları geçtiğimiz için içe aktarılan verilerin farkındadır; bu nedenle, kalıcı depolamada depolanan tüm kitapları, filmleri ve çizgi filmleri içe aktaracaktır.

Bunu yaptıktan sonra bazı sorgular yapabiliriz:

 EntertainmentIndex.query(match: {author: 'Tarantino'}).filter{ year > 1990 } EntertainmentIndex.query(match: {title: 'Shawshank'}).types(:movie) EntertainmentIndex.query(match: {author: 'Tarantino'}).only(:id).limit(10).load # the last one loads ActiveRecord objects for documents found

Artık dizinimiz arama uygulamamızda kullanılmaya neredeyse hazır.

Ray entegrasyonu

Rails ile entegrasyon için ihtiyacımız olan ilk şey, RDBMS nesne değişikliklerine tepki verebilmektir. Chewy, update_index sınıf yönteminde tanımlanan geri aramalar aracılığıyla bu davranışı destekler. update_index iki argüman alır:

  1. "index_name#type_name" biçiminde sağlanan bir tür tanımlayıcısı
  2. Güncellenen nesneye veya nesne koleksiyonuna bir geri referansı temsil eden, yürütülecek bir yöntem adı veya blok

Her bağımlı model için bu geri aramaları tanımlamamız gerekiyor:

 class Book < ActiveRecord::Base acts_as_taggable belongs_to :author, class_name: 'Dude' # We update the book itself on-change update_index 'entertainment#book', :self end class Video < ActiveRecord::Base acts_as_taggable belongs_to :director, class_name: 'Dude' # Update video types when changed, depending on the category update_index('entertainment#movie') { self if movie? } update_index('entertainment#cartoon') { self if cartoon? } end class Dude < ActiveRecord::Base acts_as_taggable has_many :books has_many :videos # If author or director was changed, all the corresponding # books, movies and cartoons are updated update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end

Etiketler de dizine eklendiğinden, değişikliklere tepki vermeleri için bazı harici modellere maymun yamaları eklememiz gerekiyor:

 ActsAsTaggableOn::Tag.class_eval do has_many :books, through: :taggings, source: :taggable, source_type: 'Book' has_many :videos, through: :taggings, source: :taggable, source_type: 'Video' # Updating all tag-related objects update_index 'entertainment#book', :books update_index('entertainment#movie') { videos.movies } update_index('entertainment#cartoon') { videos.cartoons } end ActsAsTaggableOn::Tagging.class_eval do # Same goes for the intermediate model update_index('entertainment#book') { taggable if taggable_type == 'Book' } update_index('entertainment#movie') { taggable if taggable_type == 'Video' && taggable.movie? } update_index('entertainment#cartoon') { taggable if taggable_type == 'Video' && taggable.cartoon? } end

Bu noktada, kaydedilen veya yok edilen her nesne, karşılık gelen Elasticsearch dizin türünü güncelleyecektir.

atomiklik

Hala devam eden bir sorunumuz var. Birden fazla kitabı kaydetmek için books.map(&:save) gibi bir şey yaparsak, tek bir kitap her kaydedildiğinde entertainment dizininin güncellenmesini isteriz. Böylece, beş kitap kaydedersek, Chewy dizinini beş kez güncelleyeceğiz. Bu davranış REPL için kabul edilebilir, ancak performansın kritik olduğu denetleyici eylemleri için kesinlikle kabul edilemez.

Bu sorunu Chewy.atomic bloğuyla ele alıyoruz:

 class ApplicationController < ActionController::Base around_action { |&block| Chewy.atomic(&block) } end

Kısacası, Chewy.atomic bu güncellemeleri aşağıdaki gibi gruplandırır:

  1. after_save geri aramasını devre dışı bırakır.
  2. Kaydedilen kitapların kimliklerini toplar.
  3. Chewy.atomic bloğunun tamamlanmasının ardından, tek bir Elasticsearch dizin güncelleme talebi yapmak için toplanan kimlikleri kullanır.

Aranıyor

Artık bir arama arayüzü uygulamaya hazırız. Kullanıcı arayüzümüz bir form olduğundan, onu oluşturmanın en iyi yolu elbette FormBuilder ve ActiveModel'dir. (Toptal'da, ActiveModel arabirimlerini uygulamak için ActiveData kullanıyoruz, ancak en sevdiğiniz mücevheri kullanmaktan çekinmeyin.)

 class EntertainmentSearch include ActiveData::Model attribute :query, type: String attribute :author_id, type: Integer attribute :min_year, type: Integer attribute :max_year, type: Integer attribute :tags, mode: :arrayed, type: String, normalize: ->(value) { value.reject(&:blank?) } # This accessor is for the form. It will have a single text field # for comma-separated tag inputs. def tag_list= value self.tags = value.split(',').map(&:strip) end def tag_list self.tags.join(', ') end end

Sorgu ve filtreler öğreticisi

Artık öznitelikleri kabul edebilen ve typecast yapabilen ActiveModel benzeri bir nesnemiz olduğuna göre, aramayı uygulayalım:

 class EntertainmentSearch ... def index EntertainmentIndex end def search # We can merge multiple scopes [query_string, author_id_filter, year_filter, tags_filter].compact.reduce(:merge) end # Using query_string advanced query for the main query input def query_string index.query(query_string: {fields: [:title, :author, :description], query: query, default_operator: 'and'}) if query? end # Simple term filter for author id. `:author_id` is already # typecasted to integer and ignored if empty. def author_id_filter index.filter(term: {author_id: author_id}) if author_id? end # For filtering on years, we will use range filter. # Returns nil if both min_year and max_year are not passed to the model. def year_filter body = {}.tap do |body| body.merge!(gte: min_year) if min_year? body.merge!(lte: max_year) if max_year? end index.filter(range: {year: body}) if body.present? end # Same goes for `author_id_filter`, but `terms` filter used. # Returns nil if no tags passed in. def tags_filter index.filter(terms: {tags: tags}) if tags? end end

Denetleyiciler ve görünümler

Bu noktada modelimiz, geçirilen özniteliklerle arama isteklerini gerçekleştirebilir. Kullanım şöyle görünecek:

 EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search

Denetleyicide, Chewy belge sarmalayıcıları yerine tam ActiveRecord nesnelerini yüklemek istediğimizi unutmayın:

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) # In case we want to load real objects, we don't need any other # fields except for `:id` retrieved from Elasticsearch index. # Chewy query DSL supports Kaminari gem and corresponding API. # Also, we pass scopes for every requested type to the `load` method. @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) end end

Şimdi, entertainment/index.html.haml adresinde biraz HAML yazmanın zamanı geldi:

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| = f.text_field :query = f.select :author_id, Dude.all.map { |d| [d.name, d.id] }, include_blank: true = f.text_field :min_year = f.text_field :max_year = f.text_field :tag_list = f.submit - if @entertainments.any? %dl - @entertainments.each do |entertainment| %dt %h1= entertainment.title %strong= entertainment.class %dd %p= entertainment.year %p= entertainment.description %p= entertainment.tag_list = paginate @entertainments - else Nothing to see here

sıralama

Bonus olarak, arama işlevimize sıralamayı da ekleyeceğiz.

Başlık ve yıl alanlarının yanı sıra alaka düzeyine göre sıralama yapmamız gerektiğini varsayalım. Ne yazık ki, One Flew Over the Cuckoo's Nest başlığı ayrı terimlere bölünecek, bu nedenle bu farklı terimlere göre sıralama çok rastgele olacak; bunun yerine, başlığın tamamına göre sıralamak istiyoruz.

Çözüm, özel bir başlık alanı kullanmak ve kendi analizörünü uygulamaktır:

 class EntertainmentIndex < Chewy::Index settings analysis: { analyzer: { ... sorted: { # `keyword` tokenizer will not split our titles and # will produce the whole phrase as the term, which # can be sorted easily tokenizer: 'keyword', filter: ['lowercase', 'asciifolding'] } } } define_type Book.includes(:author, :tags) do # We use the `multi_field` type to add `title.sorted` field # to the type mapping. Also, will still use just the `title` # field for search. field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end {movie: Video.movies, cartoon: Video.cartoons}.each do |type_name, scope| define_type scope.includes(:director, :tags), name: type_name do # For videos as well field :title, type: 'multi_field' do field :title, index: 'analyzed', analyzer: 'title' field :sorted, index: 'analyzed', analyzer: 'sorted' end ... end end end

Ayrıca, arama modelimize hem bu yeni özellikleri hem de sıralama işleme adımını ekleyeceğiz:

 class EntertainmentSearch # we are going to use `title.sorted` field for sort SORT = {title: {'title.sorted' => :asc}, year: {year: :desc}, relevance: :_score} ... attribute :sort, type: String, enum: %w(title year relevance), default_blank: 'relevance' ... def search # we have added `sorting` scope to merge list [query_string, author_id_filter, year_filter, tags_filter, sorting].compact.reduce(:merge) end def sorting # We have one of the 3 possible values in `sort` attribute # and `SORT` mapping returns actual sorting expression index.order(SORT[sort.to_sym]) end end

Son olarak, sıralama seçenekleri seçim kutusunu ekleyerek formumuzu değiştireceğiz:

 = form_for @search, as: :search, url: entertainment_index_path, method: :get do |f| ... / `EntertainmentSearch.sort_values` will just return / enum option content from the sort attribute definition. = f.select :sort, EntertainmentSearch.sort_values ...

Hata yönetimi

Kullanıcılarınız ( veya AND ) gibi yanlış sorgular gerçekleştirirse, Elasticsearch istemcisi bir hata oluşturur. Bunu halletmek için denetleyicimizde bazı değişiklikler yapalım:

 class EntertainmentController < ApplicationController def index @search = EntertainmentSearch.new(params[:search]) @entertainments = @search.search.only(:id).page(params[:page]).load( book: {scope: Book.includes(:author)}, movie: {scope: Video.includes(:director)}, cartoon: {scope: Video.includes(:director)} ) rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e @entertainments = [] @error = e.message.match(/QueryParsingException\[([^;]+)\]/).try(:[], 1) end end

Ayrıca, hatayı görünümde oluşturmamız gerekiyor:

 ... - if @entertainments.any? ... - else - if @error = @error - else Nothing to see here

Elasticsearch sorgularını test etme

Temel test kurulumu aşağıdaki gibidir:

  1. Elasticsearch sunucusunu başlatın.
  2. Endekslerimizi temizleyin ve oluşturun.
  3. Verilerimizi içe aktarın.
  4. Sorgumuzu gerçekleştirin.
  5. Sonucu beklentilerimizle karşılaştırın.

1. adım için, elasticsearch-extensions gem'de tanımlanan test kümesini kullanmak uygundur. Projenizin Rakefile post-gem kurulumuna aşağıdaki satırı eklemeniz yeterlidir:

 require 'elasticsearch/extensions/test/cluster/tasks'

Ardından, aşağıdaki Rake görevlerini alacaksınız:

 $ rake -T elasticsearch rake elasticsearch:start # Start Elasticsearch cluster for tests rake elasticsearch:stop # Stop Elasticsearch cluster for tests

Elasticsearch ve Rspec

İlk olarak, dizinimizin veri değişikliklerimizle senkronize olacak şekilde güncellendiğinden emin olmamız gerekir. Neyse ki, Chewy gem, faydalı update_index rspec eşleştiricisiyle birlikte gelir:

 describe EntertainmentIndex do # No need to cleanup Elasticsearch as requests are # stubbed in case of `update_index` matcher usage. describe 'Tag' do # We create several books with the same tag let(:books) { create_list :book, 2, tag_list: 'tag1' } specify do # We expect that after modifying the tag name... expect do ActsAsTaggableOn::Tag.where(name: 'tag1').update_attributes(name: 'tag2') # ... the corresponding type will be updated with previously-created books. end.to update_index('entertainment#book').and_reindex(books, with: {tags: ['tag2']}) end end end

Ardından, gerçek arama sorgularının düzgün bir şekilde gerçekleştirildiğini ve beklenen sonuçları döndürdüğünü test etmemiz gerekiyor:

 describe EntertainmentSearch do # Just defining helpers for simplifying testing def search attributes = {} EntertainmentSearch.new(attributes).search end # Import helper as well def import *args # We are using `import!` here to be sure all the objects are imported # correctly before examples run. EntertainmentIndex.import! *args end # Deletes and recreates index before every example before { EntertainmentIndex.purge! } describe '#min_year, #max_year' do let(:book) { create(:book, year: 1925) } let(:movie) { create(:movie, year: 1970) } let(:cartoon) { create(:cartoon, year: 1995) } before { import book: book, movie: movie, cartoon: cartoon } # NOTE: The sample code below provides a clear usage example but is not # optimized code. Something along the following lines would perform better: # `specify { search(min_year: 1970).map(&:id).map(&:to_i) # .should =~ [movie, cartoon].map(&:id) }` specify { search(min_year: 1970).load.should =~ [movie, cartoon] } specify { search(max_year: 1980).load.should =~ [book, movie] } specify { search(min_year: 1970, max_year: 1980).load.should == [movie] } specify { search(min_year: 1980, max_year: 1970).should == [] } end end

Test kümesi sorun giderme

Son olarak, test kümenizde sorun gidermeye yönelik bir kılavuz:

  • Başlamak için bellek içi, tek düğümlü bir küme kullanın. Özellikler için çok daha hızlı olacaktır. Bizim durumumuzda: TEST_CLUSTER_NODES=1 rake elasticsearch:start

  • elasticsearch-extensions test kümesi uygulamasının kendisiyle ilgili tek düğümlü küme durum denetimiyle ilgili bazı mevcut sorunlar vardır (bazı durumlarda sarıdır ve hiçbir zaman yeşil olmaz, bu nedenle yeşil durum kümesi başlatma denetimi her seferinde başarısız olur). Sorun bir çatalda düzeltildi, ancak umarım yakında ana depoda düzeltilecektir.

  • Her veri kümesi için, isteğinizi özelliklerde gruplayın (yani, verilerinizi bir kez içe aktarın ve ardından birkaç istek gerçekleştirin). Elasticsearch uzun süre ısınır ve verileri içe aktarırken çok fazla yığın bellek kullanır, bu nedenle, özellikle bir sürü özelliğiniz varsa, aşırıya kaçmayın.

  • Makinenizin yeterli belleğe sahip olduğundan emin olun, aksi takdirde Elasticsearch donacaktır (her test sanal makinesi için yaklaşık 5 GB ve Elasticsearch'ün kendisi için yaklaşık 1 GB gerekliydi).

toparlamak

Elasticsearch kendini "esnek ve güçlü bir açık kaynak, dağıtılmış, gerçek zamanlı arama ve analiz motoru" olarak tanımlıyor. Arama teknolojilerinde altın standarttır.

Rails geliştiricilerimiz Chewy ile bu avantajları basit, kullanımı kolay, üretim kalitesi, Rails ile sıkı entegrasyon sağlayan açık kaynaklı Ruby gem olarak paketlediler. Elasticsearch ve Rails – ne harika bir kombinasyon!

Elasticsearch ve Rails -- ne harika bir kombinasyon!
Cıvıldamak


Ek: Elasticsearch dahili öğeleri

İşte "kaputun altında" Elasticsearch'e çok kısa bir giriş…

Elasticsearch, birincil veri yapısı olarak tersine çevrilmiş dizinleri kullanan Lucene üzerine kurulmuştur. Örneğin, “köpekler yükseğe zıplar”, “çitin üzerinden atlar” ve “çit çok yüksekti” dizelerine sahipsek, aşağıdaki yapıyı elde ederiz:

 "the" [0, 0], [1, 2], [2, 0] "dogs" [0, 1] "jump" [0, 2], [1, 0] "high" [0, 3], [2, 4] "over" [1, 1] "fence" [1, 3], [2, 1] "was" [2, 2] "too" [2, 3]

Bu nedenle, her terim metindeki hem referansları hem de konumları içerir. Ayrıca, terimlerimizi değiştirmeyi seçiyoruz (örneğin, “the” gibi stop-kelimeleri kaldırarak) ve her terime fonetik hashing uyguluyoruz (algoritmayı tahmin edebiliyor musunuz?):

 "DAG" [0, 1] "JANP" [0, 2], [1, 0] "HAG" [0, 3], [2, 4] "OVAR" [1, 1] "FANC" [1, 3], [2, 1] "W" [2, 2] "T" [2, 3]

Daha sonra “köpek atlar” için sorgu yaparsak, kaynak metinle aynı şekilde analiz edilir ve hashlemeden sonra “DAG JANP” olur (“köpek”, “sıçrayışlar” için olduğu gibi “köpekler” ile aynı karmaya sahiptir ve "zıplamak").

Ayrıca, dizedeki (yapılandırma ayarlarına dayalı olarak) tek tek kelimeler arasına (“DAG” VE “JANP”) veya (“DAG” VEYA “JANP”) arasında seçim yaparak biraz mantık ekleriz. İlki [0] & [0, 1] (yani, belge 0) ve ikincisi [0] | [0, 1] [0] | [0, 1] (yani belgeler 0 ve 1). Metin içi konumlar, sonuçları ve konuma bağlı sorguları puanlamak için kullanılabilir.