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 でひな形を作成し、該当のマイグレーションファイルを消してスタートします。
とりあえずはActiveModelとTireを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は単純でtireのupdate_indexを呼び出すだけです。
idフィールドは必須としたいので、設定していなければSecureRandom.uuidでIDを作成します。
この時valid?を見ておけば、モデル全体のデータの検証をすることが出来ます。
persisted?はフォーム送信時にコントローラーのcreateかupdateどちらのメソッドに行くのかの判定に必要になります。
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イベントでcreateやdestroyに対する要素の増減を制御して対処しています。