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
に対する要素の増減を制御して対処しています。