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

Tại sao lại chọn 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:

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

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

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

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

Để 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

Cài đặt cấu trúc index

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

Để 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.

Atomicity

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:

  1. Vô hiệu hóa callback after_save
  2. Thu thập ID của những đối tượng bị đổi
  3. Sử dụng những ID thu thập được để thực hiện một yêu cầu update duy nhất.

Searching

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

Controllers and views

Ở 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