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

# はじめに

"[railsから全文検索エンジンelasticsearchを利用する](/article/2013/09/03/rails-elasticsearch/)"では、[tire](https://github.com/karmi/tire)を用いてrailsから[elasticsearch](http://www.elasticsearch.org/)を扱う方法を説明しました。  
この時、基本的なデータはデータベース上に永続化し、elasticsearchは検索用のインデックスとしてのみ使用していました。  
今回はDBには永続化せずにelasticsearch上のみでCRUDが出来るようにしてみます。

# 注意：elasticsearch用rubyクライアント

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

# モデルの準備

データベースを用いて永続化を行う際、通常は`ActiveRecord::Base`を継承したモデルを用いますが、今回はデータベースを用いないため、`ActiveModel`を使います。

また、今回作成するアプリケーションでは、elasticsearch上のみでCRUDする処理の部分以外を簡潔にするために、名前のフィールドだけを持つ単純な`User`モデルを仮定しています。

`rails g scaffold user name:string` でひな形を作成し、
該当のマイグレーションファイルを消してスタートします。

とりあえずは`ActiveModel`と`Tire`をincludeしたクラスを作成しましょう。

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

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

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

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

```ruby
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`ではそれを呼び出すようにしています。

```ruby
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`どちらのメソッドに行くのかの判定に必要になります。  


```ruby
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`を追加しています。

```ruby
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`で余分なものを無視しています。


```ruby
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
```

# モデルの全体像

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

```ruby
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`に対する要素の増減を制御して対処していますが、もっと筋のよい解決策があるかもしれません。