ActiveModelとtireでElasticsearch上でのみCRUDを行う

はじめに

railsから全文検索エンジンelasticsearchを利用する"では、tireを用いてrailsからelasticsearchを扱う方法を説明しました。
この時、基本的なデータはデータベース上に永続化し、elasticsearchは検索用のインデックスとしてのみ使用していました。
今回はDBには永続化せずにelasticsearch上のみでCRUDが出来るようにしてみます。

注意:elasticsearch用rubyクライアントについて

今回、elasticsearchとの連携はtireを用いていますが、これからは後継のプロジェクトであるelasticsearch-ruby 及び elasticsearch-rails の方が主流になっていくと思います。

モデルの準備

データベースを用いて永続化を行う際、通常はActiveRecord::Baseを継承したモデルを用いますが、今回はデータベースを用いないため、ActiveModelを使います。
また、今回作成するアプリケーションでは、elasticsearch上のみでCRUDする処理の部分以外を簡潔にするために、名前のフィールドだけを持つ単純なUserモデルを仮定しています。
rails g scaffold user name:string でひな形を作成し、該当のマイグレーションファイルを消してスタートします。

とりあえずはActiveModelTireをincludeしたクラスを作成します。

class User
  include ActiveModel::Model
  include Tire::Model::Search
end

フィールド / マッピングの定義

次にフィールドを定義します。
また、同時にelasticsearch上のマッピングも定義しておきます。
作成しているUserモデルは要素としては名前、つまりnameフィールドのみを持つとしましたが、CRUD処理を行うためにもdocumentを一意に特定するためのフィールドも必要になるため、これをidとして定義します。

idは必須としたいため、validatesも設定しておきましょう。

class User
  include ActiveModel::Model
  include Tire::Model::Search

  attr_accessor :id, :name

  validates :id, :name, presence: true

  mapping do
    indexes :id,   index:    :not_analyzed
    indexes :name, analyzer: :kuromoji
  end
end

elasticsearchへのインデックス登録の準備

elasticsearchへモデルの情報をjsonで送るために、to_indexed_jsonを定義します。
User.index.import usersなどのようにTire::Index.importを用いて一括登録することも出来るように、serializable_hashを定義し、to_indexed_jsonではそれを呼び出すようにしています。

class User

  # 中略

  def to_indexed_json
    serializable_hash.to_json
  end

  def serializable_hash
    {
      id:   id,
      name: name
    }
  end

end

保存と更新

モデル情報の保存が出来るようにしてみます。
saveは単純でtireupdate_indexを呼び出すだけです。
idフィールドは必須としたいので、設定していなければSecureRandom.uuidでIDを作成します。
この時valid?を見ておけば、モデル全体のデータの検証をすることが出来ます。

persisted?はフォーム送信時にコントローラーのcreateupdateどちらのメソッドに行くのかの判定に必要になります。

class User

  # 中略

  def persisted?
    @id.present?
  end

  def save
    @id ||= SecureRandom.uuid
    update_index if valid?
  end

  def update attributes
    attributes.each do |key, value|
      self.send "#{key}=", value
    end
    save
  end

end

削除

elasticsearch上からの削除もupdate_indexを使うことで実現します。
このときdestroyed?trueになるとupdate_indexで削除してくれるので、フィールドに削除フラグを表すdestroyedを追加しています。

class User

  # 中略
  attr_accessor :id, :name, :destroyed

  def destroyed?
    !!@destroyed
  end

  def destroy
    @destroyed = true
    update_index
  end

end

User.find, User.allの用意

コントローラーから呼ばれるUser.all, User.findを用意します。
どちらもtire.searchを使い、#findではidを指定して取得できるようにしてみます。
loadは検索結果からActiveModelのインスタンスを作成するために用意してあり、検索結果をハッシュに変更した中には_score, _typeなどの要素が含まれているため、method_missingで余分なものを無視しています。

class User

  # 中略
  def self.all
    tire.search {
      query {all}
    }.map {|data| load data.to_hash}
  end

  def self.find id
    es_data = tire.search {
      query { term :id, id }
    }
    self.load es_data.first.to_hash if es_data.first
  end

  def self.load hash
    user = self.new hash
  end

  def method_missing method_name, *arguments
    nil
  end

end

モデルの全体像

ここまで作成してきたモデルの全体像を示します。
ここに検索のロジックなどを追加することを考えると、モジュールで分割した方が良さそうですね。

class User
  include ActiveModel::Model
  include Tire::Model::Search

  attr_accessor :id, :name, :destroyed
  validates :id, :name, presence: true

  mapping do
    indexes :id,   index:    :not_analyzed
    indexes :name, analyzer: :kuromoji
  end

  def self.all
    tire.search {
      query {all}
    }.map {|data| load data.to_hash}
  end

  def self.find id
    es_data = tire.search {
      query { term :id, id }
    }
    self.load es_data.first.to_hash if es_data.first
  end

  def self.load hash
    user = self.new hash
  end

  def method_missing method_name, *arguments
    nil
  end

  def to_indexed_json
    serializable_hash.to_json
  end

  def serializable_hash
    {
      id:   id,
      name: name
    }
  end

  def persisted?
    @id.present?
  end

  def destroyed?
    !!@destroyed
  end

  def save
    @id ||= SecureRandom.uuid
    update_index if valid?
  end

  def update attributes
    attributes.each do |key, value|
      self.send "#{key}=", value
    end
    save
  end

  def destroy
    @destroyed = true
    update_index
  end

end

既知の問題

ActiveRecord::Baseを継承したモデルから、今回作成したモデルに置き換えるだけで、コントローラやビューに変更を加えることなくCRUDが出来るようになっています。

ただし少し問題もあります。
たとえば新規作成時、一覧ページにリダイレクトした後に、新たに作成したデータが存在しないように見えることがあります。
実はリロードすると正常に作成されており、これはリダイレクトされるタイミングとElasticsearchに永続化が完了するタイミングが同期していないことに原因があります。
個人的にはajax:completeイベントでcreatedestroyに対する要素の増減を制御して対処しています。