Ruby on Rails için Elasticsearch: Chewy Gem için Bir Eğitim
Yayınlanan: 2022-03-11Elasticsearch, 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.
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.
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.
Ve böylece Chewy mücevheri doğdu.
Chewy'nin özellikle dikkate değer birkaç özelliği şunları içerir:
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.
İ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.
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.
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:
-
"index_name#type_name"
biçiminde sağlanan bir tür tanımlayıcısı - 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:
-
after_save
geri aramasını devre dışı bırakır. - Kaydedilen kitapların kimliklerini toplar.
-
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:
- Elasticsearch sunucusunu başlatın.
- Endekslerimizi temizleyin ve oluşturun.
- Verilerimizi içe aktarın.
- Sorgumuzu gerçekleştirin.
- 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!
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.