読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでElasticsearch: ページネーションと1ページの表示件数を実装

elasticsearch 検索

f:id:nipe880324:20151017170217p:plain:w420

前回の記事で、「RailsでElasticsearchを使って簡単な全文検索」を実装しました。
今回は、「ページネーション・1ページあたりの表示件数」について実装していきます。
ページネーションの実装は、Railsのページネーションを実装する有名なgemのkaminariを使って実装します。

動作確認

  • Mac OS X 10.11 El Capitan
  • elasticsearch 1.7.2
  • Rails 4.2.3
  • elasticsearch-dsl 0.1.2
  • elasticsearch-model 0.1.8
  • elasticsearch-rails 0.1.8


1. データを投入

ページネーションのためにデータを増やします。

# db/seeds.rb
...
restaurants = [
  {
    name: '松屋', name_kana: 'まつや', zip: '240-0113', address: '三浦郡葉山町堀内24-3',
    pref: tokyo, category: teisyoku, closed: false
  },
  ...
] * 100
# 600件(6 * 100)のデータを作成
Restaurant.create!(restaurants)

データを投入します。

rake db:seed

Elasticsearverを起動させます。

elasticsearch

インデックスを作成しなおします。

# rails console
Restaurant.__elasticsearch__.create_index! force: true
Restaurant.__elasticsearch__.refresh_index!
Restaurant.import


これで、データベース、ドキュメントともに「600件」のレストランデータが作成されました。




2. ページネーションのためのElasticsearchのクエリ

Railsで実装を始める前に、Elasticsearchのクエリで表示件数やページ数を指定する方法を説明します。

取得するドキュメント数を指定する

sizeパラメータを指定することで取得するドキュメント数を指定できます。
デフォルトでは、「10件」となっています。

Marvel Senseを開き、sizeを指定して、match_allを実行します。

GET restaurant_development/_search
{
  "query": { "match_all": {} },
  "size": 3
}

すると、結果はhits.totalは「600件」となっていますが、hits.hitsの数を数えると「3件」となっていることがわかります。

// 検索結果
{
 "took": 2,
 "timed_out": false,
 "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
 },
 "hits": {
    "total": 600,
    "max_score": 1,
    "hits": [
       {
          "_index": "restaurant_development",
          "_type": "restaurant",
          "_id": "73",
          "_score": 1,
          "_source": {
             "name": "松屋",
             "name_kana": "まつや",
             "zip": "240-0113",
             "address": "三浦郡葉山町堀内24-3",
             ...
          }
       },
       ...
    ]
  }
}


特定の範囲のドキュメントを取得する

fromsizeパラメータを指定することで取得するドキュメントの範囲を決めることができます。
fromのデフォルト値で「0」となりま。

下記は、ドキュメント「11から20」までを取得します。

{
  "query": { "match_all": {} },
  "from": 10,
  "size": 10
}



3. Elasticsearchでページネーションを実装

kaminariを使ってページネーションを実装します。

kaminariのインストール

Gemfileelasticsearchの前gem 'kaminari'を追加します。

# Gemfile
gem 'kaminari'

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'

インストールします。

bundle


ページネーションのビューを追加

Bootstrap 3のテーマでページネーションのビューファイルを作成します。

rails g kaminari:views bootstrap3

kaminariのpaginateヘルパーメソッドを使うことでページネーションのリンクを表示できます。

<!-- app/views/top/index.html.erb -->

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  ...

  <!-- ページネーションを表示するヘルパーメソッド -->
  <%= paginate @restaurants %>
</div>


fromとsizeを指定する

Elasticsearchのfromsizeを指定します。
kaminariの機能の、pageメソッドで「ページ番号」を、perメソッドで「ページあたりの表示数」を設定することで自動的にfromsizeが計算されて、Elasticsearchに渡されます。

# app/controllers/top_controller.rb
class TopController < ApplicationController
  def index
    # page(params[:page]) - ページ番号を取得(paginateヘルパーがいいかんじにやってくれる)
    # per(40) - 1ページあたりの表示数 40件
    @restaurants = Restaurant.search(params).page(params[:page]).per(40)
  end
end


次のようにelasticsearch-modelが内部的にsizefromを計算しています。

# elasticsearch-rails/elasticsearch-model/lib/elasticsearch/model/response/pagination.rb

@page     = [num.to_i, 1].max
@per_page ||= __default_per_page
self.search.definition.update size: @per_page,
                              from: @per_page * (@page - 1)


ページネーションの動作確認

画面をロードすると1ページあたり40件表示されるようになります。
f:id:nipe880324:20151018040113p:plain:w420


このとき、RailsログのElasticsearchに発行されているクエリを見ると、size: 40, from: 40となっているので、うまくElasticsearchに値を渡せているとわかります。

Started GET "/?page=2" for ::1 at 2015-10-18 00:10:54 +0900
Processing by TopController#index as HTML
  Parameters: {"page"=>"2"}
  Restaurant Search (22.5ms) {index: "restaurant_development", type: "restaurant", body: {query: {filtered: {query: {match_all: {}}}}}, size: 40, from: 40}
  Rendered top/index.html.erb within layouts/application (32.5ms)
Completed 200 OK in 68ms (Views: 61.2ms | ActiveRecord: 0.0ms | Elasticsearch: 5.8ms)


4. ページあたりの表示件数を変更

次のように、「現在の表示件数のfrom値」と「ページあたりの表示件数のリンク」を表示できるようにします。
f:id:nipe880324:20151018040416p:plain:w420

ビューの修正

「現在の表示件数のfrom値」は、current_documentヘルパーメソッドを使って表示します。
また、「表示件数のリンク」は、per_page_linksヘルパーメソッドを使ってaタグを作成するようにします。

<!-- app/views/top/index.html.erb -->

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  <div>検索結果: <%= current_document %> / 約<%= @restaurants.results.total %></div>
  <div>表示件数: <%= per_page_links %></div>
  ...

from値の取得メソッドを作成

現在の表示件数のfrom値は、「現在のページ数 x 現在の表示件数」なので、それを返すようにします。

# app/helpers/application_helper.rb
module ApplicationHelper
  # 現在表示しているドキュメントのfrom値を表示
  # ※文字列を指定されるとうまくいかないかもしれない
  def current_document
    (params.fetch(:page, 1).to_i - 1) * params.fetch(:per, 0).to_i 
  end
end

表示件数のリンクを作成するメソッドを作成

次に、表示件数のリンクを作成するper_page_linksヘルパーメソッドを定義します。

# app/helpers/application_helper.rb
module ApplicationHelper
  ...

  # ページ表示件数のリンクを返す
  def per_page_links
    # 表示件数の初期値の設定
    current = query_string.fetch(:per, ::Restaurant::PER_PAGES.first).to_i

    # aタグの作成
    ::Restaurant::PER_PAGES.map do |per_page|
      if current == per_page
        per_page
      else
        link_to(per_page, "?#{query_string.merge(per: per_page).to_query}")
      end
    end.join(' | ').html_safe
  end

  # クエリストリングを作成
  # 表示件数やソート順などのリンクを押した時にqやclosedなどのパラメータは設定したままにする
  # pageは設定しないのでページは0ページ目ににクリアされる
  #   params - paramsオブジェクト
  def query_string(params)
    params.slice(:q, :closed, :per)
  end
end


RestaurantモデルにPER_PAGESとページネーションの表示件数を定義します。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  ...
  # ページの表示件数を追加
  PER_PAGES = [40, 80, 120]

  # デフォルトの1ページの表示件数
  paginates_per PER_PAGES.first
  ...
end


コントローラーでperメソッドを定数40から、params[:per]に変更します。

# app/controllers/top_controller.rb
class TopController < ApplicationController
  def index
    # per(params[:per])に変更
    @restaurants = Restaurant.search(params).page(params[:page]).per(params[:per])
  end
end

動作確認

画面を表示し、検索ボタン、表示件数のリンク、ページネーションのリンクを押してうまく動きます。
f:id:nipe880324:20151018040416p:plain:w420


RailsログのElasticsearchのクエリを見ると、sizefromの値が表示内容と同じあれば問題なさそうです。

Started GET "/?closed=t&page=2&per=120&q=%E6%9D%B1%E4%BA%AC%E9%83%BD" for ::1 at 2015-10-18 04:03:57 +0900
Processing by TopController#index as HTML
  Parameters: {"closed"=>"t", "page"=>"2", "per"=>"120", "q"=>"東京都"}
  Restaurant Search (11.2ms) {index: "restaurant_development", type: "restaurant", body: {query: {filtered: {query: {multi_match: {query: "東京都", fields: ["name", "name_kana", "address", "pref.name", "category.name"]}}}}}, size: 120, from: 120}
  Rendered top/index.html.erb within layouts/application (36.7ms)
Completed 200 OK in 73ms (Views: 60.8ms | ActiveRecord: 0.0ms | Elasticsearch: 11.2ms)

まとめ

kaminariを使ってページネーションを実装しました。
次は、「ソート機能」を実装します。