Railsではboolean型のカラムがElasticSearchではlong型にDynamic Mappingされて困った話

#TL;DR
Railsでboolean型のカラムを作ったら、ElasticSearchにlong型でマッピングされてエラーが出た。
原因は本番環境へのソースコード反映並びにRailsの再起動と、DBへのmigrationが行われるタイミングのズレにあった。
対象モデルの `as_indexed_json` メソッドを変更して改修した。

#問題
ElasticSearchを導入してしばらく経ったある日、boolean型のカラムを追加する反映を行ったところ、対象モデルのレコードを編集、保存する機能全般で以下のようなエラーが出た。

```rb
[400] {"error":"MapperParsingException[failed to parse [column_name]]; nested: JsonParseException[Current token (VALUE_FALSE) not numeric, can not use numeric value accessors\n at [Source: [B@14286565; line: 1, column: 4408]]; ","status":400}
```

どうやら `VALUE_FALSE` は `numeric` じゃないからダメだと言われてるらしいので、本番環境で使用しているElasticSearchのマッピングを確認すると、追加したカラムについてのフィールドが、boolean型ではなくlong型になっていた。

#原因
localではなかなか再現しなかったり、そもそもElasticSearchもいつの間にか導入されてたものでそれ自体よくわからなかったり、あまりにもわからなすぎてとりあえず1日寝かしてみたりした。
結構ガチャガチャいじり回した結果、別サーバーで行う本番DBへのmigrateと、実際のソースコード反映並びにRails再起動のタイミングにズレがあることが原因だと判明した。
具体的な挙動は以下のようなもの。

![ES_problem.png](https://qiita-image-store.s3.amazonaws.com/0/52834/95ec58ea-3ea6-d043-d881-da5adc3763a2.png)

#### ① 本番サーバーでRailsが起動する際に、DBのcolumn情報をモデルにロードする
この時点では新しく追加される予定のcolumn(図中の `new_column` )に関する情報はロードされていない。
要するに前回の反映時にロードされたモデルの情報でサービスが動いている状態。

#### ② 別サーバーを用いて、本番用のDBにcolumnを追加する
ソースコードを反映する前に、参照先のcolumnを用意しておくため、別サーバーを使って本番用のDBに変更を加える。

#### ③ 本番サーバーにソースコードが反映される前に、サービス上でDBのデータがロードされてインスタンスが生成される
本番反映による**Railsの再起動前に**サービス上でDBのデータがロードされ、インスタンスが生成される。
この際、`new_column`にdefault値を設定していた場合、DBには`new_column` のデータが存在するため、それも一緒にロードされる。しかし、Railsが持っているモデルの情報は更新されていないため、**「なんかよくわからんけど知らん値が来たわ」**という状態でインスタンスが生の値をそのまま持つ。
Railsのboolean型は、MySQLではtinyintで実現されるため、仮に `default: false` としていた場合、**この値は `0` である**。

#### ④ 生成されたインスタンスを保存し、documentをElasticSearchに追加する
インスタンスがsaveされる際、 `after_commit` が走って `refresh_index` が実行され、ElasticSearchにdocumentを追加する（要するにデータの更新）。
この時、dynamic mappingが有効になっていると、mappingが設定されていない属性について、**インスタンスの持つ値から型が推察される**。
図中で言うと、上から順に
「idは整数だからlong、nameはstring、そしてemailはstring。new_columnは……**モデルに情報がないけど0なんだから整数でlongだよね**」
という話になり、boolean型がlong型でマッピングされる処理が実現してしまう。


# 対策
## 1. dynamic mappingを切る
[Elastic Searchのドキュメント](https://github.com/elastic/elasticsearch-rails/tree/master/elasticsearch-model#index-configuration)に従って、dynamic mappingをオフにする。
Elastic Searchの用途がはっきりしているなら(というか本来的には用途がはっきりしていない状態で導入するべきではないと思うけども)、必要なattributeをホワイトリスト形式で指定して、dynamic mappingを切ってしまうのが一番安全だと思う。
ただ今回については、今後Elastic Searchをどのように利用していくかということについて、まだ明確になっていないところも多かったため、協議の結果これはナシでということに。

## 2. Elastic Searchに投げるattributeを、モデルの持つ情報準拠にする
Elastic Searchに送信するattributeは、 `as_indexed_json` メソッドで指定できる。 
例えば以下のようにすると、`User`モデルのattributeに加えて、関連する`Item`モデルの`name`も送信できる。

```elasticsearchable.rb
def as_indexed_json(options = {})
  options = options.merge(
    include: {
      item: { only: :name }
    }
  )

  json = as_json(options)
end
```

そもそもモデルのインスタンスが持つattributeがElastic Searchに送信されるようになっているのは、ここで呼ばれている`as_json`メソッドが、インスタンスメソッドの`attribute_names`を使用しているため。
>参考:
>https://github.com/rails/rails/blob/master/activemodel/lib/active_model/serializers/json.rb#L88
>https://github.com/rails/rails/blob/master/activemodel/lib/active_model/serialization.rb#L127

つまりこの部分で、モデルのインスタンスではなく、モデル自体が持つ情報をもとに送信するattributeを決定するようにしてやればよい。
具体的には、モデルのクラスメソッドの方の`attribute_names`を使う。
インスタンスメソッドの`attribute_names`とクラスメソッドの`attribute_names`の違いについては[ここ](http://qiita.com/SpicyCoffee/items/15df6efc4ef60785efb0)にまとめたので、参考にどうぞ。

```elasticsearchable.rb
def as_indexed_json(options = {})
  options = options.merge(root: false)

  # migrationのタイミングに依存するバグを防ぐため、Railsが認識しているcolumnだけをindexに追加する
  columns_to_index = self.class.column_names.map &:to_sym
  options = options.merge(
    only:    columns_to_index,
    include: {
      item: { only: :name }
    }
  )

  json = as_json(options)
end
```

この方法であれば、今後の利用法の幅に備えて全attributeをElastic Searchに送信しながら、今回起きたようなバグは防ぐことができる。

# 雑感
実際にサービスに乗せて運用してみないと顕在化しないようなバグにハマったのでなかなかに戸惑った。
対処法についても、特に何かを参照したわけではなく、gemやRailsの中を読みながらどうにかこうにかひねり出したものなので、ひょっとしたらあんまよくないやり方なのかもしれない。
そもそも記事を書くにあたって使った各用語の用法なんかも割と怪しい気がするので、ご指摘などありましたらよろしくおねがいします。