こんにちは、AppBrewのエンジニアの吉野です。 前回の記事ではElasticsearchとはどういうものか、ということについて書きました。
この記事では、
①ローカルでのElasticsearchの環境構築
から、
②Railsとどうつなぎ合わせるか
③実運用上での注意点など
について書いていきたいと思います。
動作環境
Rails 4.2.8 Elasticsearch 2.4.5
①ローカルでのElasticsearchの環境設定
開発するにあたり、ローカルで環境がほしいと思います。 ElasticSearchのAnalyzerは標準としていくつかありますが、日本語のTokenizeなどをするならば Analyzer として、kuromoji Analyzer、icu_normalizer あたりを入れていきましょう。 GUIはelasticsearch-headを入れておくことにします。
brew install elasticsearch@2.4 echo 'export PATH="/usr/local/opt/elasticsearch@2.4/libexec/bin:$PATH"' >> ~/.bash_profile source ~/.bash_profile plugin install analysis-kuromoji analysis-icu mobz/elasticsearch-head
次に設定を多少いじりましょう
/usr/local/opt/elasticsearch@2.4/libexec/config/elasticsearch.yml
に以下の行を追加しておくと何かと便利です。
script.inline: on script.indexed: on script.engine.groovy.inline.search: on script.search: on script.groovy.sandbox.enabled: true
起動は elasticsearch
で可能です。
$ elasticsearch [2018-02-25 16:53:50,683][INFO ][node ] [Mandroid] version[2.4.6], pid[1939], build[5376dca/2017-07-18T12:17:44Z] [2018-02-25 16:53:50,683][INFO ][node ] [Mandroid] initializing ... [2018-02-25 16:53:51,358][INFO ][plugins ] [Mandroid] modules [reindex, lang-expression, lang-groovy], plugins [head, analysis-kuromoji, analysis-icu], sites [head] [2018-02-25 16:53:51,420][INFO ][env ] [Mandroid] using [1] data paths, mounts [[/ (/dev/disk1)]], net usable_space [40.6gb], net total_space [232.5gb], spins? [unknown], types [hfs] [2018-02-25 16:53:51,420][INFO ][env ] [Mandroid] heap size [990.7mb], compressed ordinary object pointers [true] [2018-02-25 16:53:51,420][WARN ][env ] [Mandroid] max file descriptors [10240] for elasticsearch process likely too low, consider increasing to at least [65536] [2018-02-25 16:53:53,126][INFO ][node ] [Mandroid] initialized [2018-02-25 16:53:53,126][INFO ][node ] [Mandroid] starting ... [2018-02-25 16:53:53,213][INFO ][transport ] [Mandroid] publish_address {127.0.0.1:9300}, bound_addresses {[fe80::1]:9300}, {[::1]:9300}, {127.0.0.1:9300} [2018-02-25 16:53:53,219][INFO ][discovery ] [Mandroid] elasticsearch_yosshi/yI1KRtR_TGCC34vpmzoJEQ [2018-02-25 16:53:56,268][INFO ][cluster.service ] [Mandroid] new_master {Mandroid}{yI1KRtR_TGCC34vpmzoJEQ}{127.0.0.1}{127.0.0.1:9300}, reason: zen-disco-join(elected_as_master, [0] joins received) [2018-02-25 16:53:56,281][INFO ][http ] [Mandroid] publish_address {127.0.0.1:9200}, bound_addresses {[fe80::1]:9200}, {[::1]:9200}, {127.0.0.1:9200} [2018-02-25 16:53:56,281][INFO ][node ] [Mandroid] started [2018-02-25 16:53:56,363][INFO ][gateway ] [Mandroid] recovered [6] indices into cluster_state [2018-02-25 16:53:57,592][INFO ][cluster.routing.allocation] [Mandroid] Cluster health status changed from [RED] to [YELLOW] (reason: [shards started [[post_development_3][1]] ...]).
デフォルトはlocalhostの9200番でサーバーが立ちます。
また、
http://localhost:9200/_plugin/head/
へとアクセスすると香ばしいGUIでElasticsearchの中にあるデータを確認できます。
ローカルの設定は以上です。
②Railsとどうつなぎ合わせるか
ここからRails側でElasticsearchを使うための設定について書いていきます。
自前で必要なAPIを叩くようなものをwrapしていっても良いわけですが、 便利なものが世の中にはあるので使っていきましょう。 今回使うGemは以下の4つです。
gem 'elasticsearch', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git' gem 'elasticsearch-dsl', git: 'git://github.com/elasticsearch/elasticsearch-ruby.git' gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git'
それぞれのgemは以下のような機能を提供してくれます。
- elasticsearch
- ElasticsearchのrubyのAPIClientを提供してくれています。
- 通信部分で気になることがあればこれを見ましょう
- elasticsearch-dsl
- Elasticsearchのmappingの定義、検索のクエリ設計などをrubyっぽく書くことができるようになります。
- このライブラリ中を見てみると検索クエリの書き方のサンプルが多くのっているのでわからなくなったら見てみましょう。
- elasticsearch-model
- 名前の通りincludeすることでそのmodelに対してsearchやimport などElasticsearchに関連したメソッドを生やしてくれるものです
- elasticsearch-rails
- development環境でelasticsearch関連のlogをキレイに吐いてくれたり、データ投入のためのrake taskなどを提供してくれます。
必要なgemは入れたので実装に移りましょう。
Searchable moduleを実装
mappingは各modelによって違うとして、いくつか共通化したい処理や設定が存在するかと思います。
そのため、searchable.rb
のようなmoduleを作り、共通化していきましょう。
searchable moduleでは2つの役割を担っています
①カスタムしたanalyzerの設定 ②レコードに関するコールバックの設定 ③APIクライアントの初期化
具体的には以下のようなものとなっています。
// 'app/models/concerns/searchable.rb' require 'elasticsearch/model' Elasticsearch::Model.client = Elasticsearch::Client.new log: true, scheme: 'https', host: , port: 'ポート番号', user: 'user名', password: 'password' module Searchable extend ActiveSupport::Concern included do include Elasticsearch::Model after_commit on: [:create] do ElasticSearchIndexerJob.perform_later(self.class.to_s, self.id) unless Rails.env.development? end after_commit on: [:update] do ElasticSearchIndexUpdaterJob.perform_later(self.class.to_s, self.id) unless Rails.env.development? end after_commit on: [:destroy] do begin __elasticsearch__.delete_document unless Rails.env.development? rescue => ex end end settings analysis: { analyzer: { .... } } end end
本来createやupdateはbulkで行うべきなのでしょうが、軽い実装なら上記のようなコールバックを用意しておき、以下のようなjobを用意してあげれば、dbとelasticsearchの同期をとることができます。
class ElasticSearchIndexerJob < ActiveJob::Base queue_as :default def perform(klass, id) sleep(1) record = klass.constantize.find_by(id: id) record.__elasticsearch__.index_document if record.present? end end
各modelの設定
moduleを作ったため、これをincludeして、各モデルの設定をすれば大丈夫です。 やることは以下4点です。
①indexの定義 ②mappingの定義 ③as_indexed_jsonのオーバーライド ④search queryの設計
まずサンプルコードを貼ると以下のような形となります。
class Post < ActiveRecord::Base include Searchable index_name "post" #① mappings dynamic: 'false' do #② indexes :content, analyzer: 'kuromoji', type: 'string' indexes :n_content, analyzer: 'ngram_analyzer', type: 'string' indexes :like_count, type: 'integer' indexes :media_status, type: 'integer' indexes :published_at, type: 'date' indexes :info, analyzer: 'ngram_analyzer', type: 'string' indexes :products, type: 'nested' do indexes :name, analyzer: 'ngram_analyzer', type: 'string' indexes :id, type: 'integer' end ... end def as_indexed_json(option = {}) #③ info = "hogehoge" attributes .symbolize_keys .slice(:content, :like_count, :published_at) .merge(n_content: self.content) .merge(media_status: media_status) .merge(info: info) .merge(products: products.map{|p| {id: p.id, name: p.name}}) ... end has_many :products, through: :post_products def self.search(params) #④ search_definition = Elasticsearch::DSL::Search.search { from (page.to_i - 1) * 20 size 20 query { function_score { query { filtered { query { multi_match { query text operator 'and' fields %w{ content n_content info } } } } ... } __elasticsearch__.search(search_definition) end end
それぞれについて見ていきましょう
indexの定義
これはわざわざ説明するまでのこともなくindexの名前を設定しています。 バージョニングなどを考えたindex名については後述しますが、基本的にはわかりやすい名前をつけておけば大丈夫です。
mappingの定義
どのfiledがどういう型か、どういったanalyzerを使用するかということを定義しています。
RDBでいうところの1対多の関係を表すときは type: 'nested'
をつけておくと配列をindexingすることができます。
as_indexed_jsonのオーバーライド
mappingに則り、indexingするデータをhashの形へと変換してあげます。 ここで不足があると mappingは定義したのにデータが投入されない ということが起こってしまうので気をつけましょう
search queryの設計
最後にmappingをもとに検索クエリを組み立てて行きます。 本当に多くのクエリを用いて検索ができるため、詳細は省きますが、elasticsearch-dslのgithubを見てみると多くのサンプルを見ることができます。
これでsearchクエリさえ組み立てることができれば大丈夫というところまできました。
mappingの定義が大丈夫となったら
(Model名).__elasticsearch__.import
をしてデータを投入してみましょう。
これでいつでも検索できるようになりました!
③実運用上の問題点と解決法
最後に運用上でやっていることや問題となったことについてまとめたいと思います。
いまいちどの辺がクエリにヒットしてレコードが引っ張られているのかわからない問題
我々がやったことといえば、mappingを決め、要件をJSONに落としこんだだけですが、 実際与えらえれたクエリに対してどの辺がヒットしているのか? ということを確認したいニーズは多少あるかと思います。 そんなときは highlight という機能を使うと幸せになれるかと思います。
上記のself.search(params)
のクエリ定義の中に
def self.search(params) search_definition = Elasticsearch::DSL::Search.search { from (page.to_i - 1) * 20 size 20 highlight { fileds: [:n_content, :content] # ここを追加 } query { function_score { query { ...
highlightというブロックを追加します。 そして検索して結果をほっていくと
pry(main)> Post.search({text: "キャンメイク"}).response.hits.hits.map(&:highlight) => {"n_content"=> ["<em>キャンメイク春のコスメ</em>♡\n" + "可愛<em>い</em>過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色<em>ん</em>なカラーが出ますね。\n" + "すご<em>く</em>楽しみです!\n" + "セザ<em>ンヌの新色も楽しみで欲しいのい</em>っぱ<em>い</em>w"], "content"=>["<em>キャンメイク</em>春のコスメ♡\n" + "可愛い過ぎ♪\n" + "限定色とか新色とか新作が出るそうです。\n" + "ちなみにTwitter情報です!\n" + "新作は色んなカラーが出ますね。\n" + "すごく楽しみです!\n" + "セザンヌの新色も楽しみで欲しいのいっぱいw"]
となり、ひっかかった部分が強調タグで囲まれていることがわかりますね。 "キャンメイク"と打ったのに”ク”だけに反応していたりして、なかなか難しそうですね。
引っ張ってきたレコード、スコア順じゃない問題
elasticsearch-modelの恩恵は偉大で
(Model名).search(params).records.to_a
とするとSQLを発行し、レコードを取得できるわけですが、クエリを見てみると
Post Load (0.5ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`id` IN (301306, 300359, 297179, 304908, 302542, 299076, 312292, 303429, 308305, 304312, 311940, 297988, 301020, 307750, 305654, 298394, 311388, 308077, 297068, 296636)
のようにwhereで引いてきているため、elasticsearchでのスコア順が保証してくれません。
そのため自前でソートするのがよいかと思います。
(例)
Post.search(params).map(&:id).tap { |ids| Post.where(id: ids).order("field(posts.id, #{ids.join(',')})") }
で順番が保証されます。
activerecord-importとelasticsearch-modelのimportがconflictする問題
(※導入しようとした当時はconflictしてたのですが、現在ではactiverecord-importのimportメソッドはbulk_importにrenameしたようです。 参考: https://github.com/zdennis/activerecord-import/blob/master/lib/activerecord-import/import.rb)
一応困ってる人がいたりするかもしれないので軽く残しておくと elasticsearch-modelが各モデルに対して生やしてくれるメソッドとactiverecord-importが ActiveRecord::Associations::CollectionAssociation に生やしてくれるimportメソッドが被ってしまうという問題があり、メソッドの探索順序的に elasticsearch-modelが生やしてくれるimportが常に使われてしまうという問題がありました。
これはconfig/application.rb
に
require 'activerecord-import/base' class ActiveRecord::Base class << self alias :bulk_insert :import remove_method :import end end
としてaliasを貼ってあげることで 解決していました
fieldを追加したりmappingを変えたくなったんだけどサービスはとめたくない問題
ベストプラクティスかどうかはわかりませんが、 弊社ではindex_nameにsuffixをつけることでblue-greenの切り替えをを行っています。
具体的にはconfig/initializers
に以下のような定数を記述したファイルをつくり、
CURRENT_ES_POST_INDEX = 2 CURRENT_ES_PRODUCT_INDEX = 1 CURRENT_ES_USER_INDEX = 2 CURRENT_ES_ARTICLE_INDEX = 0
各modelのindex_nameを
class Post < ActiveRecord::Base include Searchable index_name "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX}" ... end
のようにしておくと
①mappingを定義しなおしたものをデプロイ ②rake taskなどでCURRENT_HOGEHOGE_INDEX をインクリメントしたインデックスをつくりデータを投入
Post.__elasticsearch__.index_name = "post_#{Rails.env.production? ? 'production' : 'staging'}_#{CURRENT_ES_POST_INDEX + 1}" Post.__elasticsearch__.import
③作り終えたら実際にconfig/initializers
の下にある定数をインクリメントしデプロイ
というフローでblue-greenを実現することができます。
検索候補とか出したい
検索システムを作ってると候補を出したいってなることがあるかと思います。
書き始めると長くなるので、とても参考になるURLを貼っておきます。
基本的には,弊社では検索のログや、ブランド名、商品名、ユーザー名などを、(とても参考になるURLで言われているような)Analyzerを用いてtokenizeするだけでそこそこのものができたりします。