Rails Webook

Web業界で働いています。Railsを中心にプログラミングや事業開発のようなことを書いていきます。

Railsのページネーション機能のKaminariのソースコードリーディング

Railsにページネーション機能を追加するKaminariのソースコードを読んでみてまとめました。 本体のコード量は、700行程度ですので、比較的読みやすいと思います。  

モデルはActiveRecord::Baseのメソッドの名前空間を汚さずに、ページネーションのメソッドを追加する方法がライブラリ開発では参考になると思いました。
また、ビューあたりはRailsらしい黒魔術があり処理の流れや処理を理解するのが難しいですが、表示ロジックとHTML表示を分けてる実装は面白かったです。

1. 目的

下記のようなこと中心にソースコードを見ていきたいと思います。

  • ページネーションの実装方法
  • ページネーション機能をRails(ActiveRecord, View)に組み込む方法

2. 基本情報

対象バージョン

直近のタグが2017/9だったので、直近のコミットの2018/9/4時点で読んでいきます。

コード量

全体のrubyコードが2,700行程度ですが、本体のRubyコードは700行、そのテストコードは1800行程度です。 あとは、erb, slim hamlでページネーションの表示部分のhtmlを記載していそうです。

> cloc ./kaminari
     104 text files.
     100 unique files.
      20 files ignored.

github.com/AlDanial/cloc v 1.78  T=1.03 s (82.2 files/s, 5131.8 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Ruby                            45            583            402           2639
Markdown                         7            559              0            739
YAML                             4             11              3            112
ERB                             15              0             56             51
Slim                             7              1             49             29
Haml                             7              0             49             23
-------------------------------------------------------------------------------
SUM:                            85           1154            559           3593

ディレクトリ構成

通常のgemのディレクトリ構成と少し異なります。 ルート配下にlibtestディレクトリがあるのですが、コード本体はkaminari-core, kaminari-activerecord, kaminari-actionview内のlib配下にあります。
それぞれ、kaminari-corekaminari-actionviewkaminari-activerecordと別のgemになってるため、このようなディレクトリ構成になってそうです。

kaminari
├── gemfiles
├── kaminari-actionview
│   ├── lib
│   │   └── kaminari
│   │       ├── actionview
│   │       │   ├── action_view_extension.rb
│   │       │   └── version.rb
│   │       └── actionview.rb
│   ...
├── kaminari-activerecord
│   ├── lib
│   │   └── kaminari
│   │       ├── activerecord
│   │       │   ├── active_record_extension.rb
│   │       │   ├── active_record_model_extension.rb
│   │       │   ├── active_record_relation_methods.rb
│   │       │   └── version.rb
│   │       └── activerecord.rb
│   ...
├── kaminari-core
│   ├── app
│   │   └── views
│   │       └── kaminari
│   ├── config
│   ├── lib
│   │   ├── generators
│   │   └── kaminari
│   │        └ ...
│   ├── test
│   ...
├── lib
│   ├── kaminari
│   │   └── version.rb
│   └── kaminari.rb
├── test
│   └── test_helper.rb
...

ドキュメント

kaminariのREADMEにインストールから基本的な使い方までわかりやすく記載されています。 kaminari/kaminari

基本的な使い方

kaminariはRailsにインストールするとActiveRecordにページネーション機能を追加してくれます。 モデルのActiveRecordにpageスコープやperスコープなどが利用できるようになります。

# 3ページ目のユーザーを取得 ※1ページの列数はデフォルトで25
User.page(3) #=> #<ActiveRecord::Relation [#<User ...]>

# 1ページあたり10列で、3ページ目のユーザーを取得
User.page(3).per(10) #=> #<ActiveRecord::Relation [#<User ...]>

コントローラーでは、次のようにして特定ユーザーを取得します。

def index
  @user = User.order(:name).page(params[:page]).per(params[:per])
end

ビューでは、paginateヘルパーでページネーション部分を表示することができます。

<%= paginate @users %>

paginateヘルパーでレンダリングされたHTMLは次のかんじにようになります。

f:id:nipe880324:20180926171601p:plain:w420

<!-- レンダリングされたHTMLコード -->
<nav class="pagination" role="navigation" aria-label="pager">
  <span class="first"><a href="/users">« First</a></span>
  <span class="prev"><a rel="prev" href="/users">‹ Prev</a></span>
  <span class="page"><a rel="prev" href="/users">1</a></span>
  <span class="page current">2</span>
  <span class="page"><a rel="next" href="/users?page=3">3</a></span>
  <span class="page"><a href="/users?page=4">4</a></span>
  <span class="page"><a href="/users?page=5">5</a></span>
  <span class="next"><a rel="next" href="/users?page=3">Next ›</a></span>
  <span class="last"><a href="/users?page=5">Last »</a></span>
</nav>

また、Kaminariの設定はKaminari.configureメソッドで設定できます。

# config/initializers/kaminari.rb

# frozen_string_literal: true
Kaminari.configure do |config|
  # config.default_per_page = 25
  # config.max_per_page = nil
  # config.window = 4
  # config.outer_window = 0
  # config.left = 0
  # config.right = 0
  # config.page_method_name = :page
  # config.param_name = :page
  # config.params_on_first_page = false
end

3. ActiveRecordのpageメソッドとperメソッドの実装方法

まずは、pageメソッドとperメソッドの実装について見ていきます。

使い方としては次のように使えます。

# 1ページあたり10列で、3ページ目のユーザーを取得
User.page(3).per(10) #=> #<ActiveRecord::Relation [#<User ...]>

まずは、kaminariのえんとリーポイントのlib/kaminari.rbを核にします。
各パッケージを読み込んでいることがわかります。

# lib/kaminari.rb

# frozen_string_literal: true
require 'kaminari/core'
require 'kaminari/actionview'
require 'kaminari/activerecord'

ActiveRecord::Baseあたりなのでkaminari-activerecordのエントリーポイントのkaminari-activerecord/lib/kaminari/activerecord.rbを確認します。
Rails起動時にActiveRecordがロードされた時に、ActiveRecord::BaseActiveRecordExtensionモジュールをインクルードしています。

# kaminari-activerecord/lib/kaminari/activerecord.rb

# frozen_string_literal: true
require "kaminari/activerecord/version"
require 'active_support/lazy_load_hooks'

ActiveSupport.on_load :active_record do
  require 'kaminari/core'
  require 'kaminari/activerecord/active_record_extension'
  ::ActiveRecord::Base.send :include, Kaminari::ActiveRecordExtension
end

ActiveRecordExtensionをみると、クラス階層をみながら、親クラスがActiveRecord::Baseの場合、ActiveRecordModelExtensionをインクルードしています。

# kaminari-activerecord/lib/kaminari/activerecord/active_record_extension.rb

# frozen_string_literal: true
require 'kaminari/activerecord/active_record_model_extension'

module Kaminari
  module ActiveRecordExtension
    extend ActiveSupport::Concern

    module ClassMethods #:nodoc:
      # Future subclasses will pick up the model extension
      def inherited(kls) #:nodoc:
        super
        kls.send(:include, Kaminari::ActiveRecordModelExtension) if kls.superclass == ::ActiveRecord::Base
      end
    end

    included do
      # Existing subclasses pick up the model extension as well
      descendants.each do |kls|
        kls.send(:include, Kaminari::ActiveRecordModelExtension) if kls.superclass == ::ActiveRecord::Base
      end
    end
  end
end

ActiveRecordModelExtensionをみると、pageメソッドを追加しています。 変数ばかりで少し読みづらいですが、limit(per_page).offset(per_page * (num - 1))のようなことをしています。
ドキュメントにも記載のあった通り、ページネーションをActiveRecordのlimitoffsetで実装していることがわかります。

# kaminari-activerecord/lib/kaminari/activerecord/active_record_model_extension.rb

# frozen_string_literal: true
require 'kaminari/activerecord/active_record_relation_methods'

module Kaminari
  module ActiveRecordModelExtension
    extend ActiveSupport::Concern

    included do
      # per_pageやdefault_per_pageなどのメソッドが定義されているためインクルード
      include Kaminari::ConfigurationMethods

      # Fetch the values at the specified page number
      #   Model.page(5)
      eval <<-RUBY, nil, __FILE__, __LINE__ + 1
        # pageメソッドはメソッド名を設定で変更でいるため設定のpage_method_nameでメソッド名を作成、デフォルトは:page
        def self.#{Kaminari.config.page_method_name}(num = nil)
          per_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page
          limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending do # extendingは後から補足
            include Kaminari::ActiveRecordRelationMethods
            include Kaminari::PageScopeMethods
          end
        end
      RUBY
    end
  end
end

補足で、extendingメソッドは、スコープに動的にモジュールを追加してくれるメソッドです。
今回の場合は、pageメソッドやtotal_pagesメソッドなどが実装されているKaminari::ActiveRecordRelationMethodsKaminari::PageScopeMethodsをインクルードしています。こうすることで、ActiveRecordの名前空間を汚すのを避けているのだと思います。

# pageメソッド経由だとperメソッドが使える
User.page(1).per(10)
#=> #<ActiveRecord::Relation [#<User > ...]>

# 直接はperメソッドが使えない
User.per(10)
#=> NoMethodError (undefined method `per' for #<Class:0x00007fb8e7ccb760>)

では、pageメソッドでextendingされて追加されたperメソッドの実装をみてみます。
こちらも変数が多くて少し見辛いですが、簡略化するとlimitを呼んでいるだけです。必要に応じてoffsetも。

# lib/kaminari-core/lib/kaminari/models/page_scope_methods.rb

module Kaminari
  module PageScopeMethods
    # Specify the <tt>per_page</tt> value for the preceding <tt>page</tt> scope
    #   Model.page(3).per(10)
    def per(num, max_per_page: nil)
      max_per_page ||= ((defined?(@_max_per_page) && @_max_per_page) || self.max_per_page)
      @_per = (num || default_per_page).to_i
      if (n = num.to_i) < 0 || !(/^\d/ =~ num.to_s)
        self
      elsif n.zero?
        limit(n)
      elsif max_per_page && (max_per_page < n)
        limit(max_per_page).offset(offset_value / limit_value * max_per_page)
      else
        limit(n).offset(offset_value / limit_value * n)
      end
    end

    ...

  end
end

ActiveRecordは複数回limitoffsetを呼んでも、一番後ろの値でクエリを作ってくれるので挙動として問題ありません。

# 最後の値を使ってクエリが作られる
User.limit(10).limit(5).limit(8) #=> SELECT  "users".* FROM "users" LIMIT 8

4. ビューのpaginateヘルパーメソッドの実装方法

ビューでは、paginateヘルパーでページネーション部分を表示することができます。

<%= paginate @users %>

paginateヘルパーでレンダリングされたHTMLは次のかんじにようになります。

f:id:nipe880324:20180926171601p:plain:w420

<!-- レンダリングされたHTMLコード -->
<nav class="pagination" role="navigation" aria-label="pager">
  <span class="first"><a href="/users">« First</a></span>
  <span class="prev"><a rel="prev" href="/users">‹ Prev</a></span>
  <span class="page"><a rel="prev" href="/users">1</a></span>
  <span class="page current">2</span>
  <span class="page"><a rel="next" href="/users?page=3">3</a></span>
  <span class="page"><a href="/users?page=4">4</a></span>
  <span class="page"><a href="/users?page=5">5</a></span>
  <span class="next"><a rel="next" href="/users?page=3">Next ›</a></span>
  <span class="last"><a href="/users?page=5">Last »</a></span>
</nav>

kaminari-actionviewのエントリーポイントのkaminari-acttionview/lib/kaminari/actionview.rbをみます。
Rails起動時のActionViewがロードされたときに、ActionView::BaseKaminari::Helpers::HelperMethodsのインクルードをしています。

# kaminari-acttionview/lib/kaminari/actionview.rb

# frozen_string_literal: true
require "kaminari/actionview/version"
require 'active_support/lazy_load_hooks'

ActiveSupport.on_load :action_view do
  require 'kaminari/helpers/helper_methods'
  ::ActionView::Base.send :include, Kaminari::Helpers::HelperMethods

  require 'kaminari/actionview/action_view_extension'
end

まずは、action_view_extension.rbをみていきます。
(たぶん)ActionViewがPartialファイルをレンダリングするときにログ出力をするので、kaminari部分のレンダリングのときは表示しないパッチをあててます。
また、::Kaminari::Helpers::PaginatorActionView::Contextをインクルードしています。たぶん、Paginator内で、ActionView#renderメソッドにアクセスするためにインクルードしています。

# kaminari-acttionview/lib/kaminari/actionview/action_view_extension.rb

# frozen_string_literal: true
require 'action_view'
require 'action_view/log_subscriber'
require 'action_view/context'

module Kaminari
  # = Helpers
  module ActionViewExtension
    # Monkey-patching AV::LogSubscriber not to log each render_partial
    module LogSubscriberSilencer
      def render_partial(*)
        super unless Thread.current[:kaminari_rendering]
      end
    end
  end
end

# so that this instance can actually "render"
::Kaminari::Helpers::Paginator.send :include, ::ActionView::Context

ActionView::LogSubscriber.send :prepend, Kaminari::ActionViewExtension::LogSubscriberSilencer

では、ActionView::BaseにインクルードされたKaminari::Helpers::HelperMethodsの中の、paginateヘルパーをみていきます。
Kaminari::Helpers::Paginatorクラスに処理を委譲しています。

# kaminari-core/lib/kaminari/helpers/helper_methods.rb

module Kaminari
  module Helpers

    module UrlHelper
      ...
    end
    ...

    module HelperMethods
      include UrlHelper

      # ページネーションリンクをレンダーするヘルパー
      #
      # scopeはpageメソッドで返されたスコープ、そのため、current_pageやlimit_valueなどのextendingで追加されたメソッドが利用できる
      # selfはActionView::Baseのインスタンス??
      def paginate(scope, paginator_class: Kaminari::Helpers::Paginator, template: nil, **options)
        options[:total_pages] ||= scope.total_pages
        options.reverse_merge! current_page: scope.current_page, per_page: scope.limit_value, remote: false

        # self、はたぶんActionView::Baseの特異クラスのインスタンス
        # viewから実行できるrenderやヘルパーなどのメソッドが使える
        paginator = paginator_class.new (template || self), options
        paginator.to_s
      end
    
      ...
    end
  end
end

では、Kaminari::Helpers::Paginatorクラスをみてみます。
initializeでは、@window_optionsにページネーションの表示数などの値を設定しています。paginateヘルパーから渡されなかった場合は、Kaminariの設定値を使うようにしています。
また、HTMLの出力するto_sメソッドでは、親クラスのメソッドを呼び出しています。

# kaminari-core/lib/kaminari/helpers/paginator.rb

module Kaminari
  module Helpers
    # The main container tag
    class Paginator < Tag
      # ページネーションの設定値などの準備
      # @window_optionsの例
      # {:window=>4, :left=>0, :right=>0, :current_page=>#<Kaminari::Helpers::Paginator::PageProxy:0x00007f9c93b1a210 @last=nil, @options={...}, @page=2>, :per_page=>10, :remote=>false, :total_pages=>5}
      def initialize(template, window: nil, outer_window: Kaminari.config.outer_window, left: Kaminari.config.left, right: Kaminari.config.right, inner_window: Kaminari.config.window, **options) #:nodoc:
        @window_options = {window: window || inner_window, left: left.zero? ? outer_window : left, right: right.zero? ? outer_window : right}

        @template, @options, @theme, @views_prefix, @last = template, options, options[:theme], options[:views_prefix], nil
        @window_options.merge! @options
        @window_options[:current_page] = @options[:current_page] = PageProxy.new(@window_options, @options[:current_page], nil)

        ...
      end

      ...

      def to_s #:nodoc:
        Thread.current[:kaminari_rendering] = true
        super @window_options.merge paginator: self
      ensure
        Thread.current[:kaminari_rendering] = false
      end

      ...
    end
  end
end

親クラスのKaminari::Helpers::Tag#to_sを確認します。

@template.renderメソッドで部分テンプレートをレンダリングすることができます。をレンダリングしています。 @templateはビューコンテキストなので、ビューファイル内でrenderメソッドを呼んでいるのと同じような形です。

# kaminari-core/lib/kaminari/helpers/tag.rb
module Kaminari
  module Helpers
    class Tag
      ...

      # partial_pathは"kaminari/paginator"。また、kaminari-core/app/views/kaminari/_paginator.html.erbのテンプレートファイルがあるので、
      # そのテンプレートを呼び出してくれる
      # localesオプションで、テンプレートに変数を渡せる
      def to_s(locals = {}) #:nodoc:
        formats = (@template.respond_to?(:formats) ? @template.formats : Array(@template.params[:format])) + [:html]
        @template.render partial: partial_path, locals: @options.merge(locals), formats: formats
      end
      ...
    end
  end
end

Partialレンダリングされる、_paginator.html.erbをみると、paginator.renderメソッドをブロックを引数にして呼んでいます。
ブロック内では、first_page_taglast_page_tagなどをページネーションの各要素をレンダリングするメソッドを呼び出しています。

// kaminari-core/app/views_kaminari/_paginator.html.erb
<%# The container tag
  - available local variables
    current_page:  a page object for the currently displayed page
    total_pages:   total number of pages
    per_page:      number of items to fetch per page
    remote:        data-remote
    paginator:     the paginator that renders the pagination tags inside
-%>
<%= paginator.render do -%>
  <nav class="pagination" role="navigation" aria-label="pager">
    <%= first_page_tag unless current_page.first? %>
    <%= prev_page_tag unless current_page.first? %>
    <% each_page do |page| -%>
      <% if page.display_tag? -%>
        <%= page_tag page %>
      <% elsif !page.was_truncated? -%>
        <%= gap_tag %>
      <% end -%>
    <% end -%>
    <% unless current_page.out_of_range? %>
      <%= next_page_tag unless current_page.last? %>
      <%= last_page_tag unless current_page.last? %>
    <% end %>
  </nav>
<% end -%>

このPaginator#renderメソッドは、instance_eval(&block)でブロックを実行しています。
instance_evalは、paginagorインスタンスの元でブロックを実行します。
たとえば、先ほどのブロック内の<%= first_page_tag %>は、paginator.first_page_tag.to_sが呼ばれます。

出力例

paginator.first_page_tag.to_s
=> "<span class=\"first\">\n  <a href=\"/users\">&laquo; First</a>\n</span>\n"
# kaminari-core/lib/kaminari/helpers/paginator.rb

module Kaminari
  module Helpers
    # The main container tag
    class Paginator < Tag

      ...

      # render given block as a view template
      def render(&block)
        instance_eval(&block) if @options[:total_pages] > 1

        # This allows for showing fall-back HTML when there's only one page:
        #
        #   <%= paginate(@search_results) || "Showing all search results" %>
        @output_buffer.presence
      end

各ブロック内のメソッドは、各役割ごとにインスタンス化させて処理を委譲しています。

  • page_tagメソッド -> Pageクラス
  • first_page_tagメソッド -> FirstPageクラス
  • prev_page_tagメソッド -> PrevPageクラス
  • ...
# kaminari-core/lib/kaminari/helpers/paginator.rb

module Kaminari
  module Helpers
    # The main container tag
    class Paginator < Tag

      ...

      def page_tag(page)
        @last = Page.new @template, @options.merge(page: page)
      end

      %w[first_page prev_page next_page last_page gap].each do |tag|
        eval <<-DEF, nil, __FILE__, __LINE__ + 1
          def #{tag}_tag
            @last = #{tag.classify}.new @template, @options
          end
        DEF
      end

そして各クラスはKaminari::Helpers::Tagを継承しています。

Tagを継承することで、各タグに対応した部分テンプレートをレンダリングすることができます。

  • Page -> $GEM_HOME/kaminari-x.x.x/app/views/kaminari/_page.html.erb
  • FirstPage -> $GEM_HOME/kaminari-x.x.x/app/views/kaminari/_first_page.html.erb
  • LastPage -> $GEM_HOME/kaminari-x.x.x/app/views/kaminari/_last_page.html.erb

これは、Tag#partial_pathメソッド内で、クラス名から部分テンプレートのファイル名を取得するようになっているためです。

# kaminari-core/lib/kaminari/helpers/tag.rb
module Kaminari
  module Helpers
    class Tag
      ...

      # タグからHTMLを作成する
      # 部分テンプレートはクラス名から算出する
      def to_s(locals = {}) #:nodoc:
        formats = (@template.respond_to?(:formats) ? @template.formats : Array(@template.params[:format])) + [:html]
        @template.render partial: partial_path, locals: @options.merge(locals), formats: formats
      end

      private

      def partial_path
        [
         @views_prefix,
         "kaminari",
         @theme,
         # ここで、クラス名からファイル名を取得するようにしている
         self.class.name.demodulize.underscore
        ].compact.join("/")
      end
    end
    
    # A page
    class Page < Tag
      include Link
      # target page number
      def page
        @options[:page]
      end
      def to_s(locals = {}) #:nodoc:
        locals[:page] = page
        super locals
      end
    end

    # Link with page number that appears at the leftmost
    class FirstPage < Tag
      include Link
      def page #:nodoc:
        1
      end
    end

    # Link with page number that appears at the rightmost
    class LastPage < Tag
      include Link
      def page #:nodoc:
        @options[:total_pages]
      end
    end

    ...

5. Configuの実装方法

最後に、KaminariのConfigの実装方法を軽くみます。

使用イメージは次の通りです。

# 設定値を取得
default_per_page = Kaminari::config.default_per_page

# 設定値を設定
Kaminari.configure do |config|
  config.default_per_page = 10
end

# 直接設定ができるが、configureを通して設定した方が明示的
Kaminari.config.default_per_page = 10

設定値を保持するクラスのKaminari::Configクラスがあり、initializeメソッドでデフォルト値でインスタンスを作成しています。
そして、設定可能な設定値に対しては、attr_accessorで外から設定できるようにしています。 また、明示的に設定をするためにKaminari.configureでブロックで設定できるようにしています。
とてもシンプルで設定値を1つのクラスにまとめれるので便利だと思います。

# kaminari-core/lib/kaminari/config.rb

# frozen_string_literal: true

module Kaminari
  # Configures global settings for Kaminari
  #   Kaminari.configure do |config|
  #     config.default_per_page = 10
  #   end
  class << self
    def configure
      yield config
    end

    def config
      @_config ||= Config.new
    end
  end

  class Config
    attr_accessor :default_per_page, :max_per_page, :window, :outer_window, :left, :right, :page_method_name, :max_pages, :params_on_first_page
    attr_writer :param_name

    def initialize
      @default_per_page = 25
      @max_per_page = nil
      @window = 4
      @outer_window = 0
      @left = 0
      @right = 0
      @page_method_name = :page
      @param_name = :page
      @max_pages = nil
      @params_on_first_page = false
    end

    # If param_name was given as a callable object, call it when returning
    def param_name
      @param_name.respond_to?(:call) ? @param_name.call : @param_name
    end
  end
end

まとめ

Kaminariでページネーションを実装しているソースコードを読みました。

モデル側では、pageメソッド、perメソッドなどを定義していました。
このとき、pageメソッドでextendingを使うことで、ActiveRecord::Baseの名前空間をできるだけ汚さないようにしているのはライブラリの実装としてとても参考になるような実装だと思いました。

ビュー側では、ActionView::Baseの知識やRailsらしい黒魔術があり、処理の流れをおうのが若干難しかったです。 ページネーションの表示は、ロジック部分をPaginatorクラスなどのクラスを作成することで、表示部分とロジック部分を分離しているところは、複雑ながらも面白かったです。

以上です。