#306 ElasticSearch Part 1
- Download:
- source codeProject Files in Zip (83.4 KB)
- mp4Full Size H.264 Video (31.6 MB)
- m4vSmaller H.264 Video (16.1 MB)
- webmFull Size VP8 Video (14.5 MB)
- ogvFull Size Theora Video (44.2 MB)
下の画面は記事の一覧を表示するRailsアプリケーションのページです。このページに検索機能を追加したいのですが、これらの記事はテキスト主体なので、SQLクエリを使うのではなく全文検索エンジンを使用することにします。
このトピックは以前のエピソードでも扱いました。エピソード120ではThinking Sphinxを使用し、エピソード278ではSunspotをSolrと合わせて使用しました。今回のエピソードではElasticsearchを使って全文検索機能をアプリケーションに追加します。
Elasticsearchはフル機能の全文検索エンジンで、Solrと同じようにApache Luceneの上で稼働します。REST APIを持ち、JSONを使って通信をおこないます。ElasticsearchはRuby固有ではないので、Tireというgemを介してRubyとデータのやり取りをおこないます。Tireは、どのようなRubyプロジェクトでも使用できますが、優れたモデル機能を利用して特にRails アプリケーションと簡単に統合することが可能です。さらには提供されているRails用テンプレートを使うことで、Elasticsearchを組み込んだ形で新規アプリケーションを作成できます。
$ rails new searchapp -m https://raw.github.com/karmi/tire/master/examples/rails-application-template.rb
これによって新しいRailsアプリケーションが作成され、ElasticsearchとTireが設定されます。テンプレートが、Elasticsearchが実行していることを検知できなかった場合、自動的にダウンロードとインストールをおこない、この新しいアプリケーション用に利用できる状態にしてくれます。コマンドの実行が終了してhttp://localhost:3000/
にアクセスすると、基本的なアプリケーションができていてElasticsearchを使っていくつかのレコードを検索できます。テンプレートのソースコードは、Railsアプリケーションのテンプレートで何ができるかを知るためのいい例ですので、一度見てみることをお勧めします。
アプリケーションにElasticsearchを追加する
サンプルアプリケーションは興味深いですが、自分のアプリケーションでElasticsearchを使うにはどうすればいいでしょうか? まず最初にインストールをおこないます。OS XでHomebrewを利用している場合、作業は簡単です。それ以外の場合はElasticsearchのウェブサイトにダウンロード方法の詳細があります。
$ brew install elasticsearch
Elasticsearchがインストールされると、起動方法についての指示が表示されるので、このコマンドで起動します。(使用しているElasticsearchのバージョンによってコマンドが違うので注意してください。)
$ elasticsearch -f -D es.config=/usr/local/Cellar/elasticsearch/0.18.1/config/elasticsearch.yml
このコマンドで9200番ポートでサーバが起動し、JSON REST APIを介してコマンドを使ってこのサーバと通信することができます。ですがここではTireを利用するので、次にそれをインストールします。いつものようにアプリケーションのgemfileにgemを追加してbundle
コマンドを実行します。
source 'http://rubygems.org' gem 'rails', '3.1.3' # Bundle edge Rails instead: # gem 'rails', :git => 'git://github.com/rails/rails.git' gem 'sqlite3' # Gems used only for assets and not required # in production environments by default. group :assets do gem 'sass-rails', '~> 3.1.4' gem 'coffee-rails', '~> 3.1.1' gem 'uglifier', '>= 1.0.3' end gem 'jquery-rails' gem 'tire'
検索をおこないたい対象のどのモデルにでも、2つのモジュールを加えることで、Tireを追加できます。記事を検索したいので、アプリケーションのArticle
モデルにそれらのモジュールを追加します。
class Article < ActiveRecord::Base belongs_to :author has_many :comments include Tire::Model::Search include Tire::Model::Callbacks end
一つ目のモジュールは検索や索引のためのメソッドを追加し、二つ目はコールバックを追加して記事が新規作成・更新・削除されたときに索引が自動的に更新されるようにします。
アプリケーションのデータベースにはすでにいくつかの記事があり、これらは索引に含まれません。しかし、すべてのレコードはアプリケーションのseedsファイルで定義されているので、設定ファイルを再度実行するとレコードが読み込まれるときに自動的に索引が生成されます。
$ rake db:setup
検索フォームを追加する
記事にインデックスを設定できたので、記事のページに検索のためのフォームを追加します。このページのテンプレートは以下のとおりです。
<h1>Articles</h1> <div id="articles"> <% @articles.each do |article| %> <h2> <%= link_to article.name, article %> <span class="comments">(<%= pluralize(article.comments.size, 'comment') %>)</span> </h2> <div class="info"> by <%= article.author.name %> on <%= article.published_at.strftime('%b %d, %Y') %> </div> <div class="content"><%= article.content %></div> <% end %> </div>
この簡単なフォームをページのヘッダの下に追加します。このフォームは自分自身が含まれている同じ記事ページに送信され、GETを使用します。
<%= form_tag articles_path, method: :get do %> <p> <%= text_field_tag :query, params[:query] %> <%= submit_tag "Search", name: nil %> </p> <% end %>
検索フォームが送信されるとArticlesController
のindex
アクションが実行され、現状このアクションはすべての記事を返します。コードにチェックロジックを追加して、フォームからの送信データにquery
パラメータがあったらTireのsearch
メソッドを代わりに呼び出すようにします。
def index if params[:query].present? @articles = Article.search(params[:query]) else @articles = Article.all end end
ページをリロードすると検索フォームが表示されます。しかし検索語を入力してフォームを送信するとエラーが表示されます。
エラーは、各記事に対するコメント数を表示するためにarticle.comments.size
を呼び出すことによって発生しています。どうもTireから返される記事に対して関連(association)が機能していないようです。
Tireはデータベースへのアクセスを最小限にしようとするので、Article.search
を呼び出したときに返されるのは実際のActiveRecordモデルではなく、代わりに検索インデックスに保存されている属性に基づいてTireが検索した結果のセットです。インデックスはコメントの関連については知らないため、それをどう設定していいかわかりません。これを直すためには、search
の呼び出しにload
オプションを追加し、Tireに対してデータベースから実際のレコードを読み込むように指示します。
def index if params[:query].present? @articles = Article.search(params[:query], load: true) else @articles = Article.all end end
ここで検索をおこなうと、ページが読み込まれ正しい結果が表示されます。
もし必要なすべてのデータが検索インデックスの中にあれば、load: true
を使ってデータベースからレコードを取得する必要がないので、その方がいいでしょう。これは可能ですがここでは触れず、次回のエピソードで紹介します。次に紹介するのは、追加のオプションを指定して検索をさらにカスタマイズする方法です。このためにArticle
のsearch
モデルを再定義して、ユーザから渡されたparamsハッシュを受け付けるようにします。
def self.search(params) tire.search(load: true) do query { string params[:query]} if params[:query].present? end end
Tireのsearch
メソッドをオーバーライドするので、tire.search
を使ってオーバーライドされたメソッドを呼び出して、実際のモデルを取得するためにload: true
オプションを使用しました。このメソッドに直接検索パラメータを渡す代わりに、ブロックを使うことでさらにオプションを指定して検索クエリをカスタマイズできるようにしました。このブロックでquery
を呼び出して、別のブロックを渡しています。このブロックで、パラメータが存在する場合のみ、パラメータをstring
メソッドに渡します。
ArticlesController
を単純化して、カスタムで作成したsearch
メソッドを呼び出してparamsハッシュを渡すだけにします。
def index @articles = Article.search(params) end
記事一覧のページをリロードすると以前と同じように動作し、検索ボックスをクリアして「Search」ボタンをクリックすると、すべての結果が返されます。
結果の中には、意に反してそこに表示されてしまっている記事がありますが、それは未来の公開日が入力されているためです。検索条件を変更して、まだ公開されていない記事を表示しないようにします。このためにモデルのsearch
ブロックにフィルターを設定します。
def self.search(params) tire.search(load: true) do query { string params[:query]} if params[:query].present? filter :range, published_at: {lte: Time.zone.now } end end
最初の引数はフィルターの種類で、今回の例ではrange
フィルターです。次にフィルターとする属性のハッシュを渡します。今回の場合はpublished_at
でフィルターをかけ、published_at
の時間が現在の時間よりも小さいか同じもののみを含むようにします。
他にどのようなオプションを渡して検索条件をカスタマイズできるか、興味があるところでしょう。このトピックについてのドキュメントがありますが、きれいにまとまってはいません。まず最初にTireのREADMEファイルを読むことをお勧めします。ただし、ファイルの最初の部分ではインデックスとマッピングについて書かれているので混乱するかもしれませんが、動的マッピングをおこなう場合は関係ないので気にする必要はありません。Tireが提供する追加のドキュメントがあり、こちらも一読の価値があります。
TireのオプションのほとんどはElasticsearchと1対1で対応するので、Elasticsearchのドキュメントを見るのがいいでしょう。Query DSLについてのページでは、一つのセクションを丸ごと割いてフィルターについて説明しており、先ほど使用した範囲フィルターと使用できるすべてのオプションも含まれています。サンプルのコードはJSON形式で記述されていますが、Tireで使用できるように簡単に変換できます。