Railsにページネーション機能を追加するKaminariのソースコードを読んでみてまとめました。 本体のコード量は、700行程度ですので、比較的読みやすいと思います。
モデルはActiveRecord::Base
のメソッドの名前空間を汚さずに、ページネーションのメソッドを追加する方法がライブラリ開発では参考になると思いました。
また、ビューあたりはRailsらしい黒魔術があり処理の流れや処理を理解するのが難しいですが、表示ロジックとHTML表示を分けてる実装は面白かったです。
- 1. 目的
- 2. 基本情報
- 3. ActiveRecordのpageメソッドとperメソッドの実装方法
- 4. ビューのpaginateヘルパーメソッドの実装方法
- 5. Configuの実装方法
- まとめ
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のディレクトリ構成と少し異なります。
ルート配下にlib
とtest
ディレクトリがあるのですが、コード本体はkaminari-core
, kaminari-activerecord
, kaminari-actionview
内のlib
配下にあります。
それぞれ、kaminari-core
、kaminari-actionview
、kaminari-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は次のかんじにようになります。
<!-- レンダリングされた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::Base
にActiveRecordExtension
モジュールをインクルードしています。
# 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のlimit
、offset
で実装していることがわかります。
# 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::ActiveRecordRelationMethods
やKaminari::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は複数回limit
やoffset
を呼んでも、一番後ろの値でクエリを作ってくれるので挙動として問題ありません。
# 最後の値を使ってクエリが作られる User.limit(10).limit(5).limit(8) #=> SELECT "users".* FROM "users" LIMIT 8
4. ビューのpaginateヘルパーメソッドの実装方法
ビューでは、paginate
ヘルパーでページネーション部分を表示することができます。
<%= paginate @users %>
paginate
ヘルパーでレンダリングされたHTMLは次のかんじにようになります。
<!-- レンダリングされた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::Base
にKaminari::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::Paginator
にActionView::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_tag
やlast_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\">« 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
クラスなどのクラスを作成することで、表示部分とロジック部分を分離しているところは、複雑ながらも面白かったです。
以上です。