Elasticsearch trong Rails với gem Chewy
Elasticsearch cung cấp một phương thức index và truy vấn mạnh mẽ theo chuẩn RESTfull, được xây dựng nên nền thư viện Apache Lucene. Hiện tại, thư viện hỗ trợ các phương thức tìm kiếm vô cùng hiệu quả, gọn nhẹ và dễ tùy chỉnh, có thể tìm kiếm với bộ mã UTF-8. Việc giao tiếp với thư viện ...
Elasticsearch cung cấp một phương thức index và truy vấn mạnh mẽ theo chuẩn RESTfull, được xây dựng nên nền thư viện Apache Lucene. Hiện tại, thư viện hỗ trợ các phương thức tìm kiếm vô cùng hiệu quả, gọn nhẹ và dễ tùy chỉnh, có thể tìm kiếm với bộ mã UTF-8. Việc giao tiếp với thư viện Elasticsearch hoàn toàn có thể thực hiện qua giao thức HTTP, song để thuận tiện hơn nữa, các web framework thường có những plugin riêng để đảm nhận việc giao tiếp với server Elasticsearch, chuyển các câu lệnh của ngôn ngữ lập trình trở thành các truy vấn HTTP. Với Rub on Rails chúng ta có các gem elasticsearch-ruby, elasticsearch-rails và chewy. Bài viết này sẽ hướng dẫn cách cài đặt và sử dụng cơ bản của chewy.
Chewy được xây dựng dựa trên thư viện elasticsearch-ruby, với mong muốn cải tiến và giúp cho Elasticsearch trở nên thân thiện hơn với mỗi lập trình viên Ruby. Một số đặc điểm của Chewy:
-
Các index đều có thể được quan sát bởi các model liên quan: Phần lớn các model được tạo ra đều có liên quan đến nhau. Đôi khi, ta cần phải giữ nguyên sự liên kết các dữ liệu trong khi đánh index, ví dụ như trường hợp cần đánh index cho một dãy các tags cùng với article của chúng. Chewy cho phép duy trì một bảng index có thể cập nhập thường xuyên cho mỗi model, như vậy các article tương ứng sẽ được cập nhập index nếu các tags liên quan được cập nhập.
-
Các class index độc lập với các model ORM/ODM: Với tính năng này, việc cài đặt các truy vấn liên kết nhiều model trở nên dễ dàng hơn. Ta chỉ cần cài đặt model index và làm việc với nó theo kiểu hướng đối tượng mà không cần quan tâm xem nó index những gì. Việc cập nhập từ các ActiveRecord model sang index model được chewy vận hành tự động.
-
Import dữ liệu lớn: Chewy cung cấp API phục vụ cho việc đánh index cho lượng lớn dữ liệu hay đánh index lại từ đầu cho toàn bộ database. Chewy còn có tính năng atomic update: tìm kiếm các phần tử đã được thay đổi trong một block và update index cho chúng cùng lúc.
-
Chewy cung cấp phương thức truy vấn DSL thân thiện và mạnh mẽ: Các truy vấn của Chewy có thể được kết nối, kết hợp và phối hợp hiệu quả với nhau như những scope của ActiveRecord.
Để cài đặt chewy, thêm dòng sau vào Gemfile:
gem "chewy"
Sau đó chạy:
$ bundle install
Hoặc có thể cài đặt thủ công bằng lệnh:
$ gem install chewy
Mỗi bảng trong cơ sở dữ liệu gồm một tập các trường. Mỗi trường được lưu trữ theo một cách khác nhau, nên cần được phân tích và đánh index theo một cách riêng. Chewy cung cấp các cấu trúc giúp định nghĩa các phương thức index này:
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
Ở ví dụ trên, ta định nghĩa một index cho Elasticsearch tên là entertainment gồm ba loại: book, movie và cartoon. Với mỗi loại, ta định nghĩa một số trường kết nối đến các trường tương ứng trong cơ sở dữ liệu và một hash để lưu trữ các setting cho index.
Sau khi định nghĩa EntertainmentIndex, việc tiếp theo cần phải làm là khởi tao các index và import dữ liệu:
EntertainmentIndex.create! EntertainmentIndex.import
Hoặc có thể sử dụng
EntertainmentIndex.reset!
Bây giờ, ta đã có thể bắt đầu thực hiện truy vấn:
EntertainmentIndex.query(match: {author: "Tarantino"}).filter{year > 1990} EntertainmentIndex.query(match: {title: "Shawshank"}).types :movie EntertainmentIndex.query(match: {author: "Tarantino"}).only(:id).page(2).per 10
Để cài đặt trong Rails, Chewy cung cấp hàm update_index để thay thế cho tất cả các bước callback cần thiết:
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
Bởi vì tags cũng được đánh index, tiếp theo ta cần định nghĩa một số model có thể thay đổi index khi cần:
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
Các đối tượng được lưu, cập nhập hay hủy sẽ được cập nhập vào index của Elasticsearch.
Có một vấn đề, nếu ta cập nhập một số đối tượng cùng một lúc, ta phải yêu cầu cập nhập index với từng đối tượng. Ví dụ, nếu ta lưu 5 books, ta phải update Chewy 2 lần. Việc này trên lý thuyết là hoàn toàn có thể chấp nhận được, nhưng trên thực tế thì không được khuyến khích vì ảnh hưởng đến performance.
Ta có thể giải quyết vấn đề bằng Chewy.atomic block:
class ApplicationController < ActionController::Base around_action{|block| Chewy.atomic block} end
Chewy thực hiện những công việc sau:
- Vô hiệu hóa callback after_save
- Thu thập ID của những đối tượng bị đổi
- Sử dụng những ID thu thập được để thực hiện một yêu cầu update duy nhất.
Giao diện truy vấn đã sẵn sàng được cài đặt. Chỉ cần truyền một query đúng với cú pháp của Elasticsearch vào index model là có thể có kết quả.
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
Ở ví dụ trên, model của chúng ta có thể thực hiện tìm kiếm với các tham số truyền vào ví dụ như:
EntertainmentSearch.new(query: 'Tarantino', min_year: 1990).search
Trong controller, ta chỉ cần gọi đúng phương thức này để cho ra kết quả tìm kiếm:
class EntertainmentController < ApplicationController def index @entertainments = EntertainmentSearch.new(params[:search]).search end end
Cuối cùng là form tương ứng:
= 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