アペフチ

Railsでの検索機能にgroonga-client-railsを使う(後編)

アドベントカレンダー「Groonga Advent Calendar 2016」の21日目です、書いているのは25日ですが……済みません

前回のRailsでの検索機能にgroonga-client-railsを使う(前編)では、groonga-client-rails gemを使って

  • Groongaのデータベースを作ること
  • Railsのモデル操作とGroongaデータベースの同期を取ること

をやりました。

後編の今日は、Groongaデータベースを使って、Railsアプリに検索機能を付けてみようと思います。

引き続きアプリケーションのリポジトリーをGitHubに置いています:KitaitiMakoto/groonga-client-rails-sample

目次

  1. ルーティングの追加
  2. 検索アクションの追加
  3. 検索結果の表示
  4. 検索フォームの作成
  5. 検索語のハイライト
  6. 高度な検索(カラムの指定、並び替え、ページネーション)

ルーティングの追加

検索用のルーティングを追加します。

  • posts?q=xxxと既存のコレクションリソースを使ってクエリーで検索機能を呼び出す
  • posts/searchと検索専用のリソースを追加する

の二通りあり、アプリケーション全体のデザインで選ぶべき物だと思いますが、ここでは後者にします。

# config/routes.rb

resources :posts do
  collection do
    get :search
  end
end

検索アクションの追加

PostsControllerに検索アクションを追加します。

# app/controllers/posts_controller.rb

def search
  searcher = PostsSearcher.new
  query = params[:q]
  if query.blank?
    redirect_to action: "index"
    return
  end
  @posts = searcher.search.
             query(query).
             result_set.records
end

(モデルの代わりに)サーチャークラスをインスタンス化し、クエリーを組み立てていきます。

検索の開始には#searchメソッドを呼び出します。これでクエリー組み立ての準備が整います(クエリーオブジェクトが返されます)。

queryメソッドに文字列を渡すことで、検索語を認識させます。

result_setを呼ぶとリモートのGroongaサーバーにHTTPリクエストを送って検索結果を取得します。

recordsによって、それをRubyのオブジェクトに変換して返します。

検索結果の表示

検索結果を表示します。app/views/posts/index.html.erbapp/views/posts/search.html.erbにコピーし、ActiveModel依存の所を書き換えます。

<!-- app/views/posts/search.html.erb -->

<h1>Posts</h1>

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <% @posts.each do |post| %>
      <tr>
        <td><%= post.title %></td>
        <td><%= post.body %></td>
        <td><%= link_to 'Show', post_path(extract_id(post)) %></td>
        <td><%= link_to 'Edit', edit_post_path(extract_id(post)) %></td>
        <td><%= link_to 'Destroy', post_path(extract_id(post)), method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Post', new_post_path %>

extract_idは、レコードデータからIDを取り出すヘルパーです。これも自分で定義します。

module PostsHelper
  def extract_id(post)
    post["_key"].split("-")[1]
  end
end

Groongaでは、_keyというカラムによって、レコードを一意に特定するのですが、groonga-client-rails(のデフォルト)では、「モデル名-連番」となる(Post-1)ので、そこからAcitiveRecordのIDに変換しています。

検索フォームはありませんが、これで一応機能はできました。http://localhost:3000/posts/search?q=Qiita などにアクセスすると、検索結果が見られると思います。

検索フォームの作成

次にフォームです。/posts/searchqクエリー付きでGETアクセスを投げるだけなので簡単です。

<!-- app/views/posts/_search_form.html.erb -->

<%= form_tag(search_posts_path, method: "get") do %>
  <input type=search name=q value="<%= params[:q] %>" required>
  <%= submit_tag("Search") %>
<% end %>

これをそれぞれのテンプレートファイルに埋め込んでやります(省略)。

検索語のハイライト

ただ、せっかくだから、検索語が分かりやすくなっていてほしいですよね。また、メモの全文をここで表示してしまうと、長過ぎるという場合もあると思います。両方をいっぺんに解決できる方法として、Groongaのsnippet_html関数があります(7.14.17. snippet_html)。

これは検索語の周辺数十文字(スニペット)を返してくれる関数です。更に、検索語を<span class="keyword">...</span>でマークアップしてくれます(HTML)。

snippet_htmlを使うには、Groongaから取得するカラムにこれを指定します。groonga-client-railsはデフォルトで、モデルで設定したカラムを取得してくれます(なのでtitlebodyが取れていた)。これをカスタマイズするには、クエリーにoutput_columnsというパラメーターを追加する必要があります(7.3.54.4.4.1. output_columns)。

# app/controllers/posts_controller.rb

  @posts = searcher.search.
             query(query).
             output_columns('_key,title,snippet_html(body)').
             result_set.records

これで、「bodyカラムでの検索結果にはスニペットを取得する」という意味になります。

これに合わせてビューも変えなくてはいけません。

        <td><%== post.snippet_html.join("<br>") %></td>

HTMLを埋め込むので===にしています。また、結果は配列になっているので(一つのメモの離れた所に検索語がある場合、それぞれの周辺を取得します)改行で接続します。

先述の通り、検索語はマークアップされるので、スタイリングしましょう。

/* app/assets/stylesheets/posts.scss */

.keyword {
  font-weight: bolder;
  color: red;
}

これで、検索語が赤い太字になりました。

何とか見られる結果になったんではないでしょうか。

Groongaでは、検索の際に様々な条件を付け加えたり、結果を加工したりできます。機能の詳細はドキュメント(7.3.54. select)に譲りますが、ここでは以下の三つに対応してみましょう。

match_columns
検索に使用するカラムを試定。例えば「検索語がタイトルに含まれる場合のみ表示する」など。
sortby
指定したカラムで並び替える。
paginate
検索結果が多過ぎる場合にページネーションします。

と言っても簡単で、クエリーオブジェクトから、それぞれのメソッドを呼び出すだけです。

# app/controllers/posts_controller.rb

  @posts = searcher.search.
             query(query).
             output_columns('_key,title,snippet_html(body)')
  [:match_columns, :sortby, :paginate].each do |param|
    if params[param].present?
      @posts = @posts.send(param, params[param])
    end
  end
  @posts = @posts.result_set.records

これにフォームを対応させれば出来上がりです。

例えば、タイトルで並び替えした場合はこうなります。

並び順を逆にするには、カラム名の前にマイナス記号(-)を付けます。

どうでしたか、groonga-client-railsは、無理にActiveModel風にしない所が気に入っていたりします……と言っている間に、開発者の@kouさんがよりちゃんとした記事を書いていました、締め切り破って済みませんでした……。

» Ruby on RailsでMySQL・PostgreSQL・SQLite3とGroongaを使って日本語全文検索を実現する方法