猫Rails

ねこー🐈

Wardenの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

Wardenとは?

  • 認証のフレームワーク。実際の認証は自分で実装する
  • 認証はRackミドルウェアで行う
  • Wardenをアプリで直接使うことはなさそう。deviseのような認証ライブラリのためのライブラリと考えたほうが良さそう
  • Deviseで使われてる。Wardenのstrategy/scope/callback等の知識がないとDeviseのコード読むのつらそう

導入

1. インストール

# Gemfile
gem 'warden'
gem 'bcrypt' # has_secure_passwordのため
$ bundle

2. ベースとなるRailsアプリ

  • routes
# config/routes.rb
Rails.application.routes.draw do
  get 'welcome/index'.
  root 'welcome#index'
end
  • コントローラー
# app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
  def index
    render text: "Welcome guest, it's #{Time.now}"
  end
end
  • Userのmigration
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :username
      t.string :password_digest
      t.string :authentication_token # 認証にはこれを使う

      t.timestamps null: false

      t.index :authentication_token, unique: true
    end
  end
end
  • Userモデル
# app/models/user.rb
class User < ActiveRecord::Base
  after_create :generate_authentication_token!

  has_secure_password

  private

  # user生成後に一意となるauthentication_tokenを作成する
  def generate_authentication_token!
    self.authentication_token = Digest::SHA1.hexdigest("#{Time.now}-#{self.id}-#{self.updated_at}")
    self.save
  end
end

3. Strategyを定義する

  • strategyは認証の実装。これらを切り替えることで、認証方法を切り替えられる(Strategyパターン)
  • ここで定義するのは認証トークンを利用した認証
  • Railsの場合はlib/strategiesを用意してそこに置くのが良さげ
# lib/strategies/authentication_token_strategy.rb
class AuthenticationTokenStrategy < ::Warden::Strategies::Base
  # ガード
  # params['authentication_token']がある場合のみ、authenticate!を実行する
  def valid?
    params['authentication_token']
  end

  # params['authentication_token']を使い認証
  # userが存在すれば、認証成功
  # コントローラーでenv["warden"].authenticate!の形で利用する
  def authenticate!
    user = User.find_by_authentication_token(params['authentication_token'])

    if user
      # 認証成功
      # ここで渡したuserは、コントローラーからenv['warden'].userで取り出せる
      success!(user)
    else
      # 認証失敗
      # ここで渡したメッセージは、コントローラーからenv['warden'].messageで取り出せる
      fail!('strategies.authentication_token.failed')
    end
  end
end

4. Strategyをwardenに追加する

# config/initializers/warden.rb

require Rails.root.join('lib/strategies/authentication_token_strategy')

# `AuthenticationTokenStrategy`を`:authentication_token`で参照できるようになる
Warden::Strategies.add(:authentication_token, AuthenticationTokenStrategy)

5. wardenをRackミドルウェアスタックに追加

  • session(cookie)ミドルウェアの後に追加すべき。ここではflashの後に追加しておく。
# config/application.rb
config.middleware.insert_after ActionDispatch::Flash, Warden::Manager do |manager|
  # 先程定義したstrategyをデフォルトのstrategyとする
  manager.default_strategies :authentication_token
end

6. 認証情報をコントローラーから利用する

  • current_user等のメソッドをenv['warden']を使い定義する
  • authenticate!before_filterでリクエストのたびに実行されるようにする
# app/controllers/concerns/warden_helper.rb
module WardenHelper
  extend ActiveSupport::Concern

  included do
    # ビューでも使えるようにする
    helper_method :warden, :signed_in?, :current_user

    # before_filterで認証チェック
    prepend_before_filter :authenticate!
  end

  def signed_in?
    !current_user.nil?
  end

  def current_user
    warden.user
  end

  def warden
    request.env['warden']
  end

  def authenticate!
    warden.authenticate!
  end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include WardenHelper
end

strategy

strategyとは?

  • strategyは認証の実装。これらを切り替えることで、認証方法を切り替えられる(Strategyパターン)

Warden::Strategies::Baseとは?

  • 各strategyの親クラス
  • これのサブクラスを定義することで、独自のstrategyを定義できる

使い方1. Warden::Strategies::Baseを継承する場合

  • authentiate!は必須
  • valid?はなくてもOK。ない場合はtrueで、必ず実行される
# lib/strategies/authentication_token_strategy.rb
class AuthenticationTokenStrategy < ::Warden::Strategies::Base
  # ガード
  # params['authentication_token']がある場合のみ、authenticate!を実行する
  def valid?
    params['authentication_token']
  end

  # params['authentication_token']を使い認証
  # userが存在すれば、認証成功
  # コントローラーでenv["warden"].authenticate!の形で利用する
  def authenticate!
    user = User.find_by_authentication_token(params['authentication_token'])

    if user
      # 認証成功
      # ここで渡したuserは、コントローラーからenv['warden'].userで取り出せる
      success!(user)
    else
      # 認証失敗
      # ここで渡したメッセージは、コントローラーからenv['warden'].messageで取り出せる
      fail!('strategies.authentication_token.failed')
    end
  end
end

# 追加
Warden::Strategies.add(:authentication_token, AuthenticationTokenStrategy)

使い方2. 定義 + 追加

  • addする際に、同時に定義することも可能
  • 内部でWarden::Strategies::Baseのサブクラスを定義してる
Warden::Strategies.add(:authentication_token) do
  def valid?
    ...
  end

  def authenticate!
    ...
  end
end

使い方3. 復数strategyを使う場合

  • sample1のトークン認証strategyに加えて、Basic認証strategyも使う
# lib/strategies/basic_auth_strategy.rb
class BasicAuthStrategy < ::Warden::Strategies::Base
  def auth
    @auth ||= Rack::Auth::Basic::Request.new(env)
  end

  def valid?
    auth.provided? && auth.basic? && auth.credentials
  end

  def authenticate!
    user = User.find_by_username(auth.credentials[0])
    if user && user.authenticate(auth.credentials[1])
      success!(user)
    else
      fail!('strategies.basic_auth.failed')
    end
  end
end
  • WardenのStrategyに追加
# config/initializers/warden.rb
require Rails.root.join('lib/strategies/authentication_token_strategy')
require Rails.root.join('lib/strategies/basic_auth_strategy')

Warden::Strategies.add(:authentication_token, AuthenticationTokenStrategy)
Warden::Strategies.add(:basic_auth, BasicAuthStrategy)
  • デフォルトのstrategyに指定。デフォルトstrategyは復数指定可能
# config/application.rb
config.middleware.insert_after ActionDispatch::Flash, Warden::Manager do |manager|
  manager.default_strategies :authentication_token, :basic_auth
end
  • 順番に実行されて、halt!されるまでcascadeされる
env['warden'].authenticate

scope

scopeとは?

  • :user以外にも、:adminのようなユーザーを作ることができる。それぞれにstrategyを適用することができる
  • デフォルトのscopeは:default。scope指定がない場合は常に:default
  • deviseでも使われてる。current_userとかをcurrent_adminとして扱えるやつ

使い方

scopeを設定する

use Warden::Manager do |config|
  # default_stragetiesの代わりに、default_scopeを指定する
  config.default_scope = :user

  # 各scopeに、strategiesを指定する
  config.scope_defaults :user,        :strategies => [:password]
  config.scope_defaults :account,     :store => false,  :strategies => [:account_by_subdomain]
  config.scope_defaults :membership,  :store => false,  :strategies => [:membership, :account_owner]
  config.scope_defaults :api,         :store => false  ,:strategies => [:api_token], :action => "unauthenticated_api"
end

scopeを使用する

# 認証
env['warden'].authenticate(:scope => :admin)

# 認証済みか?
env['warden'].authenticated?(:admin)

# userへのアクセス
env['warden'].user(:admin)

# ログアウト
env['warden'].logout(:admin)

callback

callbackとは?

  • env['warden'].user等にcallbackを仕掛けられる

使い方

  • callbackは宣言順に実行される
Warden::Manager.after_set_user do |user, warden, opts|
  unless user.active?
    warden.logout
    throw(:warden, :message => "User not active")
  end
end

種類

Warden::Manager.after_set_user: userをset後

基本

  • trigger
    • env['warden'].userを初めて呼ぶ時
    • env['warden'].authenticate
    • env['warden'].set_uesr(user)
Warden::Manager.after_set_user do |user, warden, opts|
  unless user.active?
    warden.logout
    throw(:warden, :message => "User not active")
  end
end

only: 指定のtriggerだけ実行

# triggerは以下の3つ
env['warden'].user           # fetch
env['warden'].authenticate   # authentication
env['warden'].set_uesr(user) # set_user

# fetch時だけ実行
Warden::Manager.after_set_user only: :fetch

except: 指定のtriggerでは実行しない

Warden::Manager.after_authentication: 認証後

  • Warden::Manager.after_set_user only: :authentication のalias
Warden::Manager.after_authentication do |user,warden,opts|
  user.last_login = Time.now
end

Warden::Manager.after_fetch: user取得後

  • Warden::Manager.after_set_user only: :fetchのalias

Warden::Manager.before_failure: failure_app呼び出し前

Warden::Manager.before_failure do |env, opts|
  request = Rack::Request.new(env)
  env['SCRIPT_INFO'] =~ /\/(.*)/
  request.params[:action] = $1
end

Warden::Manager.after_failed_fetch: ?

  • trigger
    • env['warden'].user

Warden::Manager.before_logout: ログアウト前

  • trigger
    • env['warden'].logout
Warden::Manager.before_logout do |user,warden,opts|
  user.forget_me!
  warden.response.delete_cookie "remember_token"
end

Warden::Manager.on_request: リクエスト毎(wardenインスタンス生成時)

Warden::Manager.on_request do |warden|
  # do_something
end

Warden::Manager.prepend_xxxxx: prepend

  • prependしたい場合は、prepend_xxxxとする
  • prepend_before_failureとか

failure_app

  • 認証失敗時の処理をRackアプリとして実装できる
  • failure_appとしてセットされたRackアプリは、user.authenticate! で全ての認証が失敗した時に起動される

使い方1. lambdaを使う場合

  • RackアプリならなんでもOK。lambdaでもOK
config.middleware.use Warden::Manager do |manager|
  manager.default_strategies :authentication_token
  manager.failure_app = ->(env) { ['401', {'Content-Type' => 'application/json'}, { error: 'Unauthorized', code: 401 }] }
end

使い方2. クラスを使う場合

# app/controllers/unauthorized_controller.rb
class FailureApp
  def call(env)
    # `fail!`のメッセージを取得できる
    error_message = env["warden"].message

    status = 401
    headers = { "Content-Type" => "application/json"}
    body = [error_message]

    [stauts, headers, body]
  end
end
# config/application.rb
config.middleware.insert_after ActionDispatch::Flash, Warden::Manager do |manager|
  manager.default_strategies :authentication_token
  manager.failure_app = FailureApp
end

使い方3. Railsのコントローラーを使う場合

# config/initializers/warden.rb
Rails.application.config.middleware.use Warden::Manager do |manager|
  manager.default_strategies :authentication_token

  # アクションを直接指定してもよいが開発環境でキャッシュされてしまうようなので、lambdaにしておくのが良さそう
  manager.failure_app = ->(env) { SessionsController.action(:new).call(env) }
end

# app/controllers/sessions_controller.rb
def new
  flash.now.alert = warden.message if warden.message.present?
end

userをsessionに保存する

  • userインスタンスをそのままsessionに突っ込むのではなく、user.idだけ突っ込む
# /config/initializers/warden.rb
# sessionにはuser.idを保存する
Warden::Manager.serialize_into_session do |user|
  user.id
end

# sessionからuser.idを取り出して、userを取得する
Warden::Manager.serialize_from_session do |id|
  User.find(id)
end

未ログイン時のルーティングを指定する

  • constraintとwardenを利用することで、未ログイン時のルーティングを指定できる
# config/routes.rb
# 未ログインの場合は、このルーティングが適用される
scope constraints: ->(request) { request.env['warden'].user.nil? } do
  get "signup", to: "users#new", as: "signup"
  get "login", to: "sessions#new", as: "login"
end

resources :users
resources :sessions

テスト

ヘルパーを追加

  • login_aslogoutを追加してくれる
include Warden::Test::Helpers

login_as: ログイン

# "A User"として、ログインする
login_as "A User"

# "An Admin"として、ログインする
login_as "An Admin", :scope => :admin

# 両方で、ログインする
login_as "A User"

ログアウト

# ログアウトする
logout

# adminとして、ログアウトする
logout :admin

使いそうなメソッドまとめ

Warden::Strategies::Base

request: Rack::Requestオブジェクト

session: sessionオブジェクト

params: パラメータオブジェクト

env: Rackのenv

succes!: 認証成功 + halt!

  • halt!でstrategyのcascadingを止める
# このuserはenv['warden'].userでアプリから取り出せる
success! user

fail: 認証失敗 + halt!

  • halt!でstrategyのcascadingを止める
  • !なしだとhalt!しないっぽい。次のstrategyに移り実行する
# このメッセージはenv['warden'].messageでアプリから取り出せる
fail "Invalid email or password"

redirect!: リダイレクト + halt!

custom!: カスタムのRackアプリ配列 + halt!

halt!: strategyのcascadingを止める

  • 後続のstrategyは実行されない

pass: このstrategyを飛ばす

Warden::Manager

manager.default_strategies: デフォルトのstrategy

# passwordとbasicの2つのstrategyをデフォルトとしてセット
manager.default_strategies :password, :basicp

manager.failure_app=: failure_app設定

manager.failure_app = FailureApp

manager.default_scope: デフォルトのscope

use Warden::Manager do |manager|
  manager.failure_app = Public::SessionsController.action(:new)

  # default_stragetiesの代わりに、default_scopeを指定する
  manager.default_scope = :user

  # 各scope*4に、strategiesを指定する
  manager.scope_defaults :user,        :strategies => [:password]
  manager.scope_defaults :account,     :store => false,  :strategies => [:account_by_subdomain]
  manager.scope_defaults :membership,  :store => false,  :strategies => [:membership, :account_owner]
  manager.scope_defaults :api,         :store => false  ,:strategies => [:api_token], :action => "unauthenticated_api"

end

manager.scope_defaults: デフォルトのstrategy(scope)

Warden::Manager.serialize_into_session: sessionに保存する

Warden::Manager.serialize_into_session do |user|
  user.id
end

Warden::Manager.serialize_from_session: sessionから取り出す

Warden::Manager.serialize_from_session do |id|
  User.find(id)
end

Railsコントローラー

env['warden']: wardenを取得

env['warden'].user: 認証成功時にsetしたuser

env['warden'].user # デフォルトscope
env['warden'].user(:api) # scope指定

env['warden'].message: 認証失敗時にsetしたエラーメッセージ

env['warden'].authenticated?: 認証済みなら、true

env['warden'].authenticated? # デフォルトscope
env['warden'].authenticated?(:foo) # scope指定

env['warden'].authenticate!: 認証する

env['warden'].authenticate # 認証(失敗時に、処理を継続する)
env['warden'].authenticate! # 認証(失敗時に、例外を投げる)
env['warden'].authenticate(:password) # passwordストラテジーを利用する
env['warden'].authenticate(:password, :basic) # strategyは復数指定可能
env['warden'].authenticate!(:scope => :api) # scopeを指定する

env['warden'].valid?: ガード

env['warden'].set_user(@user): userを自分でセットする

env['warden'].set_user(@user) # デフォルトscope
env['warden'].set_user(@user, :scope => :admin) # scope指定
env['warden'].set_user(@user, :store => false) # 今回のリクエストだけで、sessionには保存しない

env['warden'].logout: ログアウト

env['warden'].logout # デフォルトscope
env['warden'].logout(:sudo) # scope指定

ざっくりコードリーディング

  • v1.2.8
  • 難しい...あんまりわからんかった

warden.gemspec

  • 依存gemはrackのみ

lib/warden/proxy.rb

  • env['warden']Warden::Proxy.new
  • env['warden']のメソッド群はここに定義されてる

lib/warden/strategies

  • Warden::Strategies.addが定義されてる

lib/warden/strategies/base.rb

  • Warden::Strageties::Baseは全てのStrategyの親クラス
  • Warden::Strategies.addを使えば、WArden::Strageties::Baseを継承した自前のstrategyを簡単に定義できる
  • succsess!等のメソッドはここに定義されてる

参考URL

RequestStoreの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

request_storeとは?

  • リクエスト毎にグローバルな変数を使える
  • ☆975

導入

1. インストール

gem 'request_store'
$ bundle

2. 使ってみる

# ApplicationController
before_action :set_current_user
def set_current_user
  RequestStore.store[:current_user] = current_user
end

# モデル
RequestStore.store[:current_user] #=> current_user

類似機能との比較

Thread.current

  • スレッドローカル変数。つまりスレッド単位でグローバルな変数。(ドキュメントにはnot thread-local but fiber-localと書いてあったが違いがわからんかった。fiber使わんし難しい...)
  • request_storeは内部でコレ使ってる
# Thread.current: 現在のスレッド
# Thread.current[:foo] 現在のスレッドにおいて、グローバルな変数
Thread.current[:foo] = 0
Thread.current[:foo] #=> 0

問題点

  • アプリサーバはリクエスト1回分でスレッドが終了せず、次のリクエストも同じスレッドで処理する。その際に値がリセットされていないので、前回の値を持ち越してしまう
# 毎回0にリセットされず、1,2,3,...と増えていく
def index
  Thread.current[:counter] ||= 0
  Thread.current[:counter] += 1

  render :text => Thread.current[:counter]
end

Webrickでは問題ないらしい

ActiveSupport::CurrentAttributes

  • リクエスト毎にリセットされるスレッドローカルな属性を定義できる
  • こちらも内部でThread.currentを利用してる
  • ユースケースとしてはrequest_id等を想定してるっぽい
  • Rails5.2で追加
  • 参考: https://github.com/rails/rails/pull/29180

使い方

  • たぶんこんな感じ?自信なし
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  # 属性定義。この属性はリクエスト事にリセットされる
  attribute :user
end


# before_actionで`Current.user`をセット。これでどこからでも`Current.user`でcurrent_userにアクセスできるようになる
class ApplicationController < ActionController::Base
  before_action :set_current_user

  def set_current_user
    Current.user = currrent_user
  end
end

# モデル
Current.user #=> currrent_user

使う際の注意点

グルーバルにアクセスできちゃう

  • グルーバルにアクセスできちゃうので、
    • MVCが壊れてカオスになる
    • テストが難しくなる
  • コレが必要になる場合は設計が間違っている可能性があるので、まずは設計を見直す
  • CurrentAttributesの記事だけど、同じことが当てはまりそう: https://techracho.bpsinc.jp/hachi8833/2017_08_01/43810

リクエスト毎にマルチスレッド使うのはNG

テスト

  • ミドルウェアを追加する必要あり
# spec_helper.rb
def app
  Rack::Builder.new do
    use RequestStore::Middleware
    run MyApp
  end
end

Rails以外で使う

  • ミドルウェアを追加すればOK
use RequestStore::Middleware

ざっくりコードリーディング

  • コードベースはかなり小さい

request_store.gemspec

  • 依存gem
    • rack: Rackミドルウェアを使うため

lib/request_store.rb

  • RequestStore
  • Thread.current[:request_store]を便利に扱えるようにしたラッパーのようなクラス
module RequestStore
  # `RequestStore.store[:foo]`は`Thread.current[:request_store][:foo]`に相当
  def self.store
    Thread.current[:request_store] ||= {}
  end

  ...
end

lib/request_store/middleware.rb

  • RequestStore::Middleware
  • レスポンス時にThread.current[:request_store]をクリアするRackミドルウェア
    # callはRackミドルウェアの規約
    def call(env)
      # リクエストの処理
      # フラグ立てる
      RequestStore.begin!

      # Rackアプリ本体の処理
      response = @app.call(env)

      # リクエスト時の処理
      # フラグ折る + データをクリア
      returned = response << Rack::BodyProxy.new(response.pop) do
        RequestStore.end!
        RequestStore.clear!
      end
    ensure
      unless returned
        RequestStore.end!
        RequestStore.clear!
      end
    end

lib/request_store/railtie.rb

  • ミドルウェアを追加したり

参考URL

Gonの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

Gonとは

  • Railsの変数をJSで使えるようになる

導入

1. インストール

# Gemfile
gem 'gon'
$ bundle

2. セットアップ

# application.html.erb

<head>
  ...
  # application.jsの前に読み込めばロード前にgon変数にアクセスできる
  #
  # 以下のようなHTMLを生成してくれる
  # <script>
  # //<![CDATA[
  # window.gon={};gon.hoge=1;
  # //]]>
  # </script>
  <%= Gon::Base.render_data %>
  <%= javascript_include_tag "application" %>
  ...
</head>

3. 使ってみる

class ProductsController < ApplicationController
  def index
    # Rails側で変数をセット
    gon.hoge = 1

    ...
  end
end
# coffee

# JS側で使用できる
# 型キャストやエスケープもいい感じにやってくれる
gon.hoge // => 1

設定

使い方

  • Gon::Base.render_dataのオプションとして指定する
Gon::Base.render_data(watch: true)

watch: watch機能を有効化する

  • watch機能 を参照
  • 実際にはfalseでもwatch機能は使える。明示的に宣言するだけ
  • デフォルト: false

camel_case: キャメルケースに変換する

  • デフォルト: false
# 設定
Gon::Base.render_data(camel_case: true)

# ruby
gon.int_cased = 1

# js
gon.intCased #=> 1

camel_depth: キャメルケースを適用する深さ

  • デフォルト: 1
# 設定
Gon::Base.render_data(:camel_case => true, :camel_depth => 2)

# js
gon.testHash.testDepthOne.test_depth_two

namespace: gonという名前空間を変える

  • デフォルト: 'gon'
# 設定
Gon::Base.render_data(:namespace => 'serverExports')

# js
serverExports.your_int

init: window.gon = {}で初期化する

  • デフォルト: true
# js
# 初期化されてるので、データがない場合でもエラーにならない
window.gon // => {}

type: <script>type="text/javascript"を追加する

  • false
Gon::Base.render_data(type: true) #=> <script type="text/javascript">window.gon=...</script>

nonce: <script>nonce=...を追加する

  • CSP対応
  • デフォルト: nil
Gon::Base.render_data(nonce: 'test') #=> "<script nonce=\"test">...

need_tag: <script>タグあり

  • デフォルト: true
Gon::Base.render_data(need_tag: true)
#=> <script>
#=> //<![CDATA[
#=> window.gon={};gon.hoge="piyo";
#=> //]]>
#=> </script>
Gon::Base.render_data(need_tag: false)
#=> window.gon={};gon.hoge="piyo";

cdata: CDATAあり

  • デフォルト: true

global_root: globalという名前空間を変える

  • デフォルト: 'global'

amd: AMD対応

  • デフォルト: false
  • include_gon_amdと同じ

<script>タグのrenderメソッド*3

Gon::Base.render_data

  • 基本コレを使えばok

include_gon

  • Rails3の場合はこっちらしい
  • 内部でGon::Base.render_dataを利用してる
<%= include_gon %>

include_gon_amd

  • AMDの場合はこっち
  • 内部でGon::Base.render_dataを利用してる
<%= include_gon_amd %>

メソッド

gon.hoge=: set

gon.hoge = 1

gon.push: set

gon.push(hoge: 1, piyo: 2)

gon.hoge: get

gon.hoge #=> 1

gon.all_variables: setした全ての値

gon.hoge = 1
gon.piyo = 2
gon.all_variables #=> {"hoge"=>1, "piyo"=>2}

gon.clear: setした値を消す

gon.hoge = 1
gon.piyo = 2
gon.all_variables #=> {"hoge"=>1, "piyo"=>2}
gon.clear
gon.all_variables #=> {}

watch機能

  • Ajaxでポーリングして、gonのデータをリアルタイムで取得する

導入

1. watchオプションをtrueにする

  • どうもwatchオプションは明示的にする意味しかなく、指定しなくてもOKっぽい
# app/views/layouts/application.html.erb
<%= include_gon(watch: true) %>

2. アプリ作成

  • コントローラーでgon.watchを利用するのがポイント
# app/controllers/home_controller.rb
# ここにAjaxリクエストが送られる。そのたびにusers_countの値がレスポンスして更新される
def index
  @users_count = User.count
  gon.watch.users_count = @users_count
end
# app/views/home/index.html.erb
# Ajax成功時に、コールバックでここの表示が変わる
<div id='users-counter'></div>

3. JS

  • gon.watch()は一定間隔でリクエストを送り、コントローラーでgonにセットした変数をコールバックで利用する
# app/assets/javascripts/home.js.coffee
# コールバック
#   uesrs_count: Ajaxのレスポンス値
renewUsers = (uesrs_count) ->
  $('#users-counter').text(users_count)

# gon.watchの使い方
# gon.watchを使うとポーリングできる
# ここでは1秒ごとに`/home`にAjaxリクエストを送って、`#users-counter`の表示を変更している
#
# gon.watch(name_of_variable, options, callback)
#   name_of_variable: 変数名(コントローラーで`gon.watch.users_count = @users_count`とすれば、'users_count'となる)
#   options
#     interval: Ajaxリクエストする間隔。ms
#     method: HTTPメソッド。デフォルトはGET
#     url: 陸ストを送るURL
#   callback: Ajaxのコールバック
gon.watch('users_count', interval: 1000, renewUsers)

止める

  • gon.unwatchで止める
# ビュー
<a href='#' id='stop-renewing'>
  Stop renewing
</a>
# coffee
$('#stop-renewing').click ->
  # 止める
  gon.unwatch('users_count', renewUsers)
  return false

グローバルに使う

  • Gon.globalを使う
# config/initializers/some_initializer.rb
Gon.global.variable = 1
# coffee
gon.global.variable // => 1

RSpecでコントローラーテスト

# spec/support/shared_contexts/gon.rb
shared_context :gon do

  # Gonは内部でRequestStore(リクエストグローバルな変数。gem)を利用している
  # RequestStoreはRackミドルウェアで変数を削除する
  # しかしテストではRackミドルウェアを経由しない
  # そのためテストケース毎にGonの変数を手動でクリアする必要がある
  # 参考: https://tech.misoca.jp/entry/2015/06/15/151419
  let(:gon) { RequestStore.store[:gon].gon }
  before { Gon.clear }
end

# spec/controllers/thingies_controller_spec.rb
RSpec.describe ThingiesController do
  include_context :gon

  describe 'GET #new' do
    it 'gonifies as expected' do
      get :new, {}, valid_session

      # Gonに変数がsetされていることをテスト
      expect(gon['key']).to eq :value
    end
  end
end

ざっくりコードリーディング

gon.gemspec

  • 依存gem
    • actionpack: Railsのcontroller/routing
    • request_store: リクエストグローバルな変数
    • multi_json: 主要なJSON Engineに対応したJSONパーサ

lib/gon.rb

  • メインとなるGonクラス
  • コントローラーのgonの正体はGon
  • gon.hoge = 1gon.hogeにはmethod_missingを利用している。

lib/gon/base.rb

  • Gon::Base.render_data

-Gon::Base.render_dataのオプションのデフォルト値

    VALID_OPTION_DEFAULTS = {
        namespace: 'gon',
        camel_case: false,
        camel_depth: 1,
        watch: false,
        need_tag: true,
        type: false,
        cdata: true,
        global_root: 'global',
        namespace_check: false,
        amd: false,
        nonce: nil
    }

lib/gon/global.rb

  • Gon::GlobalはGonのサブクラス
  • Gon::Globalはgon.globalに対応
  • Gonとの違いはあんまりないっぽい

lib/gon/watch.rb

  • Gon::WatchはGonのサブクラス
  • Gon::Watchはgon.watchに対応

coffee/watch.coffee

  • gon.watchgon.unwatchgon.unwatchAllが定義
  • jQuery依存っぽい

js/watch.js

  • coffee/watch.coffeeをコンパイルしたファイル

参考URL

Awesome Printの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

Awesome Printとは

  • コンソールやログ等の出力が見やすくするgem。いい感じにインデントを追加してくれたり、カラーリングしてくれたりする。

導入

インストール

group :development do
  gem "awesome_print"
end
$ bundle install

使ってみる

$ rails console
# 色付きで、いい感じのインデントで出力
ap Cat.first

メソッド

ap(object): オブジェクトを見やすく出力

ap Cat.first

object.ai: オブジェクトを見やすく加工

  • apの戻り値版

使い方

obj.ai
obj.ai(plain: false) # 色付けしない
obj.ai(html: false)  # htmlに加工しない

オプション

  indent:        4,      # Number of spaces for indenting.
  index:         true,   # Display array indices.
  html:          false,  # Use ANSI color codes rather than HTML.
  multiline:     true,   # Display in multiple lines.
  plain:         false,  # Use colors.
  raw:           false,  # Do not recursively format instance variables.
  sort_keys:     false,  # Do not sort hash keys.
  sort_vars:     true,   # Sort instance variables.
  limit:         false,  # Limit arrays & hashes. Accepts bool or int.
  ruby19_syntax: false,  # Use Ruby 1.9 hash syntax in output.
  color: {
    args:       :pale,
    array:      :white,
    bigdecimal: :blue,
    class:      :yellow,
    date:       :greenish,
    falseclass: :red,
    fixnum:     :blue,
    float:      :blue,
    hash:       :pale,
    keyword:    :cyan,
    method:     :purpleish,
    nilclass:   :red,
    rational:   :blue,
    string:     :yellowish,
    struct:     :pale,
    symbol:     :cyanish,
    time:       :greenish,
    trueclass:  :green,
    variable:   :cyanish
  }

string.red: 文字を赤色にする

使い方

  • 文字列のみ
  • purpleとか他の色もok
"cat is kawaii".red #=> "\e[1;31mcat is kawaii\e[0m"

puts "cat is kawaii".red # -> cat is kawaii

colorの種類

  • gray
  • red
  • green
  • yellow
  • blue
  • purple
  • cyan
  • white
  • black
  • redish
  • greenish
  • yellowish
  • blueish
  • purpleish
  • cyanish
  • pale

logger.ap(message): Awesome Printを使い、ログ吐き

使い方

  • LoggerとActiveSupport::BufferedLoggerにapメソッドを加える
  • そのためRailsデフォルトloggerでこんな感じで利用できる
  • デフォルトは:debugレベル。変更可能
logger.ap @users

# 出力
[
    [0] #<User:0x00007fcce9d38d18> {
                :id => 1,
        :first_name => nil,
         :last_name => nil,
             :email => nil,
        :created_at => Fri, 31 Aug 2018 07:16:07 UTC +00:00,
        :updated_at => Fri, 31 Aug 2018 07:16:07 UTC +00:00
    }
]

ログレベル指定(全体)

AwesomePrint.defaults[:log_level]

ログレベル指定(個別)

logger.ap @users, :warn

ap_debug(object): Awesome Printを使い、debug

  • ActionView::Baseにapメソッドを追加するので、ビューでapが利用できる
  • 色付けして、<pre debug_dump>で囲む
  • <pre>タグで囲むので、<%== %>でHTMLエスケープしないようにする
  • debugに近い(こっちはyaml)
<%== ap @users %>
<%== ap_debug @users %>
<%== debug @users %>

オプション

グローバル

# ~/.apr
AwesomePrint.defaults = {
  :indent => -2,
  :color => {
    :hash  => :pale,
    :class => :white
  }
}

個別

obj.ai(plain: false) # 色付けしない

REPLとの統合

irb

# ~/.irbrc
require "awesome_print"
AwesomePrint.irb!

pry

# ~/.pryrc
require "awesome_print"
AwesomePrint.pry!
# 通常時
pry(main)> [1, 2]
=> [1, 2]
# AwesomePrint.pry!時
pry(main)> [1, 2]
[
    [0] 1,
    [1] 2
]

参考リンク

https://github.com/awesome-print/awesome_print

Kaminariの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。
  • 正確な情報はドキュメントを参照してください。ドキュメントのできが良すぎてほとんどコピペみたいになってしまったので、ドキュメントを見たほうが良いです。 -> https://github.com/kaminari/kaminari

導入

インストール

# Gemfile
gem 'kaminari'
$ bundle install

使ってみる

コントローラー

  • ページ番号はparams[:page]に格納される。これをpageメソッドに食わせる
# app/controllers/items_controller.rb
def index
  @items = Item.page(params[:page])
end

ビュー

  • paginateメソッドでページネーションのテンプレートをrenderする
# app/views/items/index.html.erb
<%= paginate @items %>

scopeメソッド

page: nページ目のレコードを取得

  • pageは1から始まるので注意。page(0)page(1)と同じ結果になる
  • デフォルトでは1ページ25レコード
  • 内部的にはActiveRecordのlimitとoffsetを利用している
User.page(1) # 1ページ目の分のレコード(LIMIT 25 OFFSET 0)
User.page(2) # 2ページ目の分のレコード(LIMIT 25 OFFSET 25)
  • 実際にはコントローラーにparams[:page]が渡ってくるので、それを食わせる
User.page(params[:page])
  • pageは内部的にlimitを利用しているので、両方使うと上書きする
User.limit(10).count #=> 10
User.limit(10).page(1).count #=> 25

limit_value: 取得するレコード数

User.page(1).limit_value         #=> 25
User.page(1).per(10).limit_value #=> 10

total_pages: 総ページ数

User.page(1).total_pages #=> 50
User.page(1).per(50).total_pages #=> 25

current_page: 現在のページ番号

User.page(1).current_page #=> 1

next_page: 次のページ番号

User.page(1).next_page #=> 2

prev_page: 前のページ番号

User.page(2).prev_page #=> 1

first_page?: 1ページ目なら、true

User.page(1).first_page? #=> true

last_page?: 最終ページなら、true

User.page(50).last_page? #=> true

out_of_range?: ページが範囲外なら、true

User.page(100).out_of_range? #=> true

per: ページ毎のレコード数を設定

  • デフォルト: 25
User.page(7).per(50)

# モデルに対しては直接使えない(page指定なしに使うことはありえないから)
User.per(50) # エラー

padding: 最初のn件は取得しない

  • OFFSETを使用して最初のn件は取得しない
  • モデルに対しては直接使えない
User.page(1).padding(3) # SELECT  `users`.* FROM `users` LIMIT 25 OFFSET 3

total_count: 全体のレコード数

User.count                #=> 1000
User.page(1).count        #=> 25
User.page(1).total_count  #=> 1000

without_count: COUNTクエリを発行しない

  • 参考: https://qiita.com/yuki24/items/aab0d8e417d6fe546688
  • 一般的に、ページネーションではリンクを表示するために総レコード数を知らないといけない。そのためクエリを発行する必要がある
  • しかし、リンクが"next"と"prev"だけでいいなら総レコード数を知る必要はない。この場合without_countを使うとCOUNTクエリを発行しない
  • 次のページが存在するか確かめるためにlimit + 1する(デフォルトならLIMIT 26)
  • レコード数が大量にある場合に役立つ
# コントローラー
User.page(3).without_count
  • ビューではpaginateを使う代わりに、link_to_prev_pagelink_to_next_pageを使い自分でリンクを用意する
# ビュー

<%#= paginate @users %>

<%= link_to_prev_page @users, 'Previous Page' %>
<%= link_to_next_page @users, 'Next Page' %>

except: page/per取り消し

  • pageperは内部的にはActiveRecordのlimitとoffsetを利用している。なのでpageとperを取り消したくなったら、except(:limit, :offset)を使えばOK
  • exceptはActiveRecordのメソッド
User.page(3).per(10)                         # SELECT `users`.* FROM `users` LIMIT 10 OFFSET 20
User.page(3).per(10).except(:limit, :offset) # SELECT `users`.* FROM `users`

ビューヘルパーメソッド

paginate: ページネーションのテンプレートをrender

使い方

# « First ‹ Prev ... 2 3 4 5 6 7 8 9 10 ... Next › Last »
<%= paginate @users %>

オプション

window: リンク数(内側)

  • デフォルト: 4
# ... 5 6 7 8 9 ...
<%= paginate @users, window: 2 %>

outer_window: リンク数(外側)

  • デフォルト: 0
# 1 2 3 ...(snip)... 18 19 20
<%= paginate @users, outer_window: 3 %>

left: リンク数(外側 - 左)

  • デフォルト: 0
# 1 ...(snip)... 18 19 20
<%= paginate @users, left: 1, right: 3 %>

right: リンク数(外側 - 右)

  • デフォルト: 0
# 1 ...(snip)... 18 19 20
<%= paginate @users, left: 1, right: 3 %>

param_name: パラメータ名

  • params[:page]の:page
  • デフォルト: :page
# コントローラーでは`params[:page]`の代わりに`params[:pagina]`を使う
<%= paginate @users, param_name: :pagina %>

params: パラメータ操作

# 上書き
<%= paginate @users, params: {controller: 'foo', action: 'bar'} %>

# merge
# コントローラーでは`params[:hoge] #=> "piyo"`
<%= paginate @users, params: {hoge: "piyo"} %>

remote: リンクがAjaxになる

  • 全てのリンクにdata-remote="true"を追加
# これだけでリンクがAjaxになる
<%= paginate @users, remote: true %>

views_prefix: ViewのDirectory

  • デフォルト: kaminari/
# app/views/templates/kaminariのpartialを探す
<%= paginate @users, views_prefix: 'templates' %>

theme: テーマ指定

<%= paginate @users, theme: 'my_custom_theme' %>
# 基本
<%= link_to_next_page @items, 'Next Page' %>

# Ajax
<%= link_to_previous_page @items, 'Previous Page', remote: true %>

# ブロックで、1ページ目の処理を指定できる
<%= link_to_previous_page @users, 'Previous Page' do %>
  <span>At the Beginning</span>
<% end %>

page_entries_info: ページ情報をrender

使い方

# "Displaying posts 6 - 10 of 26 in total"のような表示
<%= page_entries_info @posts %>

オプション

entry_name: 表示名を変更

# "Displaying items 6 - 10 of 26 in total"のような表示
<%= page_entries_info @posts, entry_name: 'item' %>
# <link rel="next" href="/users?page=5">
# <link rel="prev" href="/users?page=3">
<%= rel_next_prev_link_tags @users %>
  • 実際にはタグ内で使う
<head>
  <title>My Website</title>

  # <link rel="next" href="/users?page=5">
  # <link rel="prev" href="/users?page=3">
  <%= yield :head %>
</head>

<% content_for :head do %>
  <%= rel_next_prev_link_tags @items %>
<% end %>

path_to_next_page: 次ページのpath

# /users?page=5
<%= path_to_next_page @users %>

path_to_prev_page: 前ページのpath

# /users?page=3
<%= path_to_prev_page @users %>

next_page_url: 次ページのURL

# http://www.example.org/items?page=2
<%= next_page_url @items %>

prev_page_url: 前ページのURL

# http://www.example.org/items
<%= prev_page_url @items %>

ヘルパーメソッドをコントローラーで使う

  • Kaminari::Helpers::UrlHelperモジュールにヘルパーメソッドが定義されているので、コントローラーでincludeすればOK
class UsersController < ApplicationController
  include Kaminari::Helpers::UrlHelper

  def index
   @users = User.page(1)

   path_to_next_page(@items) #=> /items?page=2
  end
end

設定

  • kaminariのデフォルト動作の変更は、ざっくり3箇所で指定可能。同一内容の設定の場合、下が優先させる
    1. グローバル設定
    2. モデル単位の設定
    3. 個別の設定

グローバル設定

設定ファイルを作る

$ rails g kaminari:config
      create  config/initializers/kaminari_config.rb
# config/initializers/kaminari_config.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

default_per_page: ページ毎のレコード数

  • デフォルト: 25

max_per_page: ページ毎のレコード数の最大値

  • デフォルト: nil

max_pages: ページ数の最大値

  • デフォルト: nil

window: リンク数(内側)

  • デフォルト: 4

outer_window: リンク数(外側)

  • デフォルト: 0

left: リンク数(外側 - 左)

  • デフォルト: 0

right: リンク数(外側 - 右)

  • デフォルト: 0

page_method_name: pageメソッド(nページ目のレコードを取得)の名前変更

  • デフォルト: :page

param_name: パラメータ名

  • params[:page]の:page
  • デフォルト: :page

params_on_first_page: 最初のページでparamsを無視しない

モデル単位の設定

paginates_per: ページ毎のレコード数

class Article < ActiveRecord::Base
  paginates_per 10
end

max_paginates_per: ページ毎のレコード数の最大値

  • perスコープでこの数値以上を指定した場合、この数値が利用される
  • デフォルト: nil(max制限はなし)
class User < ActiveRecord::Base
  max_paginates_per 50
end

max_pages: ページ数の最大値

  • ここで指定した以上のページがあっても、リンクを作らない

class Article < ActiveRecord::Base max_pages 100 end

個別の設定

  • ページ毎のレコード数指定にはperを使う
User.page(7).per(50)
  • 見た目の変更にはpatinateのオプションを指定する
# ... 5 6 7 8 9 ...
<%= paginate @users, window: 2 %>

# 1 2 3 ...(snip)... 18 19 20
<%= paginate @users, outer_window: 3 %>

I18n

ラベルはI18nに対応してる

en:
  views:
    pagination:
      first: "&laquo; First"
      last: "Last &raquo;"
      previous: "&lsaquo; Prev"
      next: "Next &rsaquo;"
      truncate: "&hellip;"
  helpers:
    page_entries_info:
      entry:
        zero: "entries"
        one: "entry"
        other: "entries"
      one_page:
        display_entries:
          zero: "No %{entry_name} found"
          one: "Displaying <b>1</b> %{entry_name}"
          other: "Displaying <b>all %{count}</b> %{entry_name}"
      more_pages:
        display_entries: "Displaying %{entry_name} <b>%{first}&nbsp;-&nbsp;%{last}</b> of <b>%{total}</b> in total"

ラベルを変更する

  • 変更したい場合は、ロケールファイルを用意して変更したい部分を変えればOK
# config/locales/kaminari.en.yml
en:
  views:
    pagination:
      previous: "<-"

ラベルに日本語を使う

1. デフォルトのロケールを日本語にする

# /config/application.rb
config.i18n.default_locale = :ja

2. 日本語のロケールファイルを用意する

# config/locales/kaminari.ja.yml
ja:
  helpers:
    page_entries_info:
      more_pages:
        display_entries: "<b>%{total}</b>中の%{entry_name}を表示しています <b>%{first} - %{last}</b>"
      one_page:
        display_entries:
          one: "<b>%{count}</b>レコード表示中です %{entry_name}"
          other: "<b>%{count}</b>レコード表示中です %{entry_name}"
          zero: "レコードが見つかりませんでした %{entry_name}"
  views:
    pagination:
      first: "&laquo; 最初"
      last: "最後 &raquo;"
      next: "次 &rsaquo;"
      previous: "&lsaquo; 前"
      truncate: "&hellip;"

kaminari-i18n(gem)

テンプレートを変更する

1. パーシャルを作成する

# defaultテーマを利用する
$ rails g kaminari:views default
  • 以下の7つのパーシャルが作成される
    • app/views/kaminari/_first_page.html.erb: << First
    • app/views/kaminari/_last_page.html.erb: Last >>
    • app/views/kaminari/_next_page.html.erb: Next >
    • app/views/kaminari/_prev_page.html.erb: < Prev
    • app/views/kaminari/_page.html.erb: 4(各ページへのリンク)
    • app/views/kaminari/_gap.html.erb: ...(ページ間の省略)
    • app/views/kaminari/_paginator.html.erb: ページネーションHTML全体。各partialはここで使われる

2. haml/slimに変換する

  • erbではなくhaml/slimを扱いたい場合はhtml2hamlhtml2slimを使って、自分で変換する。
  • 昔は-e hamlオプションでhamlを生成できたが、現在はdeprecated

3. パーシャルを変更する

  • 作成されたパーシャルはkaminari内部で使用されているものと同じ。しかしこちらのほうが優先されるので、こちらを自分で変更することで、テンプレートを変更できる。

配列をページネートする

  • Relationだけでなく、配列もページネート可能
@paginatable_array = Kaminari.paginate_array(my_array_object).page(params[:page]).per(10)

total_countを指定

  • 実際のcountとは異なった値を返すような、配列っぽいオブジェクトに対して使う(ドキュメントではRSolr)
@paginatable_array = Kaminari.paginate_array([], total_count: 145).page(params[:page]).per(10)
@paginatable_array.total_count #=> 145
@paginatable_array.count       #=> 0

テーマ

  • テーマを変更することで、ページネーション部分のHTMLを変更できる
  • 各CSSフレームワークに対応したテーマが多いっぽい
  • デフォルトのテーマはdefault

使い方

# defaultテーマを利用
$ rails g kaminari:views default

# bootstrap4用のテーマを利用
$ rails g kaminari:views bootstrap4

テーマ一覧

  • default
  • bootstrap2
  • bootstrap3
  • bootstrap4
  • bourbon: Bourbon
  • bulma
  • foundation
  • foundation5
  • github
  • google
  • materialize
  • purecss
  • semantic

kaminari_themes(gem)

復数のテーマを使う

1. カスタムテーマ用のディレクトリを用意する

# 1. defaultでテンプレート作成
% rails g kaminari:views default

# 2. cd
% cd app/views/kaminari

# 3. カスタムテーマ用のディレクトリを作る
% mkdir my_custom_theme

# 4. テンプレートをすべてコピー
% cp _*.html.* my_custom_theme/

2. paginateでカスタムテーマ用のディレクトリを参照する

<%= paginate @users, theme: 'my_custom_theme' %>

コンポーネント

  • kaminariはコンポーネントベースで構成されている
  • 主要コンポーネントは以下の3つ。これらは単独のgemだがkaminariにバンドルされてる
    • kaminari-core: コアロジック
    • kaminari-activerecord: Active Recordアダプタ
    • kaminari-actionview: Action Viewアダプタ
  • コンポーネントは他にも色々ある。コンポーネントベースな設計のおかげで、自分の環境にあったコンポーネントを選んで使うことができる。

使い方

# ActiveRecord + Rails(ActionView)の場合
# 普通にkaminariを使う場合はこうなる
gem 'kaminari'

# ActiveRecord + Rails(ActionView)の場合
# kaminari gemは以下の3つのgemからなるので、個別にinstallしてもOK
# こんな感じでコンポーネントを選ぶことが可能
gem 'kaminari-activerecord'
gem 'kaminari-actionview'
gem 'kaminari-core' # これは2gemと依存関係にあるので省略可能

# Mongoid + Rails(ActionView)の場合
gem 'kaminari-mongoid'
gem 'kaminari-actionview'
gem 'kaminari-core' # これは2gemと依存関係にあるので省略可能

# ActiveRecord + Sinatraの場合
gem 'kaminari-activerecord'
gem 'kaminari-sinatra'
gem 'kaminari-core' # これは2gemと依存関係にあるので省略可能

コンポーネント一覧

ORM

kaminari-activerecord: Active Record

kaminari-mongoid: Mongoid

kaminari-mongo_mapper: MongoMapper

kaminari-data_mapper: DataMapper

フレームワーク

kaminari-actionview: Rails(Action View)

kaminari-sinatra: Sinatra

kaminari-grape: Grape

その他メモ

ちゃんとorderを使う

  • 内部的にはActiveRecordのlimitとoffsetを利用している。なのでちゃんとorderを使う必要がある
# bad
User.page(params[:page])

# good
User.order('name').page(params[:page])

ユーザーフレンドリー + ページキャッシュ

  • /users?page=33/users/page/33でアクセスできるようにすると良いらしい
    • ユーザーフレンドリーになる
    • ページキャッシュが効く
# routes.rb

# concern使わない場合
resources :users do
  get 'page/:page', action: :index, on: :collection
end

# concernを使う場合
concern :paginatable do
  get '(page/:page)', action: :index, on: :collection, as: ''
end
resources :users, concerns: :paginatable

1.0.0で大きく変わってるので注意する

管理画面のテンプレートを用意する

  • 管理画面等のテンプレートが必要になった場合には、ジェネレータで--views-prefixオプションを使う
$ rails g kaminari:views default --views-prefix admin
      create  app/views/admin/kaminari/_next_page.html.erb
      create  app/views/admin/kaminari/_last_page.html.erb
      create  app/views/admin/kaminari/_first_page.html.erb
      create  app/views/admin/kaminari/_page.html.erb
      create  app/views/admin/kaminari/_paginator.html.erb
      create  app/views/admin/kaminari/_prev_page.html.erb
      create  app/views/admin/kaminari/_gap.html.erb

サポート

  • Ruby 2.0.0, 2.1.x, 2.2.x, 2.3.x, 2.4.x, 2.5.x, 2.6
  • Rails 4.1, 4.2, 5.0, 5.1, 5.2
  • Sinatra 1.4
  • Haml 3+
  • Mongoid 3+
  • MongoMapper 0.9+
  • DataMapper 1.1.0+

ざっくりコードリーディング

  • v1.1.1(現在の最新)が対象
  • どこに何があるか?程度

lib/

  • lib/配下はkaminari.rbとバージョンファイルしかない。実際のコードは各コンポーネントにある

lib/kaminari.rb

  • 3つの主要コンポーネントをrequire
  • これでgem "kaminari"で一通りの機能が使えるようになる
require 'kaminari/core'
require 'kaminari/actionview'
require 'kaminari/activerecord'

kaminari-core/

  • kaminariのコアロジック
  • コレ自体がgem

コア機能

kaminari-core/lib/kaminari/

  • コア機能置き場

kaminari-core/lib/kaminari/models/array_extension.rb

  • Kaminari.paginate_arrayのロジック

kaminari-core/lib/kaminari/models/configuration_methods.rb

  • paginates_per等のモデルの設定用のクラスメソッド群

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

  • per等のscopeメソッド群

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

  • paginate等のヘルパーメソッド群

kaminari-core/lib/kaminari/helpers/tags.rb

  • kaminariのページネーションで使われるHTMLを表現するTagクラス
  • TagのサブクラスとしてPrevPage等があり、それらはapp/views/kaminari/_prev_link.html.erb等に対応する

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

  • TagのサブクラスとしてPaginatorがあり、app/views/kaminari/_paginator.html.erb等に対応する(?)

パーシャル

kaminari-core/app/views/kaminari/

  • パーシャル置き場
  • $ rails g kaminari:views defaultで作成するパーシャルでもある
  • erb以外にもhaml/slimもある。Deprecatedなだけでまだ使えるっぽい

kaminari-core/app/views/kaminari/_first_page.html.erb

  • "<< First"となる、1ページ目へのリンクのパーシャル
    • t('views.pagination.first')でロケールファイルを利用
<span class="first">
  <%= link_to_unless current_page.first?, t('views.pagination.first').html_safe, url, remote: remote %>
</span>

kaminari-core/app/views/kaminari/_last_page.html.erb

  • "Last >>"

kaminari-core/app/views/kaminari/_next_page.html.erb

  • "Next >"

kaminari-core/app/views/kaminari/_prev_page.html.erb

  • "< Prev"

kaminari-core/app/views/kaminari/_page.html.erb

  • "4"となる、各ページへのリンクのパーシャル

kaminari-core/app/views/kaminari/_gap.html.erb

  • "..."となる、ページ間の省略を表すパーシャル

kaminari-core/app/views/kaminari/_paginator.html.erb

  • paginateヘルパーでrenderするやつ。ページネーションHTML全体。
  • _first_page.html.erb等のパーシャルはここで利用される
  • kaminari-core/lib/kaminari/helpers/paginator.rbのKaminari::Helpers::Paginatorインスタンスを利用して、renderしてる。first_page_tagなどもpaginator.rbで定義
<%= 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 -%>

kaminari-core/config/locales/kaminari.yml

  • デフォルトのロケールファイル
  • パーシャルはここを参照してる

ジェネレータ

kaminari-core/lib/generators/kaminari/config_generator.rb

  • $ rails g kaminari:configに対応

kaminari-core/lib/generators/kaminari/views_generator.rb

  • $ rails g kaminari:viewsに対応

kaminari-actionview/

  • ActionViewモジュール
  • ActionView::Baseにヘルパー機能をincludeして、ビューでヘルパー使えるようにしてる
  • 他にもちょい

kaminari-activerecord/

  • ActiveRecordモジュール
  • ActiveRecord::Baseにscope/config機能をincludeしてる

参考URL

Draperの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

感想とか

感想

  • ヘルパーをオブジェクト指向的に書けるようになる
  • 個人的には類似gemのActiveDecoratorのがシンプルで好みかも。でもDraperも十分良さそう

pros

  • 高機能
  • 関連先もデコれる
  • Decorator gemでは一番利用者が多いっぽい

cons

  • 毎回明示的にデコらないといけない(prosと考えることもできる)
  • どこでもデコれるのは自由度がありすぎてカオスになりそう -> decorates_assignedを使えば回避できるかも

基本的な使い方

導入

1. インストール

# Gemfile
gem 'draper'
$ budle install

2. セットアップ

# app/decorators/application_decorator.rb を作成
# ApplicationDecoratorは全てのDecoratorの親クラスになる
$ rails generate draper:install

3. デコレータを作成して、使用する

# app/decorators/article_decorator.rb を作成
$ rails generate decorator Article
# app/decorators/article_decorator.rb

class ArticleDecorator < Draper::Decorator
  # Articleにのメソッドが使えるようになる。published?がそれ
  delegate_all

  # ビューで`@article.publication_status`のように、@articleのメソッドとして利用できる
  def publication_status
    # published?はArticleのメソッドだけど、ArticleDecoratorでもそのまま使える
    if published?
      "Published at #{published_at.strftime("%A, %B %e")}"
    else
      "Unpublished"
    end
  end

end
# ビュー

<%= @article.publication_status %>

ヘルパー/ビュー/モデルにロジックを置く場合との比較

ヘルパーにロジックを置く場合

  • 手続き的で、オブジェクト指向的でない
  • 名前空間がグローバル(ファイルは分けらる)
    • 名前衝突の可能性
    • どこに定義されているかわからない
# app/helpers/articles_helper.rb
def publication_status(article)
  if article.published?
    "Published at #{article.published_at.strftime('%A, %B %e')}"
  else
    "Unpublished"
  end
end

# ビュー
<%= publication_status(@article) %>

ビューにロジックを置く場合

  • 手続き的で、オブジェクト指向的でない
  • メンテナンスしにくい
  • 読みにくい
<% if @article.published? %>
  <%= "Published at #{@article.published_at.strftime('%A, %B %e')}" %>
<% else %>
  Unpublished
<% end %>

モデルに置く場合

  • モデルにはドメインロジックを置きたい。プレゼンテーションロジックがモデルにあるのは微妙
  • HTMLを描画するための機能(helper等)が利用できない
  • fat model
# app/decorators/article_decorator.rb

class Article < ApplicationRecord
  def publication_status
    if published?
      "Published at #{published_at.strftime("%A, %B %e")}"
    else
      "Unpublished"
    end
  end
end
# ビュー

<%= @article.publication_status %>

デコレータにロジックを置く場合

  • オブジェクト指向的
  • プレゼンテーションロジックをモデルから分けられる
# app/decorators/article_decorator.rb

class ArticleDecorator < Draper::Decorator
  delegate_all

  def publication_status
    if published?
      "Published at #{published_at.strftime("%A, %B %e")}"
    else
      "Unpublished"
    end
  end
end
# ビュー

<%= @article.publication_status %>

ヘルパーメソッドを使う*2

hメソッド: ヘルパーにアクセス

  • hメソッドを通して、ヘルパーにアクセスできる
  • Railsのヘルパー、自作ヘルパー両方にアクセスできる
  • helpersでもok
class ArticleDecorator < Draper::Decorator
  def emphatic
    # helpers.content_tag(:strong, "Awesome") でもok
    h.content_tag(:strong, "Awesome")
  end
end

include Draper::LazyHelpers: ヘルパーを直接include

  • hを介さず、ヘルパーを直接include
  • でも大量のヘルパーが定義されちゃって、デコレーター/モデル/ヘルパーのメソッドがごっちゃになるから、いまいちっぽい
class ArticleDecorator < Draper::Decorator
  include Draper::LazyHelpers
end

モデルのメソッドを使う*3

object: モデルインスタンスにアクセス

object.published_at.strftime("%A, %B %e")
model.published_at.strftime("%A, %B %e") # modelはobjectのalias

delegate_all: 全てのメソッドを委譲

  • 内部的にはmethod_missingを使い委譲している
  • メソッド探索の順番: デコレータ -> 親デコレータ -> モデル
class ArticleDecorator < Draper::Decorator
  delegate_all
end

delegate: 指定のメソッドを委譲

  • deleageメソッドはActive Supportのdelegateとほぼ同じ。ただしto: :objectを省略できる。
class ArticleDecorator < Draper::Decorator
  # 指定のメソッドのみ委譲する(toオプションを指定しないとデフォルトでobjectに委譲する)
  # titleで、object.titleにアクセスできるようになる
  delegate :title, :body

  # 指定のメソッドを指定したオブジェクトに委譲する
  delegate :name, :title, to: :author, prefix: true
end

オブジェクトをデコる*3

基本

# app/controllers/articles_controller.rb
def show
  # articleをデコる
  @article = Article.find(params[:id]).decorate
end

デコる方法は3つ

  • 一番オススメ
  • デコレータをモデル名から推測してくれる(そのためデコレータ名がモデル名から推測できないといけない)
@article = Article.find(params[:id]).decorate
  • こちらならデコレータの名前が違う場合でもOK
# 名前同じ場合
@article = ArticleDecorator.new(Article.find(params[:id]))
@article = ArticleDecorator.decorate(Article.find(params[:id])) # newと同等

# 名前違ってもOK
@widget = ProductDecorator.new(Widget.first)
@widget = ProductDecorator.decorate(Widget.first)
  • .decorates_findersを使うと、デコレータがfind系(find/all/first/...)のメソッドが利用できるようになる。これでfindしつつデコることが可能
  • でもいまいちな気がする。Draperのデコレータはビューで使うことを想定している。DBアクセスはモデルがやるべきで、デコレータの仕事じゃない
# app/decorators/articl_decorator.rb
class ArticleDecorator < Draper::Decorator
  decorates_finders
end

# コントローラー
@article = ArticleDecorator.find(params[:id])

コレクションの各要素をデコる*2

  • コレクションを操作すると、各要素は一気にデコられる
# オススメ
# Relationの場合、そのままチェーンできる
@articles = Article.recent.decorate

# こっちでもできる
# 配列の場合、`ArticleDecorator.decorate_collection`に食わせる
# Rails3だとallは配列を返すので、こっちを使う
@articles = ArticleDecorator.decorate_collection(Article.all)

コレクション自体をデコる

  • モデルのクラスメソッドを生やすイメージ
  • Draper::CollectionDecoratorを継承
  • 使わなそう -> patinationのメソッド生やすのに使える
# app/decorators/articles_decorator.rb

# ArticleDecoratorではない。複数形になるので注意。
class ArticlesDecorator < Draper::CollectionDecorator
  def page_number
    42
  end
end

@articles = Article.all.decorate # ArticlesDecoratorオブジェクト(ActiveRecordのRelation相当)
@articles.page_number # => 42

関連先をデコる*2

メソッド

decorates_association: 関連先をデコる

# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
  # ArticleDecoratorがArticleをデコレートした時に、自動でAuthorDecoratorがAuthorをデコレートする
  decorates_association :author
end


# app/decorators/author_decorator.rb
class AuthorDecorator < Draper::Decorator
  def hoge
    model.name + "hogehoge"
  end
end

# Articleしかデコレートしていないのに、authorまでデコレートできている
article = Article.first.decorate
article.author.hoge

decorates_associations: 関連先をデコる(複数)

# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
  decorates_associations :author, :comments
end

オプション

with: 関連先のデコレーターを指定する

class ArticleDecorator < Draper::Decorator
  # AuthorDecoratorではなく、FancyPersonDecoratorでデコレート
  decorates_association :author, with: FancyPersonDecorator
end

scope: 関連先をscopeで絞り込む

  • decorate対象の絞り込ではなく、レコード自体の絞り込み
class ArticleDecorator < Draper::Decorator
  # `article.comments`時に、`recent`で絞り込む
  decorates_association :comments, scope: :recent
end

context: 関連先にcontextを渡す

# 関連先にcontextを渡す
class ArticleDecorator < Draper::Decorator
  decorates_association :author, context: {foo: "bar"}
end


# ラムダで上書きすることも可能
# parent_contextはhashだよ
class ArticleDecorator < Draper::Decorator
  decorates_association :author,
    context: ->(parent_context){ parent_context.merge(foo: "bar") }
end

POROをデコる

  • ActiveRecord::BaseにはDraper::Decoratableモジュールがincludeされている。これによりArticle.first.decorateのような処理が可能になる
  • POROにもDraper::Decoratableモジュールをincludeすれば同じことが可能になる
class Cat
  include Draper::Decoratable
end

class CatDecorator < ApplicationDecorator
  def nyaa
    "nyaa"
  end
end

Cat.new.decorate.nyaa # => "nyaa"
  • ActiveModel::Modelとかのformで使うようなオブジェクトをデコレートする時に便利そう
class ArticleForm
  include ActiveModel::Model
  include Draper::Decoratable

  attr_accessor :field1, ...

end

自動でデコる

  • デコレーターはモデルのように振る舞えるので、actionの開始時にデコレートして、ずっとデコレータを使いたくなる。でもコレは非推奨。ビューで使うように設計されているので、ビュー以外で使うべきでない。そのためコントローラーではモデルを使い、render直前にデコって、ビューではデコレータを使う
  • decorates_assignedを使うとこれを自動化できる。コントローラーでは@articleをモデルとして扱い、ビューではarticleをデコレーターとして扱える。
  • decorates_assignedはGoRailsでもおすすめしてた。draperは結構自由にデコレートできてしまいカオスになりそうなので、コレを使って自動化しておくとよさそう(参考: https://gorails.com/episodes/decorators-with-draper)
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  # `decorates_assigned`を使うと、
  # コントローラーでは`@article`をモデルとして扱い、
  # ビューでは`article`をデコレータとして扱える。
  #
  # 具体的には以下の2つをしてくれる
  # - @articleインスタンス変数をデコってくれる
  # - helper_methodを使い、ビューで`article`でアクセスできるようにする
  decorates_assigned :article

  def show
    @article = Article.find(params[:id])
  end
end
# app/views/articles/show.html.erb
<%= article.decorated_title %>
  • イメージとしてはこんな感じのことをしてくれてる
def article
  @decorated_article ||= @article.decorate
end
helper_method :article
  • indexでも使う場合は複数形も必要
decorates_assigned :article, :articles

ページネーションと一緒に使う

問題点

  • kaminari等のページネーションgemと一緒に使うには一工夫必要
  • ページネーションgemはRelationにcurrent_page等のメソッドを追加する。そのままデコレートしてしまうとcurrent_page等のメソッドにアクセスできなくなってしまう。
cats = Cat.all    # Cat::ActiveRecord_Relationインスタンス
cats = cats.page  # Cat::ActiveRecord_Relationインスタンス
cats.current_page # => 1

cats = cats.decorate # CatsDecoratorインスタンス
cats.current_page    # NoMethodErrorになってしまう

解決方法1: Draper::CollectionDecoratordelegate

# config/initializers/draper.rb
Draper::CollectionDecorator.delegate :current_page, :total_pages, :limit_value, :total_count

解決方法2: Draper::CollectionDecoratorのサブクラスをコレクションにする

# Draper::CollectionDecoratorのサブクラスを作って、delegate
class PaginatingDecorator < Draper::CollectionDecorator
  delegate :current_page, :total_pages, :limit_value, :total_count
end

# コレクションがPaginatingDecoratorになるようにする
class ApplicationDecorator < Draper::Decorator
  def self.collection_decorator_class
    PaginatingDecorator
  end
end

# 各DecoratorはApplicationDecoratorを継承する
class AnimalDecorator < ApplicationDecorator
end

その他メモ

Decorator/Presenter/ViewModel/Exibitの違い

  • Decorator/Presenter/ViewModel/Exibitの違いがわからん
  • いくつか記事を見た感じ、どうも文脈/人によって同じ言葉でも意味が変わるっぽい
  • draperの文脈では全て同じもの(ビューヘルパーをオブジェクト指向的に扱うための、モデルのデコレータ)を指すことが多い気がする
  • draperのDecoratorはGoFのDecoratorパターンに由来すると思われるが、ビュー以外での使用は想定していない(はず)。あんまり名前にこだわりすぎないほうがいいかもしれない

参考サイトのまとめ

Decorator と Presenter を使い分けて、 Rails を ViewModel ですっきりさせよう

  • チーム内で認識を統一して、用語を使い分けてる
  • 良さそう。でも難しい。
  • 用法
    • Decorator: 単一のモデルクラスに対応する ViewModel
    • Presenter: 複数のモデルクラスにまたがる ViewModel、永続化されたモデルと一致しない ViewModel
    • ViewModel: Decorator 、Presenter の上位概念。ビューに関連するロジックをまとめるレイヤーを指す。

Exhibit vs Presenter

  • 全体的に難しい。あんまり理解できてない
  • 用法
    • Decorator: Presenter、Exibitの上位概念。GofのDecoratorと同じで、オブジェクトに機能/振る舞いを追加するもの。サンプルではSimpleDelegatorやDecoratorを使い実装
    • Presenter: Decoratorの一種。モデルをデコレートするプレゼンテーションロジック置き場?(自信なし)
    • Exibit: Decoratorの一種。モデルをビューに繋げる。Exibit自体はビューの機能を持たず、contextを持ち、contextにrenderさせる
    • Presenter + Exhibit: PresenterとExhibitは排他的な関係ではない。同時に実現可能。draperはこれ?

model の decorator の話

  • 用法
    • Presenter: 一つのView につき一つの Decorator
    • ActiveDecorator(gem): 一つの model につき一つの Decorator(たぶんdraperもコレに該当)
    • Exhibit: View x Model の組み合わせにつき一つの Decorator

デコレータのクラス*2

Draper::Decorator: モデルインスタンスのデコレータ

  • article.decorateの戻り値は、このクラスのサブクラス
  • 基本コレをつかう
# app/decorators/article_decorator.rb

class ArticleDecorator < Draper::Decorator
  # Articleにのメソッドが使えるようになる。published?がそれ
  delegate_all

  # ビューで`@article.publication_status`のように、@articleのメソッドとして利用できる
  def publication_status
    # published?はArticleのメソッドだけど、ArticleDecoratorでもそのまま使える
    if published?
      "Published at #{published_at}"
    else
      "Unpublished"
    end
  end

  def published_at
    object.published_at.strftime("%A, %B %e")
  end
end
# ビュー

<%= @article.publication_status %>

Draper::CollectionDecorator: コレクションのデコレータ

  • articles.decorateの戻り値は、このクラスのサブクラス
  • 使わなそう
# app/decorators/articles_decorator.rb
class ArticlesDecorator < Draper::CollectionDecorator
  def page_number
    42
  end
end

@articles = Article.all.delegate
@articles = ArticlesDecorator.new(Article.all)
@articles = ArticlesDecorator.decorate(Article.all)

contextで追加データを渡す

# デコレータ内で、contextメソッドを通して利用できる
Article.first.decorate(context: {role: :admin})

デコレータでHTMLをレンダリング

# デコレータ
def emphatic
  h.content_tag(:strong, "Awesome") # => <strong>Awesome</strong>
end
  • 長い場合は、h.renderで部分テンプレートを使って組み立てる
# デコレータ
def sub_view
  h.render 'sub_view', title: model.title # => articles/sub_view.html.erb を表示する
end

デコレータはモデルのように振る舞う

@person = Person.first
@decorated_person = @person.decorate

@decorated_person.is_a?(Person) # true
@person == @decorated_person # true

モデルメソッドをオーバーライドする

  • 便利だけどわかりにくくなりそう
# デコレータ
def created_at
  article.created_at.strftime("%m/%d/%Y - %H:%M")
end

# ビュー
@article.created_at

ApplicationDecorator

  • ApplicationRecordに合わせて、Draper::DecoratorではなくApplicationDecoratorを継承すると吉
# app/decorators/application_decorator.rb
class ApplicationDecorator < Draper::Decorator
  # 全てのデコレータで共有する処理はここに置く
end

# app/decorators/article_decorator.rb
# Draper::Decoratorではなく、ApplicationDecoratorを継承する
class ArticleDecorator < ApplicationDecorator
end

ApplicationControllerの名前変更

  • ベースとなるコントローラーはApplicationControllerで、draperもこれを仮定している
  • 変更したい場合は以下の通り
Draper.configure do |config|
  config.default_controller = BaseController
end

Active Jobとの統合

  • デコレートされたオブジェクトをJobとしてシリアライズできるが、デシリアライズ時にはデコレートは解除されている。モデルオブジェクトのGlobal IDを使ってシリアライズをするため。

テスト

  • RSpec, MiniTest::Rails, Test::Unitの3つに対応
  • decoratorをgenerateした場合、テストも追加してくれる

RSpec

  • デフォルトのパスはspec/decorators。パスを変えるなら、type: :decoratorが必要

RSpecのコントローラーテスト

# デコられているかテストできる述語マッチャ(predicate matchers)
assigns(:article).should be_decorated

# デコレーターを指定できる
assigns(:article).should be_decorated_with ArticleDecorator

ApplicationController以外を使う場合はコレが必要

# spec_helper.rb
config.before(:each, type: :decorator) do |example|
  Draper::ViewContext.controller = ExampleEngine::CustomRootController.new
end

isolated test

  • Draperはヘルパーメソッドにアクセスするために、view contextを必要とする
  • デフォルトではApplicationControllerのview contextを使う
  • この依存を取り除いて、テストをスピードアップしたい場合は以下の設定をする。ただしデコレーターはヘルパーにアクセスできなくなる
# spec_helper

# 依存を取り除く
Draper::ViewContext.test_strategy :fast

# 個別にHelperをinclude
Draper::ViewContext.test_strategy :fast do
  include ApplicationHelper
end

1.0へのアップデート

  • 2013に1.0へのアップデートがあって、かなり変更が入ったらしい
  • 主要なものだけ

Draper::Base -> Draper::Decorator

Draper::DecoratorEnumerableProxy -> Draper::CollectionDecorator

Draper::ModelSupport -> Draper::Decoratable

自動的に委譲 -> delegate_allで明示的に委譲

委譲の制御allows/denies/denies_allは削除

delegate_allでクラスメソッドも委譲する

デフォでfind系メソッドは追加されない

その他の使いそうなメソッド

l: デコレータでlocalizeメソッド

  • よく使うのでhelpers.localizelで使える
  • localizeでもok

decorator.decorated_with?: 指定クラスでデコレートされていればtrue

Cat.first.decorate.decorated_with?(CatDecorator) # => true

decorator.decorated?: オブジェクトがデコレートされていればtrue

Article.first.decorate.decorated? # => true
Article.first.decorated? # => false

Pumaの使い方 まとめ

  • 自分用のメモを公開したものです。ドキュメント/ソースコード等をまとめただけで試していないコードも多いので、信頼性は低いです。

感想

  • 新規にRailsアプリ作るならpuma使えば良さそう。でも、unicornやpassengerから乗り換えるほどではないかも(ケースバイケースだがパフォーマンスの顕著な差はないっぽい)
  • Heroku使う場合はメモリ使用量が少なくて良さそう。メモリ500MBプランだとunicornで2worker動かすの辛かった記憶

スレッドベース

参考

unicornはプロセスベース

  • workerプロセスが2つあったら、2つのリクエストを同時に処理できる
  • メリット
    • シンプル(理解しやすい + コードが綺麗になりやすい)
    • スレッドの知識がなくても安心
    • スレッドセーフなコードを意識しなくても良い(普通にRailsアプリを書いていれば問題なさそうだけど、スレッドの知識が浅いので不安)

pumaはスレッドベース

  • スレッドが2つあったら、2つのリクエストを同時に処理できる
  • 実際には本番環境ではマルチプロセス + マルチスレッドで動かす(Clustered mode)。workerプロセスが2つ + スレッドが2つだったら、4つのリクエストを同時に処理できる
  • ただし実際には、MRIではGILがあるため1プロセスで1スレッドしか実行されない
  • メリット
    • (MRIだとしても)IO時に別スレッドに処理させることができる
    • スロークライアントの影響を受けにくい
    • メモリ使用量が少ない(参考: http://puma.io/)

MRIのスレッド

  • スレッドは複数持てるが、同時に実行できるスレッドは1つ。GIL(Global Interpreter Lock)のため。
  • ただし、Bocking IO(ファイルIO、ネットワークIO等)になった際に、別のスレッドに切り替えて処理を進める。MRIでもスレッドベースの恩恵は部分的にある。
  • IOはだいたい総時間の10~25%程度らしい(参考: https://techracho.bpsinc.jp/hachi8833/2017_11_13/47696)

Jruby・Rubiniusのスレッド

  • スレッドをフルに使える
  • pumaにはJrubyやWindows特有の成約がいくつかある(別項目)
  • MRI以外使うことはないかなー

スロークライアント

  • 回線の遅いクライアント(3Gのモバイル端末など)
  • Pumaはスレッドベースなので、IOの際に(MRIだとしても)別のスレッドに処理をさせることができる。なのでネットワークIOが長いスロークライアントには都合がよい

railsへの導入

# Gemfile

gem 'puma'
$ bundle install
# 起動

# $ rails s Puma
$ rails s

設定

参考URL

設定ファイルの読み込み

  • 3つの起動コマンド(rails spumactl startpuma)は、以下の設定ファイルを自動で読み込む
    • 環境指定がない場合: config/puma.rb
    • 環境指定がある場合: config/puma/<environment_name>.rb
  • pumactl -F config/puma.rb startのように、オプションで指定することも可能

設定項目

bind: バインド

  • URI指定しかないので、シンプル
  • デフォルト: "tcp://0.0.0.0:9292"
# TCPソケットを使う場合
bind 'tcp://0.0.0.0:9292'

# UNIXソケットを使う場合
# TCPより若干パフォーマンスが上がる(場合がある)
bind 'unix:///var/run/puma.sock'

# UNIXソケットのパーミッションを変更する必要がある場合、umaskパラメーターを利用する
bind 'unix:///var/run/puma.sock?umask=0111'

# SSLを利用する場合
bind 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert'

port: バインド(portとhost)

  • bind使えばok
# bind 'tcp://0.0.0.0:9292' と同じ
port '9292', '0.0.0.0'

ssl_bind: バインド(SSL)

  • bind使えばok
# bind 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert' と同じ
ssl_bind '127.0.0.1', '9292', {
  key: path_to_key,
  cert: path_to_cert
}

workers: ワーカー数

  • ワーカー数を指定するとclustered modeになる
  • master processからworkerをforkする
  • workerプロセスはそれぞれスレッドプールを持つ
  • デフォルト: 0
# ワーカー2スレッド16の場合、スレッドは合計で32になる
workers 2

threads: スレッド数のmin・max

  • プールで利用できるスレッドの数
  • スレッド数はtrafficによって自動で増減する
  • maxを大きくしすぎるとマシンリソースを食いつくしてしまう可能性があるので、注意
  • ここで指定したスレッド以外にも、スロークライアントの処理等のpuma自体の内部的な処理にもスレッドが作られるので注意。なので-t 1:1で指定しても、実際には7スレッドくらいが作成される。
  • デフォルト: 0:16
threads 16, 16

environment: 環境

  • デフォルト: "development"
environment 'production'
environment ENV.fetch("RAILS_ENV") { "development" } # railsで使う場合は、環境変数RAILS_ENVを使うのが良さげ

demonize: デーモン化

  • デフォルト: false
  • pidfile、stdout_redirectと一緒に使う
daemonize true

pidfile: pidファイル置き場

pidfile '/u/apps/lolcat/tmp/pids/puma.pid'
pidfile "#{Dir.pwd}/tmp/pids/puma.pid" # Railsの場合、tmp/pids/puma.pidに置くと良さげ

stdout_redirect: 標準出力/標準エラーを出力するファイル

  • 第三引数は追記モード
stdout_redirect '/u/apps/lolcat/log/stdout', '/u/apps/lolcat/log/stderr'
stdout_redirect '/u/apps/lolcat/log/stdout', '/u/apps/lolcat/log/stderr', true

preload_app!: プリロード

  • 全てのアプリコードをfork前にロードする。これによりRuby 2.0+の場合、OSのcopy-on-writeが効く。そのためメモリ使用量が下がる
  • Cluster modeでしか使えない
  • phased-restartとpreloadは同時には使えない
preload_app!

before_fork{}: 各ワーカーのフォーク前の処理

before_fork do
  ActiveRecord::Base.connection_pool.disconnect!
end

on_worker_boot{}: 各ワーカーのboot前の処理

  • ブート前のセットアップはここで行う
  • アプリ固有ではなく、puma固有の処理はここで行う
  • DB接続はここでやっておく
on_worker_boot do
  ActiveSupport.on_load(:active_record) do
    ActiveRecord::Base.establish_connection
  end
end

prune_bundler: phased_restart時にbundlerのコンテキストを新しいものに切り替えてくれる

  • これによりphased_restart(1つずつ再起動)した時に最新のGemfileを見に行ってくれる
  • phased_restartを行いたい時にほぼ必須のオプション
  • デフォルト: off
prune_bundler

plugin: プラグイン読み込み

plugin :tmp_restart

activate_control_app: コントロールサーバのURL

  • pumactlコマンドでpumaを操作するのに利用する
  • 詳しくは コントロールサーバ を参照
# デフォルト: localhostのport 9293
activate_control_app

# unixソケット
activate_control_app 'unix:///var/run/pumactl.sock'

# tokenによる認証
# トークンを指定すると、クエリパラメータにそのトークンを指定しなければいけなくなる
activate_control_app 'unix:///var/run/pumactl.sock', { auth_token: '12345' }
activate_control_app 'unix:///var/run/pumactl.sock', { no_token: true }

state_path: stateファイルのパス

  • stateファイルはサーバの状態の情報
  • pumactlコマンドでpumaを操作するのに利用する
state_path '/u/apps/lolcat/tmp/pids/puma.state'
state_path "#{Dir.pwd}/tmp/pids/puma.state" # Railsの場合、tmp/pids/puma.stateかな

directory: 起動ディレクトリ

  • defalut: カレントディレクトリ
directory '/u/apps/lolcat'

on_worker_shutdown: 各ワーカーのshutdown前の処理

on_worker_shutdown do
  puts 'On worker shutdown...'
end

tag: プロセスリストでの追加情報

  • tagを指定しない場合は、推測する
  • tagを追加したくない場合は、空文字を指定する
tag 'app name'

worker_timeout: 全てのワーカーがマスタープロセスにチェックインする、タイムアウト時間

  • タイムアウトしたら、workerはリスタートされる
  • デフォルト: 60秒
worker_timeout 60

worker_boot_timeout: ワーカーのブートのタイムアウト時間

  • デフォルト: worker_timeoutの値
worker_boot_timeout 60

lowlevel_error_handler{}: アプリ外の例外のエラーハンドリング

  • デフォルト: 500とエラーテキストを返す
lowlevel_error_handler do |e|
  Rollbar.critical(e)
  [500, {}, ["An error has occurred, and engineers have been informed. Please reload the page. If you continue to have problems, contact support@example.com\n"]]
end

app: Rackアプリ

  • config自体がRackアプリになる
app do |env|
  puts env

  body = 'Hello, World!'

  [200, { 'Content-Type' => 'text/plain', 'Content-Length' => body.length.to_s }, [body]]
end

rackup: アプリ起動ファイルのパス

  • デフォルト: "config.ru"
rackup '/u/apps/lolcat/config.ru'

quiet: リクエストロギングをdisable

  • デフォルト: false
quiet

log_requests: リクエストロギングをenable

restart_command: pumaの再起動に使用するコマンド

restart_command '/u/app/lolcat/bin/restart_puma'

load: 追加のconfigファイルをロード

on_restart{}: リスタート前の処理

  • 例: ログファイルを閉じる、DBコネクションを閉じる、Redisコネクションを閉じる
on_restart do
  puts 'On restart...'
end

persistent_timeout: persistent connectionsのタイムアウト

first_data_timeout: 受信無しでtcpソケットを開き続ける際のタイムアウト

tcp_mode!: pumaをTCPモードで起動

queue_requests: リクエストをキューする

shutdown_debug: shutdown時にバックトレースを出力

debug: デバグ情報を出力

early_hints: Early Hintsのサポートを有効にする

他にも色々

プロセス管理

シグナル

  • masterプロセスにシグナルを送ることで、pumaの停止/再起動等が可能
  • pumactlコマンドがいい感じにラップしてくれてるので、そっちを使うのが良さそう

シグナル一覧

INT: 停止

  • Ctrl+C

QUIT: 停止

TERM: 停止

USR2: 再起動

  • 設定ファイルをリロードする

USR1: 再起動(Phased Restart)

  • 設定ファイルをリロードしない

TTIN: ワーカー1増やす

TTOU: ワーカー1減らす

HUP: ログファイルをリオープン

  • ログファイルはstdout_redirectの設定を見る
  • stdout_redirectがなければINTのようにふるまう

pumaコマンド

  • pumaコマンドのオプションは設定ファイルに対応する項目がある。詳細については 設定項目 を参照
  • ただし設定ファイルとは設定方法が微妙に違う項目もあるので注意

オプション

-C, --config PATH: 設定ファイル読み込み(loadに対応)

# デフォルト値(config/puma.rb)
$ puma

# 指定
$ puma -C config/puma.rb

# ファイルを指定したくない場合(デフォルト値が使われるのを防ぐ)
$ puma -C "-"

-b, --bind URI: バインド(bindに対応)

# TCPソケットを使う場合
$ puma -b tcp://127.0.0.1:9292

# UNIXソケットを使う場合
# TCPより若干パフォーマンスが上がる(場合がある)
$ puma -b unix:///var/run/puma.sock

# UNIXソケットのパーミッションを変更する必要がある場合、umaskパラメーターを利用する
$ puma -b 'unix:///var/run/puma.sock?umask=0111'

# SSLを利用する場合
$ puma -b 'ssl://127.0.0.1:9292?key=path_to_key&cert=path_to_cert'

-t, --threads INT: スレッド数(threadsに対応)

$ puma -t 8:32

-w, --workers COUNT: workers数(workersに対応)

$ puma -w 3

-d, --daemon: デーモン化(daemonizeとquietに対応)

-e, --environment ENVIRONMENT: 環境(environmentに対応)

-p, --port PORT: bind(portに対応)

-q, --quiet: リクエストロギングをdisable(quietに対応)

-v, --log-requests: リクエストロギングをenable(log_requestsに対応)

-R, --restart-cmd CMD: pumaの再起動に使用するコマンド(restart_commandに対応)

-S, --state PATH:stateファイルのパス(state_pathに対応)

--tcp-mode: TCPモード(tcp_mode!に対応)

--early-hints: Early Hintsのサポートを有効にする(early_hintsに対応)

--debug: デバグ情報を出力(debugに対応)

--dir DIR: directory: 起動時ディレクトリ(directoryに対応)

--pidfile PATH: PIDファイルパス(pidfileに対応)

--preload: プリロード(preload_app!に対応)

--prune-bundler: bundler切り替え(prune_bundlerに対応)

--tag NAME: プロセスリストでの追加情報(tagに対応)

--redirect-stdout FILE: 標準出力のリダイレクト先ファイル(stdout_redirectに対応)

--redirect-stderr FILE, 標準エラー出力のリダイレクト先ファイル(stdout_redirectに対応)

--[no-]redirect-append: 追記モード(stdout_redirectに対応)

--control-url URL: コントロールサーバのurl(activate_control_appに対応)

# pumaの場合
$ puma --control-url tcp://127.0.0.1:9293 --control-token foo

# pumactlの場合
$ pumactl --control-url 'tcp://127.0.0.1:9293' --control-token foo restart

--control URL: --control-urlと同じ(activate_control_appに対応)

--control-token TOKEN: コントロールサーバの認証用トークン(activate_control_appに対応)

-I, --include PATH: $LOAD_PATHに追加

-V, --version: バージョン

-h --help: ヘルプ

pumactlコマンド

pumactl

  • コントロールサーバを使ってpumaのプロセス管理を行うコマンド
  • コントロールサーバのurlが指定されている場合は、HTTPリクエストを使う。urlが指定されていない場合は、シグナルを使う。
  • url指定は、設定ならactivate_control_app、オプションなら--control-url
  • ctlはcontrolの略なんだね
  • pumactlのメリット
    • pumaのプロセス管理はpumaコマンド(起動/状態確認など)、シグナル(停止/再起動など)、PID/stateファイル管理などで色々面倒だった。pumactlコマンドでプロセス管理の統一的なインタフェースを用意したってことらしい。(参考: http://ruby-journal.com/digesting-pumactl/)
  • 参考: https://github.com/puma/puma/blob/master/lib/puma/control_cli.rb

コントロールサーバ

  • HTTPリクエスト受け付けて、pumaの再起動などを行うRackアプリのサーバ
  • HTTPリクエストを送るにはpumactlコマンドを使う
  • tokenを使えば簡易的な認証が可能
  • 使い方
    • 設定ファイルならactivate_control_app
    • pumaコマンドなら--control-url
  • 参考: https://github.com/puma/puma/blob/master/lib/puma/app/status.rb
# puma + コントロールサーバ 起動
$ puma --control-url tcp://127.0.0.1:9293 --control-token foo

# HTTPリクエストでpuma再起動
$ pumactl --control-url 'tcp://127.0.0.1:9293' --control-token foo restart

コマンド

start: 起動

$ pumactl start

stop: 停止(TERM)

$ pumactl stop

halt: 停止(QUIT)

# $ kill -QUIT `(cat tmp/pids/server.pid)` 相当
$ pumactl halt

restart: 再起動(USR2)

$ pumactl restart

phased-restart: 再起動(USR1)

  • phased-restart
$ pumactl phased-restart

status: PID確認

$ pumactl status

# 起動している場合
PID 2557 is running

# 起動してない場合
No puma process

stats: worker等の情報

gc: ガベージコレクション開始

gc-stats: ガベージコレクションの情報

reload-worker-directory: ?

オプション

-S, --state PATH: stateファイルのパス

-F, --config-file PATH: 設定ファイルのパス

-P, --pidfile PATH: pidファイルのパス

-p, --pid PID: pid

-C, --control-url URL: control serverのurl

-T, --control-token TOKEN: control serverの認証トークン

-H, --help: ヘルプ表示

-V, --version: バージョン表示

-Q, --quiet: 表示を止める

railsコマンド

起動

$ rails s
$ rails s Puma

結局どうすれば?

  • 設定は全て設定ファイルに書いて、pumactlコマンド使うのが簡単そう。

その他メモ

2つのモード

Single mode: プロセスは1つだけ

  • もともとはpumaはSingle modeのみでスレッド専用として開発された

Clustered mode: masterプロセス1つ + workerプロセス複数

  • master-workerモデル
  • 設定でworkersを指定するとこっちになる
  • 本番環境ではClustered modeで使うべき

2つのリスタート

参考

Hot Restart: リスタート時に、サーバーのソケットを開きっぱなしにする

  • 処理が完了するのを待ってから再起動する
  • unicornとかのホットデプロイとは違うっぽい(ダウンタイムなしという意味ではPhased Restartが近い?)

メリット

  • リクエストの取りこぼしがなくなる
  • preloadと併用できる

デメリット

  • ダウンタイムあり(リスタートが完了するまで、リクエスト処理は止まる)

使い方

  • pumactl restart
  • USR2シグナル

Phased Restart: 1つずつworkerを再起動する

  • ワーカーを1つずつ再起動するので、どこかのワーカーがリクエスト処理をしてくれる

メリット

  • ダウンタイムなし(常にどこかのworkerが生きているので、リクエスト処理が止まらない)

デメリット

  • Clustered mode(復数worker)のみ
  • preloadは使えない(workerを1つずつkillして、1つずつ再起動するため)
  • DBスキーマは新しいのに、アプリコードは古いという自体になりうる(新しいコードと古いコードが共存するので)。なのでマイグレーションをするような環境ではHOT Restartを利用すべき。あるいは古いバージョンのアプリコードと互換性がないといけない。

使い方

  • pumactl phased-restart
  • USR1シグナル

プラグイン

  • Pumaに機能を追加できる
  • Puma3.0から導入

使い方

# Gemfile
gem 'puma-heroku' # プラグイン
$ bundle install
# config/puma.rb
plugin :heroku

有名プラグイン

tmp_restart: tmp/restart.txtをtouchするとリスタートする

heroku: heroku向けの設定を用意してくれる

自分で実装

  • configやstart等のフックメソッドをオーバーライドすれば実装できるっぽい
  • 上の2つのプラグインを参考にする

worker数、thread数の目安

worker数の目安

  • CPUの観点
    • とりあえずworker数 = CPUコア数。実際にはさらに最適化すべき。コア数以上にしたほうが良い場合もある。CPUコア数の1.5倍まで増やしてもいいかも
    • cpu使用率70%くらいが目安
  • メモリの観点
    • worker数 = RAM / (1プロセスのメモリ使用量 * 1.2) # Railsアプリだと1プロセス200MB~400MB程度が基準
    • メモリ使用量70%くらいが目安
  • ロードバランシングの観点
    • 最適なロードバランシングのためにはworker数は3以上

thread数の目安

  • thread数決めるのは難しい。5程度にして後は忘れるのがいいという意見も。
  • デフォは16。これはそこそこ妥当らしい。
  • ちなみにMRIの場合はIOだけ並列化可能。これはだいたい総時間の10~25%程度らしい
  • Unicornから移行する場合
    • workerをunicornの半分にする + thread数を2にする -> これでメモリ使用量が50%になる
    • スレッドに慣れてきたら、workerを減らしてthreadを増やして調整すると良い

puma + Systemd

puma + Nginx

puma + heroku

Rails5からRailsのデフォルト

  • Rails5でActionCableの導入に伴い、development環境のアプリケーションサーバがWebrickからpumaへ変更

Mongrelから派生

  • pumaはMongrelから派生した
  • 改善点
    • Rackアプリにした
    • スレッドベースにした

プラットフォームによる成約

JRuby, Windows

restart時にソケットを必ず閉じる

ディスクリプタを受けわたせないため

cluster modeがない

fork(2)がないため

Windows

daemon modeがない

fork(2)がないため

puma関係のgem

puma_worker_killer: 定期的にworkerをkillするgem

  • workerを長時間放置しておくと、メモリ使用量が徐々に増えてきて、サーバ全体の処理が遅くなる可能性がある。 -> puma_worker_killerを使い、定期的にworkerをkillして新しいworkerを立ち上げる
  • Ruby Webアプリのプロセスは時間とともにメモリ使用量が増加する。生成後の2倍から3倍に達することもある。
  • unicorn_worker_killerのpuma版
  • 当然cluster modeでしか使えない

導入

1. インストール

# Gemfile
gem 'puma_worker_killer'
$ bundle install

2. 設定

# config/puma.rb
before_fork do
  PumaWorkerKiller.config do |config|
    # サーバのメモリ(1024MB)
    config.ram           = 1024

    # 確認頻度(5秒毎)
    config.frequency     = 5

    # workerを再起動する閾値(メモリ使用量が65%になったらkill)
    # 一番メモリ使用量の多いworkerを再起動する
    config.percent_usage = 0.65

    # rolling_restartの頻度(12時間に1回)
    # rolling_restart: メモリ使用量に関係なく、順番にworkerを再起動する仕組み
    config.rolling_restart_frequency = 12 * 3600

    # falseにすると監視ログを止める
    # 普通のログに混ざって紛らわしい
    config.reaper_status_logs = true

  end
  PumaWorkerKiller.start
end

puma-dev: powのpuma版

  • powのpuma版。powと同等の機能が利用できる
  • 開発時のサーバとして利用すると便利

対応OS

  • OS X
  • Linux

powの知識

pros

  • シンボリックリンクを貼るだけでバーチャルホストで切って開発できる
  • "rails s"で起動することなく、アクセスすると自動で起動する
  • 同一LAN内からアクセス可能
  • 設定が不要(Zero-configuration)

cons

  • もうメンテされていない
  • 起動が遅い
  • フォアグラウンドじゃないのでbinding.pryできない(pry-remoteやプロキシ使えばいける)

使い方

# ローカルからアクセス
http://[project名].dev/

# 同一LAN内からアクセス
http://[project名].[LANのIP-ADDRESS].xip.io

xip.io: IPアドレスをサブドメインに与えることでIPアドレス自身を返す、グローバルなDNS

  • 37signalsがpowのために作ったらしい
  • puma-devもxip.ioを利用できる
ワイルドカードDNS
  • IPアドレスをサブドメインに与えることで、IPアドレス自身を返す、グローバルなDNS
  • 例: "192.168.100.200.wild.card" は "192.168.100.200" を返す

powよりpuma-devを使うべき

  • powがメンテされていない
  • https対応
  • WebSocket対応
  • powder(pow用のコマンドツール)相当の機能が付属している
  • MacとLinuxをサポート

導入(mac)

0. powをインストール済みの場合は、アンインストールする

  • 色々問題があるっぽい
$ curl get.pow.cx/uninstall.sh | sh

1. brewでインストール

  • gemでも可能
$ brew install puma/puma/puma-dev

2. DNS設定

  • resolverを使い、ローカルのMacにpuma-dev用のDNSサーバ設定を追加
$ sudo puma-dev -setup

3. セットアップ

  • ".dev"ではなく".test"にする
$ puma-dev -install -d test

4. シンボリックリンクを貼る

# puma-devコマンドを使う場合
$ cd my_app
$ puma-dev link -n my_app

# 自分でシンボリックリンク貼ってもokらしい
$ cd ~/.puma-dev/
$ ln -s my_app

5. リスタート

touch tmp/restart.txt

デフォルトの設定

port: 80と443

domain: .test

  • 昔は.devドメインだったが、2017年にGoogleが所有してからHSTS onlyになってしまったため、.testに変わった
  • ChromeのVer.63から、.devにはHTTPSが必須

コマンド

# puma-devコマンドを使う場合
$ cd my_app
$ puma-dev link -n my_app

# 自分でシンボリックリンク貼ってもokらしい
$ cd ~/.puma-dev/
$ ln -s my_app

touch tmp/restart.txt: アプリ再起動

puma-dev -stop: 全アプリを再起動

  • puma-devにUSR1シグナルを送っている
# これと同じ
$ pkill -USR1 puma-dev

puma-dev: 起動(フォアグラウンド)

  • ~/.puma-devを使う

puma-dev -h: ヘルプ

sudo puma-dev -setup: 設定(macのみ)

puma-dev -pow: ~/.powを使う

  • By default, puma-dev uses the domain .test to manage your apps. If you want to have puma-dev look for apps in ~/.pow, just run puma-dev -pow.

環境変数

環境変数一覧

CONFIG: pumaの設定ファイルのパス
  • デフォルト: なし
  • config/puma-dev.rbとかを指定する
THREADS: pumaのスレッド数
  • デフォルト: 5
WORKERS: pumaのworker数
  • デフォルト: 0

環境変数の読み込み順(上から順)

  • ~/.powconfig
  • .env
  • .powrc
  • .powenv

シンボリックリンク

  • "~/.puma-dev/"配下にサブディレクトリを置けば、使える
  • cool-frontend.testの場合、以下の2つを探す
    • ~/.puma-dev/cool-frontend
    • ~/.puma-dev/cool/frontend

HTTPS

  • デフォルトでport443を使う
  • 自動でオレオレ証明書を作ってくれるっぽい: "~/Library/Application Support/io.puma.dev/cert.pem."

ログ

  • ログのパス: ~/Library/Logs/puma-dev.log