Railsにて、Controllerのactionにクエリストリング付でリダイレクトする

RailsでControllerのactionにリダイレクトする時は

redirect_to action: :index

のように書くものの、クエリストリング付でリダイレクトする時はどうすれば良いか調べた時のメモ。

 

環境

 

対応

redirect_to の引数を

def move_with_field
  redirect_to action: :index, ham: 'spam'
end

のように、actionの後ろへ ham: 'spam' な形で指定します。

 
例えば

Rails.application.routes.draw do
  get 'home/index'
  get 'home/move_with_field'

のようなroutes.rbの場合は、

http://localhost:3760/home/move_with_field

から

http://localhost:3760/home/index?ham=spam

へとリダイレクトします。

 
もし、リダイレクト時にクエリストリングも引き継ぎたい場合、

def move_with_unpermitted_params
  redirect_to action: :index, **params
end

としても、エラー unable to convert unpermitted parameters to hash になります。

 
そのため、Strong Parameterを使って

def move_with_permitted_params
  p = move_params
  redirect_to action: index, **p
end

private

def move_params
  params.permit(:foo, :bar)
end

とすることで、クエリストリングのうち許可されたキーのみを持って

http://localhost:3760/home/move_with_permitted_params?foo=1&bar=2&baz=3

から

http://localhost:3760/home/index?bar=2&foo=1

へとリダイレクトします。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_routing_app

 
railsをポート 3760 で起動し、 % bin/rails print_redirect:run を実行すると、リダイレクト元と先のURLが確認できます。

Railsで、うっかりトランザクションをネストしたところロールバックされなくなったため、requires_newとjoinableを調べてみた

Railsで、うっかりトランザクションをネストしてロールバックされなくなったので、requires_newとjoinableを調べた時のメモを残します。

なお、調査時のソースコードが長くなってしまったことから、一部のみ記載しています。

もしよろしければ、Github上のソースコードも参照してください。
https://github.com/thinkAmi-sandbox/rails_transaction_app

 
目次

 

環境

 

調べ始めた経緯

トランザクションなし

あるところに、こんな感じのViewがありました。

 
erbはこんな感じです。

<h1>登録フォーム</h1>
<%= form_with(url: @post_path, method: :post, local: true) do |f| %>
  <% if flash[:message].present? %>
    <div><%= flash[:message] %></div>
  <% end %>

  <div class="field">
    <%= f.label :name, '名称' %>
    <%= f.text_field :name, autofocus: true %>
  </div>

  <div class="actions">
    <%= f.submit '送信' %>
  </div>
<% end %>

 

当初、Viewに対応するコントローラでは、モデルの保存のみが実装されていました。

class HomeController < ApplicationController
  def new
    @post_path = home_create_path
  end

  def create
    fruit = Fruit.new(create_params)
    fruit.save!

    flash[:message] = '保存に成功しました'

    redirect_to home_new_path
  end
# ...

  private

  def create_params
    params.permit('name')
  end
end

 

[誤] トランザクションを作成したが、トランザクションの中で例外を捕捉していた

ある時、別のモデルや外部APIトランザクションで更新することになりました。

Statusモデルと外部APIが増え、StatusとFruitをトランザクションで保存する必要が出てきました。

そこで、モデルに対しStatusとFruitをトランザクションで保存するクラスメソッド save_with_invalid_transaction を作成しました。

また、トランザクションの挙動を確認するため、外部APIは常に失敗するようにメソッド save_by_api を用意しました。

class Fruit < ApplicationRecord
  def self.save_with_invalid_transaction(params, type_name)
    ActiveRecord::Base.transaction do
      status = Status.find_by_key('fruit')
      status.name = 'success'
      status.save!

      fruit = Fruit.new(params)
      fruit.save!

      save_by_api
      true
    rescue StandardError
      status = Status.find_by_key('fruit')
      status.name = "error - #{type_name}"
      status.save!

      false
    end
  end

  private

  def self.save_by_api
    # 外部APIで保存エラーになるとする
    raise StandardError
  end
end

 
動作確認をしたところ、トランザクションの中で例外を捕捉していたため、トランザクションロールバックできず

  • Fruitは入力値で登録済
  • Statusはエラーで更新済

となりました。
トランザクション中にrescueするとロールバックしないので注意! - Qiita

 

トランザクションの外で例外を捕捉

上記のコードから例外を捕捉する場所をトランザクションの外に変えたメソッド save_with_valid_transaction

class Fruit < ApplicationRecord
# ...
  def self.save_with_valid_transaction(params, type_name)
    ActiveRecord::Base.transaction do
      puts "現在のトランザクション数 : #{ActiveRecord::Base.connection.open_transactions}"
      status = Status.find_by_key('fruit')
      status.name = 'success'
      status.save!

      fruit = Fruit.new(params)
      fruit.save!

      save_by_api
      true
    end

  rescue StandardError
    status = Status.find_by_key('fruit')
    status.name = "error - #{type_name}"
    status.save!

    false
  end
#...
end

と用意してみたところ、トランザクションロールバックができ、

となりました。

 
また、参考までに現在のトランザクション数を出力するようにしましたが、今回は 1 でした。
mysql - How to tell if already within a database transaction in ruby on rails? - Stack Overflow

 

[誤] うっかりコントローラとモデルの両方でデフォルトのトランザクションを実装

上記の通り、モデルの save_with_valid_transaction メソッドではトランザクションを使っています。

ただ、うっかりしていてモデルにトランザクションがあると気づかず、コントローラでもトランザクションを作ってしまいました。

その結果、コントローラとモデルでトランザクションがネストしてしまいました。

class HomeController < ApplicationController
  def new_with_nest_transaction
    @post_path = home_create_with_nest_transaction_path
    render 'home/new'
  end

  def create_with_nest_transaction
    is_success = false
    ActiveRecord::Base.transaction do
      is_success = Fruit.save_with_valid_transaction(create_params, 'nest')
    end

    if is_success
      flash[:message] = '保存に成功しました'
    else
      flash[:message] = '保存に失敗しました'
    end

    redirect_to home_new_with_nest_transaction_path
  end
# ...
end

 
ここで、コントローラ側・モデル側とも transaction はデフォルトのまま、引数は何も指定しませんでした。

デフォルトでは

  • requires_new == false
  • joinable == true

となることから、モデル側のトランザクションがコントローラ側のトランザクションに合流してしまいました。

 
そのため、トランザクションの内側で外部APIの例外が捕捉される形となり、

  • Fruitは入力値で登録済
  • Statusはエラーで更新済

と、DBにStatusがエラーなのに入力値が保存されているという状態でした。

 
また、ログにも

現在のトランザクション数 : 1

が出力され、トランザクションが合流していることがわかりました。

 

モデル側のトランザクションでrequires_newとjoinableを指定

うっかりネストして親のトランザクションに合流しないようにする方法を調べたところ、以下の記事がありました。
【翻訳】ActiveRecordにおける、ネストしたトランザクションの落とし穴 - Qiita

 
そこで、モデル側のトランザクション

class HomeController < ApplicationController
# ...
  def self.save_with_new_transaction(params, type_name)
    ActiveRecord::Base.transaction(requires_new: true, joinable: false) do # 引数追加
      puts "現在のトランザクション数 : #{ActiveRecord::Base.connection.open_transactions}"

      status = Status.find_by_key('fruit')
      status.name = 'success'
      status.save!

      fruit = Fruit.new(params)
      fruit.save!

      save_by_api
      true
    end

  rescue StandardError
    status = Status.find_by_key('fruit')
    status.name = "error - #{type_name}"
    status.save!

    false
  end

としたところ、別々のトランザクションとみなされ、

となりました。

ログを見ても

現在のトランザクション数 : 2

と、トランザクション数が増え、別トランザクションになっていました。

 

require_newとjoinableとActiveRecord::Rollbackの組み合わせを調査

上記の通り対応はできました。

ただ、transactionのオプション require_newjoinable それに例外を組み合わせた時、どのような挙動になるのかが気になりました。

そこでまずはロールバック用の例外 ActiveRecord::Rollback を組み合わせた内容を調査することにしました。

 

調査方法

今まで参考にした記事より、

ように見えました。

また、ActiveRecordの実装を見ると

def transaction(requires_new: nil, isolation: nil, joinable: true)
  if !requires_new && current_transaction.joinable?
    if isolation
      raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
    end
    yield
  else
    transaction_manager.within_new_transaction(isolation: isolation, joinable: joinable) { yield }
  end
rescue ActiveRecord::Rollback
  # rollbacks are silently swallowed
end

となっていました。
https://github.com/rails/rails/blob/v6.1.4/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L313

 
そこで、

と、親と子に挟まれたトランザクションを調査対象ととらえ、requires_newjoinable の組み合わせごとに結果を見てみました。

 
また、上記の組み合わせに対し、例外 ActiveRecord::Rollback

のいずれかで発生させてみて、各挙動を確認してみました。

 
今回、トランザクションおよび ActiveRecord::Rollback を起こす処理はモデルに

class Fruit < ApplicationRecord
# ...
  def self.rollback_by_parent(requires_new:, joinable:)
    puts "==== #{__method__}, requires_new #{requires_new}, joinable: #{joinable} ====>"
    puts "親のトランザクションの外 : #{ActiveRecord::Base.connection.open_transactions}"

    # すべて実行した後に、親でロールバック発生
    ActiveRecord::Base.transaction do
      puts "親のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}"
      Fruit.create!(name: '', description: "[親でロールバック] 親: requires_new=#{requires_new} joinable=#{joinable}")

      ActiveRecord::Base.transaction(requires_new: requires_new, joinable: joinable) do
        puts "自身のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}"
        Fruit.create!(name: '自身', description: "[親でロールバック] 自身: requires_new=#{requires_new} joinable=#{joinable}")

        ActiveRecord::Base.transaction do
          puts "子のトランザクションの中 : #{ActiveRecord::Base.connection.open_transactions}"
          Fruit.create!(name: '', description: "[親でロールバック] 子: requires_new=#{requires_new} joinable=#{joinable}")
        end
      end

      raise ActiveRecord::Rollback
    end
  end
# ...

のような形で実装しました。

 
また、モデルに渡す requires_newjoinable の組み合わせ条件は、RSpecを使って

RSpec.describe Fruit, type: :model do
  describe '#rollback_by_xxx系 のトランザクションの設定とロールバック状況' do
    context 'requires_new: true, joinable: false' do
      context '親でロールバック' do
        it 'すべてロールバック' do
          Fruit.rollback_by_parent(requires_new: true, joinable: false)

          expect(Fruit.all.count).to eq 0
        end
      end

      context '自身でロールバック' do
        it '親・自身・子はそれぞれ別トランザクションのため、自身とそのネストしたトランザクションの子はロールバックするが、親は残る' do
          Fruit.rollback_by_self(requires_new: true, joinable: false)

          expect(Fruit.all.count).to eq 1
          expect(Fruit.find_by(name: '')).to be_truthy
        end
      end

      context '子でロールバック' do
        it '親・自身・子はそれぞれ別トランザクションのため、子はロールバックするが、親・自身は残る' do
          Fruit.rollback_by_child(requires_new: true, joinable: false)

          expect(Fruit.all.count).to eq 2
          expect(Fruit.find_by(name: '')).to be_truthy
          expect(Fruit.find_by(name: '自身')).to be_truthy
        end
      end
    end

のようにモデル側に渡して検証しました。

 
ソースコードの全体は、以下のファイルになります。

 

調査結果

transactionに渡す条件のうち、 requires_new: true, joinable: false の場合が

となり、一番すっきりしました。

 
一方、 requires_new: true, joinable: true という、transactionに requires_new: true だけ渡し、joinable がデフォルトの場合は、

となり、子のトランザクションが合流してきた時に意図しない挙動になりました。

 
また、他のケースでは ActiveRecord::Rollback が握りつぶされてしまってロールバックできなくなる等、いずれも意図しない挙動となっていました。

 

require_newとjoinableと非ActiveRecord::Rollbackの組み合わせを調査

ActiveRecord::Rollbackは握りつぶされますが、それ以外の例外は握りつぶされません。

そのため、いずれの結果もすべてのトランザクションロールバックしていました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_transaction_app

Railsにて、コントローラのアクションが長くて見通しが悪くなったため、Formオブジェクトとフィルタを使うようにした

Railsを書いている中で、コントローラのアクションが長くなってしまい、コードの見通しが悪くなったことがありました。

そのような時はFormクラスとフィルタを使うのが良いと同僚に教わったため、リファクタリングした時のメモを残します。

 
目次

 

環境

 

仕様について

以下のフォームがあるとします。

 
このフォームでは以下のことを行います。

  • 入力したCodeのバリデーションを行う
    • バリデーションをパスした場合は、外部APIを使ってデータを保存する
    • バリデーションをバスできなかった場合は、入力フォームへ戻る

 
リファクタリング前は、このコミットの状態だとします。
https://github.com/thinkAmi-sandbox/controller_filter_app/pull/1/commits/bf683d47e826a523a2aa3d9fd569312993ad6dd3

 
以降、ビューやコントローラの詳細を見ていきます。

 

ビュー

データの保存は外部APIで行うため、今回のフォームはModelとは紐付いていません。

そのため、 form_with を使った時はこんな感じになります。

<h1>登録フォーム</h1>
<%= form_with(url: home_create_path, method: :post, local: true) do |f| %>
  <% flash[:messages]&.each do |message| %>
    <div><%= message %></div>
  <% end %>

  <div class="field">
    <%= f.label :code, 'Code' %>
    <%= f.text_field :code, autofocus: true %>
  </div>

  <div class="actions">
    <%= f.submit '送信' %>
  </div>
<% end %>

 

コントローラ

2つのアクションメソッドがあります。各機能は以下のとおりです。

  • new
    • 上記ビューを表示
  • create
    • 各種バリデーションと保存
      • バリデーションエラーの場合は、 new アクションへリダイレクト
      • バリデーションをパスしたら、外部APIを使ってデータを保存し、 new アクションへリダイレクト

 
コードとしてはこんな感じです。なお、バリデーション用・保存用の外部APIについては今回省略し、常に成功する ( true を返す) ものとします。

class HomeController < ApplicationController
  def new
  end

  def create
    code = create_params[:code]

    unless code =~ /[0-9]{2}/
      flash[:messages] = ['フォーマットが異なります']
      return redirect_to home_new_path
    end

    unless valid_by_api?(code)
      flash[:messages] = ['バリデーションエラーとなりました']
      return redirect_to home_new_path
    end

    begin
      create_by_api!(code)
      flash[:messages] = ['登録できました']
      redirect_to home_new_path
    rescue StandardError
      flash[:messages] = ['登録に失敗しました']
      redirect_to home_new_path
    end
  end

  private

  def create_params
    params.permit(:code)
  end

  def valid_by_api?(code)
    # 外部APIを使った確認:常にTrueを返す
    true
  end

  def create_by_api!(code)
    # 外部APIを使った登録:常にTrueを返す
    true
  end
end

 

テストコード (request spec)

上記のビューとコントローラーの動作を確認するための request spec も用意しました。

require 'rails_helper'

RSpec.describe 'Homes', type: :request do
  describe 'GET /new' do
    before { get home_new_path }

    it 'テンプレートが表示されること' do
      expect(response).to have_http_status '200'
      expect(response.body).to include('登録フォーム')
    end
  end

  describe 'POST /create' do
    let(:valid_code) { '12' }

    context '正常系' do
      before do
        params = { code: valid_code }
        post home_create_path(format: :json), params: params
      end

      it '入力画面にリダイレクトすること' do
        expect(response).to have_http_status '302'
      end

      it 'リダイレクト先でエラーが表示されていること' do
        follow_redirect!
        expect(response.body).to include '登録できました'
      end
    end

    context '異常系' do
      context 'code のフォーマットが不正な場合' do
        before do
          params = { code: 'abc' }
          post home_create_path(format: :json), params: params
        end

        it '入力画面にリダイレクトすること' do
          expect(response).to have_http_status '302'
        end

        it 'リダイレクト先でエラーが表示されていること' do
          follow_redirect!
          expect(response.body).to include 'フォーマットが異なります'
        end
      end

      context '外部APIのバリデーションでエラーになる場合' do
        before do
          expect_any_instance_of(HomeController).to receive(:valid_by_api?).once.with(valid_code).and_return(false)

          params = { code: valid_code }
          post home_create_path(format: :json), params: params
        end

        it '入力画面にリダイレクトすること' do
          expect(response).to have_http_status '302'
        end

        it 'リダイレクト先でエラーが表示されていること' do
          follow_redirect!
          expect(response.body).to include 'バリデーションエラーとなりました'
        end
      end

      context '外部APIの登録でエラーになる場合' do
        before do
          expect_any_instance_of(HomeController).to receive(:create_by_api!).once.with(valid_code).and_raise(StandardError)

          params = { code: valid_code }
          post home_create_path(format: :json), params: params
        end

        it '入力画面にリダイレクトすること' do
          expect(response).to have_http_status '302'
        end

        it 'リダイレクト先でエラーが表示されていること' do
          follow_redirect!
          expect(response.body).to include '登録に失敗しました'
        end
      end
    end
  end
end

 
以上が元のコードとなります。

 

修正したいアクションについて

先ほどの create アクションを再掲します。

アクションの中にバリデーションと保存処理が混在しており、見通しが悪くなっています。

これを修正していきます。

class HomeController < ApplicationController
  def create
    code = create_params[:code]

    unless code =~ /[0-9]{2}/
      flash[:messages] = ['フォーマットが異なります']
      return redirect_to home_new_path
    end

    unless valid_by_api?(code)
      flash[:messages] = ['バリデーションエラーとなりました']
      return redirect_to home_new_path
    end

    begin
      create_by_api!(code)
      flash[:messages] = ['登録できました']
      redirect_to home_new_path
    rescue StandardError
      flash[:messages] = ['登録に失敗しました']
      redirect_to home_new_path
    end
  end
# ...

 

Step1. フォームに関するバリデーションをFormオブジェクトへ移動

今回のバリデーションは

  1. フォームで入力可能な値ではない
  2. フォームで入力可能な値だが、外部APIを使った時に不正な値

の2つがあります。

1.はフォームに関係するバリデーションです。DjangoではFormを使いますが *1RailsではFormオブジェクトを使えば良いと教わりました。

そこで、1.のバリデーションをFormオブジェクトへ移していきます。

 

Formオブジェクトの作成

まずは app/forms/create_form.rb としてファイルを作成します。

Formオブジェクトでは

  • include ActiveModel::Model をinclude
  • attr_accessor として、入力項目 code を用意
  • Model同様、 validates を使ってバリデーションを実装

となります。

class CreateForm
  include ActiveModel::Model

  attr_accessor :code

  validates :code, format: { with: /[0-9]{2}/, message: 'フォーマットが異なります' }
end

 

コントローラの修正

ビューでFormオブジェクトを使えるように準備します。

  • ビューでFormオブジェクトを使えるようにするため、アクション new にてフォームオブジェクトのインスタンス変数をセット
  • アクション create にて入力値のバリデーションをFormオブジェクトで行う

とすると

def create
  code = create_params[:code]

  unless code =~ /[0-9]{2}/
    flash[:messages] = ['フォーマットが異なります']
    return redirect_to home_new_path
  end

def create
   if @create_form.invalid?
     flash[:messages] = @create_form.errors.map(&:full_message)
     return redirect_to home_new_path
   end

   code = @create_form.code

となります。

 

ビューの修正

フォームオブジェクトをビューに紐付けるため、 form_withmodel としてFormオブジェクトの変数を渡します。

<%= form_with(model: @create_form, url: home_create_path, method: :post, local: true) do |f| %>

 

コントローラの再修正

modelを追加したため、create_params が変わります。

params.permit(:code)

から

params.require(:create_form).permit(:code)

と、require にFormオブジェクトの追加が必要になります。

 

テストコードの修正

create_paramに create_form を追加したため、

context '正常系' do
  before do
    params = { code: valid_code }
...

context '正常系' do
  before do
    params = { create_form: { code: valid_code }}
...

になります。

Step1の全体は、こちらのコミットになります。
https://github.com/thinkAmi-sandbox/controller_filter_app/pull/1/commits/07b48411721ee631ae806c58daed23123d7504d8

 

Step2. コントローラのバリデーションをフィルタへ移動

Formのバリデーションは移動できたものの、アクションにまだバリデーションが含まれてしまっています。

Djangologin_required デコレータ *2 のような、「バリデーションエラーとなった時はリダイレクトする」のような機能がほしいなと思っていたところ、 before_action フィルタを使えばよいと教わりました。

 
まず、Formオブジェクトにセットするところやバリデーション部分をそれぞれ

def set_form
  @create_form = CreateForm.new(create_params)
end

def validate_form
  return if @create_form.valid?

  flash[:messages] = @create_form.errors.map(&:full_message)
  redirect_to home_new_path
end

def validate_api
  return if valid_by_api?(@create_form.code)

  flash[:messages] = ['バリデーションエラーとなりました']
  redirect_to home_new_path
end

というプライベートメソッドに移動します。

 
次に、 before_action フィルタで、それらのプライベートメソッドを使うよう

class HomeController < ApplicationController
  before_action :set_form, only: :create
  before_action :validate_form, only: :create
  before_action :validate_api, only: :create

とします。

 
その結果 create メソッドは

def create
  begin
    create_by_api!(code)
    flash[:messages] = ['登録できました']
    redirect_to home_new_path
  rescue StandardError
    flash[:messages] = ['登録に失敗しました']
    redirect_to home_new_path
  end
end

と、外部APIで登録する処理だけになりました。

 
なお、

  • before_action フィルタの数が増加
  • インスタンス変数にセットしている set_*** 系が増加

などが発生すると、これでも見通しが悪くなるかもしれません。

ただ、今回はそこまでの量ではないため、この形で終えようと思います。

 
Step2の全体は以下のコミットとなります。
https://github.com/thinkAmi-sandbox/controller_filter_app/pull/1/commits/ac415af8b913d732b74f20e32a8b1cc4ff85ed26

 

Step3. begin ~ rescueの begin を削除

Railsとは関係ないですが、リファクタリングついでです。

Rubyでは begin なしで良いと教わったため、

def create
  create_by_api!(@create_form.code)
  flash[:messages] = ['登録できました']
  redirect_to home_new_path
rescue StandardError
  flash[:messages] = ['登録に失敗しました']
  redirect_to home_new_path
end

へと変更しました。

コミットはこちらです。
https://github.com/thinkAmi-sandbox/controller_filter_app/pull/1/commits/6b28d77e34c6bfcca5a70e5baefe3845b57fe58b

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/controller_filter_app

 
今回の一連の流れがまとまっているプルリクはこちらです。
https://github.com/thinkAmi-sandbox/controller_filter_app/pull/1

Rails6.1 + Devise 3.8.0 で、Deviseのデフォルトの挙動を確認してみた

Deviseを使うと認証まわりの機能をRailsに組み込むのが容易です。

ただ、「そういえばDeviseにある各モジュールのデフォルトの挙動はどんな感じだろう」と思ったため、調べた時のメモを残します。

 
目次

 

環境

  • Rails 6.1.3.2
  • Devise 3.8.0

 

検証するためのRails + Deviseアプリを作成

今回使用するモジュールは

  • database_authenticatable
  • registerable
  • recoverable
  • confirmable
  • validatable
  • lockable

です。

ただ、各モジュールの設定はデフォルトのままではなく、気になった設定はカスタマイズしていきます。

具体的には以下です。

  • 変更時にもconfirmableを動作させる
  • Deviseが送信するメール中のトークンを見やすくするため、DeviseのMailerとビューを差し替え
  • 動作確認をしやすいよう、以下の設定へと変更
    • 確認トークンの有効期限を1分
    • ログイン失敗は3回まで
    • ログイン失敗のロックは1分間
    • パスワードリセットで生成するトークンの有効期限は1分

 

アプリ作成

今回は rails_devise_default_app という名前でRailsアプリを作成します。

% rails new rails_devise_default_app

 

Gemfileの修正

各モジュールのViewについては必要最低限とするため、今回不要な webpaker をGemfileを削除します。

一方、今回検証する Devise と、Deviseが送信するメールをブラウザで確認するための Letter Opener を追加します。

# コメントアウト
# gem 'webpacker', '~> 5.0'

# 追加
gem 'devise'

group :development do
  gem 'letter_opener'
end

 
変更したので、インストールします。

% bundle install

 

RailsのControllerとViewの作成

Deviseでログインしている時の挙動を確認するため、

  • 誰でもアクセスできるページ
    • Controllerの index メソッドを使う
  • ログインしたユーザーのみアクセスできるページ
    • Controllerの show メソッドを使う

の2つを用意します *1

% bin/rails g controller home index show --helper=false --assets=false
Running via Spring preloader in process 40269
      create  app/controllers/home_controller.rb
       route  get 'home/index'
get 'home/show'
      invoke  erb
      create    app/views/home
      create    app/views/home/index.html.erb
      create    app/views/home/show.html.erb
      invoke  test_unit
      create    test/controllers/home_controller_test.rb

 
show だけログイン必須とするため、 Controller filter の before_action で Devise の authenticate_user!show メソッドのみ動作するよう定義します。
https://github.com/heartcombo/devise#controller-filters-and-helpers

class HomeController < ApplicationController
  before_action :authenticate_user!, only: ['show']

  def index
  end

  def show
  end
end

 
Viewのうち、index (app/views/home/index.html.erb) は誰でもアクセスできるページのため

<h1>Public Page</h1>

とします。

一方、 show (app/views/home/show.html.erb) はログイン済ユーザーのみアクセス可能なため、その旨をViewにも表示します。

<h1>Private Page</h1>
<p>only signed in users</p>

 
ただ、これだけだとログインしているユーザーが誰か分からないため、共通で使う layout (app/views/layouts/application.html.erb) でユーザー情報を表示させます。

なお、layoutには Webpacker のタグが記載されていたため、そちらは削除しておきます。

<head>
  ...
  <%# webpacker を使用していないため、不要なものを削除 %>
  <%#= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>

<body>
  <%# deviseを使ったログインができているかを確認しやすくするため、追加 %>
  <% if current_user.present? %>
    <p>ログイン済ユーザ: <%= current_user.email %></p>
  <% end %>
  <p class="notice"><%= notice %></p>
  <p class="alert"><%= alert %></p>
  <%# 追加ここまで %>

  <%= yield %>
</body>
</html>

 

Railsの起動するポートを変更

本来この設定を行う必要はないのですが、手元でRailsアプリが増えてしまっているので、app/config/puma.rb を修正し、今回のアプリが起動するポートを 3710 へと変更しておきます。

port ENV.fetch("PORT") { 3710 }

 

Action Mailerまわりの修正

設定ファイル config/environment/development.rb に対し、Action Mailerまわりについて、Railsの起動ポートと Letter Opener を考慮した形で、末尾に追記します。

# 追加
# メール内のリンクをRailsの起動ポートと合わせる
config.action_mailer.default_url_options = { host: 'localhost', port: 3710 }

# Letter Openerを使うように設定
config.action_mailer.delivery_method = :letter_opener

# Letter Openerで確認するため、実際のメール配信を行う
config.action_mailer.perform_deliveries = true

 

Deviseのジェネレータで初期化

bin/rails g devise:install を実行します。

% bin/rails g devise:install

Running via Spring preloader in process 94335
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml
===============================================================================

Depending on your application's configuration some manual setup may be required:

  1. Ensure you have defined default url options in your environments files. Here
     is an example of default_url_options appropriate for a development environment
     in config/environments/development.rb:

       config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

     In production, :host should be set to the actual host of your application.

     * Required for all applications. *

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root to: "home#index"
     
     * Not required for API-only Applications *

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

     * Not required for API-only Applications *

  4. You can copy Devise views (for customization) to your app by running:

       rails g devise:views
       
     * Not required *

 

Devise用の User Model を作成

Deviseのジェネレータを使って生成します。

% bin/rails g devise User
Running via Spring preloader in process 41318
      invoke  active_record
      create    db/migrate/20210605140140_devise_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
      insert    app/models/user.rb
       route  devise_for :users

 
続いて、User Model (app/models/user.rb) に Devise のモジュールを組み込みます。

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  # devise :database_authenticatable, :registerable,
  #        :recoverable, :rememberable, :validatable
  #
  # 変更
  devise :database_authenticatable, # 認証
         :registerable, # 登録・変更・削除
         :recoverable, # パスワードリセット
         :confirmable, # メールでの登録
         :validatable, # メールやパスワードのバリデーション
         :lockable # アカウントロック
end

 

マイグレーションファイルの編集

Deviseのジェネレータが生成したマイグレーションファイル (db/migrate/<タイムスタンプ>_devise_create_user.rb) に対して、以下の編集を行います。

  • 必要なDeviseモジュールの項目やIndexを有効化
  • Deviseとは関係ないフィールド name を用意
# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[6.1]
  def change
    create_table :users do |t|
      ## Database authenticatable
      t.string :email,              null: false, default: ""
      t.string :encrypted_password, null: false, default: ""

      ## Recoverable
      t.string   :reset_password_token
      t.datetime :reset_password_sent_at

      ## Rememberable
      # t.datetime :remember_created_at

      ## Trackable
      # t.integer  :sign_in_count, default: 0, null: false
      # t.datetime :current_sign_in_at
      # t.datetime :last_sign_in_at
      # t.string   :current_sign_in_ip
      # t.string   :last_sign_in_ip

      ## Confirmable
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.string   :unconfirmed_email # Only if using reconfirmable

      ## Lockable
      t.integer  :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts
      t.string   :unlock_token # Only if unlock strategy is :email or :both
      t.datetime :locked_at


      t.timestamps null: false

      # 追加した項目
      t.string :name,              null: false, default: ""
    end

    add_index :users, :email,                unique: true
    add_index :users, :reset_password_token, unique: true
    add_index :users, :confirmation_token,   unique: true
    add_index :users, :unlock_token,         unique: true
  end
end

 
マイグレーションファイルの編集が終わったら、マイグレーションを実行しておきます。

% bin/rails db:migrate

 

User向けにDeviseのViewを生成

今回使わないものが多いですが、DeviseのViewをジェネレータで生成しておきます。

% bin/rails g devise:views users
Running via Spring preloader in process 40901
      invoke  Devise::Generators::SharedViewsGenerator
      create    app/views/users/shared
      create    app/views/users/shared/_error_messages.html.erb
      create    app/views/users/shared/_links.html.erb
      invoke  form_for
      create    app/views/users/confirmations
      create    app/views/users/confirmations/new.html.erb
      create    app/views/users/passwords
      create    app/views/users/passwords/edit.html.erb
      create    app/views/users/passwords/new.html.erb
      create    app/views/users/registrations
      create    app/views/users/registrations/edit.html.erb
      create    app/views/users/registrations/new.html.erb
      create    app/views/users/sessions
      create    app/views/users/sessions/new.html.erb
      create    app/views/users/unlocks
      create    app/views/users/unlocks/new.html.erb
      invoke  erb
      create    app/views/users/mailer
      create    app/views/users/mailer/confirmation_instructions.html.erb
      create    app/views/users/mailer/email_changed.html.erb
      create    app/views/users/mailer/password_change.html.erb
      create    app/views/users/mailer/reset_password_instructions.html.erb
      create    app/views/users/mailer/unlock_instructions.html.erb

 

DeviseのMaierとメールテンプレートの差し替え

Deviseデフォルトのメールテンプレートには、Deviseが生成した各種トークンが記載されていません。

そこで、目視で確認しやすくするよう、メールのテンプレートを差し替えます。

また、差し替えたメールテンプレートを使うために、DeviseのMailerも差し替えます。

なお、前回の記事でふれた通り、Devise 4.8.0の時点ではWikiのやり方ではメールテンプレートが差し替わらないことに注意します。
Devise 4.8.0 でメールテンプレートを変更したい場合、カスタムメーラーの template_path ではなく headers_for をオーバーライドする - メモ的な思考的な

 
まずは、Deviseのジェネレータで生成したメールテンプレートを差し替えるため、ディレクトapp/views/users/mailer にあるテンプレート

  • confirmation_instructions.html.erb
  • reset_password_instructions.html.erb
  • unlock_instructions.html.erb

の各ファイルを開き、テンプレートの末尾にトークンを表示するよう

<p>token <%= @token %></p>

を追加します。

 
次に、Mailerを差し替えるため、 app/views/mailers/devise_my_mailer.rb として以下を作成します。

class DeviseMyMailer < Devise::Mailer
  def headers_for(action, opts)
    super.merge!(template_path: 'users/mailer')
  end
end

 
最後に、Devise向けの設定ファイル config/initializer/devise.rbconfig.mailer を変更します。

config.mailer = 'DeviseMyMailer'

 

User向けにDeviseのControllerを生成

今回はコントローラのカスタマイズをしませんが、参考までに生成しておきます。

% bin/rails generate devise:controllers users
Running via Spring preloader in process 41089
      create  app/controllers/users/confirmations_controller.rb
      create  app/controllers/users/passwords_controller.rb
      create  app/controllers/users/registrations_controller.rb
      create  app/controllers/users/sessions_controller.rb
      create  app/controllers/users/unlocks_controller.rb
      create  app/controllers/users/omniauth_callbacks_controller.rb
===============================================================================

Some setup you must do manually if you haven't yet:

  Ensure you have overridden routes for generated controllers in your routes.rb.
  For example:

    Rails.application.routes.draw do
      devise_for :users, controllers: {
        sessions: 'users/sessions'
      }
    end

 

追加したControllerやDeviseのルーティングを追加

config/routes.rb に各ルーティングを追加します。

なお、ルートルーティングはファイルの先頭に書いておきます。
3.14 rootを使う | Rails のルーティング - Railsガイド

Rails.application.routes.draw do
  root to: 'home#index'

  # Deviseのルーティング
  devise_for :users

  # ログインしていないと表示できないページ
  get '/private', to: 'home#show', as: 'private_page'
end

 

動作確認しやすくするよう設定ファイルの修正

動作確認しやすくするため、設定ファイル (config/initializers/devise.rb) を修正します。

# 確認トークンの有効期限
# config.confirm_within = 3.days
config.confirm_within = 1.minute

# ロック回数
# config.maximum_attempts = 20
config.maximum_attempts = 3

# ロック期間 
# config.unlock_in = 1.hour
config.unlock_in = 1.minute

# パスワード忘れの有効期限
# config.reset_password_within = 6.hours
config.reset_password_within = 1.minute

 
以上で、Deviseのデフォルトの挙動を確認するためのアプリができました。

引き続き、Deviseのデフォルトの挙動を見ていきます。スクリーンショット多めです。

 

新規ユーザー登録(サインアップ)について

流れ

今回は bar@example.com ユーザーを登録してみます。

新規ユーザー登録のURLは以下です。
http://localhost:3710/users/sign_up

 
メールとパスワードを入力し、sign upボタンをクリックします。

なお、Deviseデフォルトのテンプレートを修正しない場合、Modelに追加した項目 name は表示されません。

 
ボタンをクリック後、ルートパスに戻り、確認待ちのメッセージが表示されます。

 
一方、メールを確認すると、トークン付URLが記載されたメールが届いています。

 
トークン付URLのリンクをクリックすると、ユーザを認証できました。

また、この時点ではログインできていないため、ログインページへ移動します。

 
先ほど設定したメールアドレスとパスワードを入力すると、ログインできました。

 

ログイン済でのサインアップ・サインイン・パスワードリセットの挙動

ログイン済の状態で

の各ページへアクセスしたところ、ルートパスへリダイレクトしました。

 

メールアドレス変更

流れ

今回は bar@example.com から foo@example.com へメールアドレスを変更してみます。

Deviseのデフォルトでは、ユーザー情報の編集ページでメールアドレスを変更します。
http://localhost:3710/users/edit

 
変更後のメールアドレスと、現在のパスワードを入力し、Updateボタンをクリックします。

なお、Deviseデフォルトのテンプレートを修正しない場合、Modelに追加した項目 name は表示されません。

 
ボタンをクリック後、ルートパスに戻り、確認待ちのメッセージが表示されます。

 
一方、メールを確認すると、トークン付URLが記載されたメールが届いています。

 
メール中のトークン付URLのリンクをログイン中のブラウザで開くと、変更が完了します。ログイン状態も維持されます。

 

メールアドレス確認待ちでの挙動

変更後のメールアドレスが確認待ちの状態の場合、変更後のメールアドレスではログインできません。

 
また、ユーザー情報の編集ページを開くと、現在メールアドレスが変更中と表示されます。

 
ただし、現在メールアドレスが変更中であってもメールアドレスの変更は可能なため、トークン付URLのリンクが含まれるメールが送信されます。

 

変更後のメールアドレスに、変更前と同じメールアドレスを入力した時の挙動

こんな感じで入力します。

 
この場合、トークン付URLが記載されたメールは届かず、変更が完了したと表示されます。

 

パスワード変更について

パスワード変更は、ユーザー情報編集ページで行なえます。

新しいパスワードと現在のパスワードを入力します。

変更されました。ログイン状態は維持されます。

 

パスワードリセットについて

流れ

以下のURLからパスワードのリセットができます。
http://localhost:3710/users/password/new

   
今回、パスワードリセットする時の状態として、

  • ユーザー登録したもののメールアドレスの確認前の場合
  • ユーザー登録に加えメールアドレス確認が完了している場合
  • アカウントがロックされている場合

の3パターンを確認しましたが、いずれも挙動は同じでした。

 
まずはパスワードリセットの画面に、登録済のメールアドレスを入力します。

 
メールを確認すると、トークン付URLが記載されたメールが届いています。

 
画面にもメールを送ったと表示されます。

 
メールのリンクを踏むと、パスワードの再入力画面が表示されます。

 
新しいパスワードを入力し、Change my password ボタンをクリックすると、パスワードが変更されました。

また、ログインも完了しています。

 

パスワードリセットトークンの有効期限が切れた場合

新しいパスワードの入力画面にエラーメッセージが表示されます。

 

パスワードリセット中に、もう一度パスワードリセットを行った場合

新しいパスワードを再入力した際に、トークンが不正というエラーが表示されます。

 

パスワードリセット中に、今までのパスワードでログインしようとした場合

スクリーンショットでの表現は難しいため言葉だけとなりますが、ログインできます。

ただし、パスワードリセットのトークンが有効な間は、トークン付URLからパスワード変更が可能です。

 

アカウントロックについて

流れ

アカウントロックは、Deviseの lockable モジュールになります。

ログイン時にパスワード間違いを続け、ロックまであと1回の状態はこのような状態です。

 
デフォルトでは20回間違えるとアカウントがロックされます。

この後、正しいパスワードを入力したとしても、アカウントはロックされたままです。

 
アカウントがロックされたと同時に、メールアドレスにトークン付URLを含んだメールが届きます。

 
メールのURLをクリックすると、ロックを解除され、ログインできるようになります。

 
正しいメールアドレスとパスワードを入力すると、ログインできました。

 

メール再送について

Deviseでは、メールアドレスの所有者確認やパスワード忘れのメール再送も可能です。

 

メールアドレス確認のメールを再送する場合

Resend confirmation instructions にて再送可能です。
http://localhost:3710/users/confirmation/new

 
サインアップ時のメールアドレス確認の場合でも再送が可能です。

この場合、トークンは同じになります。

1回目

2回目

 

ロック解除メールを再送する場合

こちらは Resend unlock instructions になります。
http://localhost:3710/users/unlock/new

 
メールアドレスの確認とは異なり、再送時のトークンは異なります。

1回目

2回目

 

気になるところ

今まで見てきたとおり、Deviseには色々実装されています。

一方で、気になった部分を書いておきます。

 

別ブラウザでもトークン付URLを踏んだら認証できること

Chromeでユーザ登録を行ってトークン付URLを含むメールを受信した後、トークン付URLをEdgeへコピーしてアクセスしたところ、ユーザの認証ができました。

「同一セッションで認証する」などの制限は行っておらず、トークン付URLを知っていれば誰でも認証できるようです。

既存メールアドレスの存在が分かってしまうこと

デフォルトの状態では、そのアプリケーションにメールアドレスが登録されているかどうかが判断できる作りとなっていました。

 

スクリーンショット
サインアップ時で既存メールアドレスを入力した時

 

メールアドレス変更で既存メールアドレスを入力した時

 

パスワード忘れで、メールアドレスが存在しない時

 

トークン付URLを踏む前のメールアドレスを、サインインで入力した時

 

メールアドレス確認の再送画面で、既に認証済のメールを入力した時

 

メールアドレス確認の再送画面で、メールアドレスが存在しない時

 

ロック解除再送で、ロックしていない時

 

ロック解除再送で、メールアドレスが存在していない時

 

Deviseの paranoid モードを使った対応

Deviseでもこの挙動は認識されています。

DeviseのWiki では paranoid mode を使うと良いと記載されています。
How To: Using paranoid mode, avoid user enumeration on registerable · heartcombo/devise Wiki

 
config/initializer/devise.rb の paranoidtrue とすることで、paranoid modeになります。

config.paranoid = true

 
そこで、paranoid mode を有効化した後の挙動を確認していました。

 

paranoid modeでメールアドレスの存在が分からなくなったケース

サインイン前でも使用可能な画面の

  • パスワード忘れで、メールアドレスが存在しない時
  • メールアドレス確認の再送画面で、既に認証済のメールを入力した時
  • メールアドレス確認の再送画面で、メールアドレスが存在しない時
  • ロック解除再送で、ロックしていない時
  • ロック解除再送で、メールアドレスが存在していない時

については、エラーメッセージが変わり、サインイン画面へと遷移しています。

 

変更なし:サインアップ時で既存のメールアドレスを入力した場合

Wikiにある通り、サインアップ時は変わりません。

 

変更なし:メールアドレス変更で既存のメールアドレスを入力した場合

ログイン済で操作できる画面のせいか、エラーは変わりませんでした。

 

変更なし:トークン付URLを踏む前のメールアドレスを、サインインで入力した時

 

メールアドレス変更で他人のメールアドレスを誤入力すると、他人がアカウントを奪える

例えば、以下の記事にあるような操作をした場合です。
観点2: ユーザが間違って第三者の連絡手段を入力してしまった場合、第三者に個人情報が流出してしまう | Webサービスによくある各機能の仕様とセキュリティ観点(ユーザ登録機能) - Qiita

 

流れ

メールアドレス変更時に、誤ったメールアドレスと正しいパスワードを入力すると、誤ったメールアドレスの持ち主にメールが届きます。

そこで、トークン付URLを踏むと、誤ったメールアドレスへアカウントのメールアドレスが変更されました。しかし、この時点では未ログイン状態です。

 
続いて、パスワードリセット画面へ移動して、メールアドレスを入力します。

 
リセットURL付きのメールがメールアドレスに届きます。

 
続いて、パスワードリセットのURLをクリックします。

 
新しいパスワードを入力します。

 
パスワード変更が完了し、ログインできました。これで、誤ったメールアドレスの持ち主がアカウントを奪えました。

 

徳丸本での記載

徳丸本 第2版 p502 の「メールアドレス変更に必要な機能的対策」によると、

  • 新規メールアドレスによる受信確認
  • 再認証
  • メール通知

このうち、新規メールアドレスによる受信確認を徳丸本 p497 にある方式B (確認番号を入力する方式) にすれば、誤ったメールアドレスを入力しても問題なさそうです。

以下のさきほど挙げた記事でも、似たような内容がふれられています。
Webサービスによくある各機能の仕様とセキュリティ観点(ユーザ登録機能) - Qiita

 
ただ、Deviseの標準機能では、パスワードリセットを確認番号入力化する機能は見当たりませんでした。

そこで、他のWebフレームワークでの実装はあるのか気になりTweetしたところ、徳丸先生に拾っていただけました。ありがとうございます。

 
もし、実装しているWebフレームワークをご存じの方がいれば、教えていただけるとありがたいです。

 

Deviseでの対応 (通知機能)

さて、標準のDeviseではパスワードリセットを確認番号化できないものの、通知機能はあります。
Notify users via email when their passwords change · heartcombo/devise Wiki

上記Wikiにはパスワード変更のみの記載されていますが、Devise 4.8.0 時点ではメールアドレス変更での通知にも対応しています。

 
そこで、devise.rbの以下の設定をアンコメントした上でRailsを再起動し、挙動を確認してみます。

# Send a notification to the original email when the user's email is changed.
config.send_email_changed_notification = true

# Send a notification email when the user's password is changed.
config.send_password_change_notification = true

 

ユーザー情報編集画面の場合

 
ユーザー情報の編集画面では、メールアドレスとパスワードを変更できます。

今回はメールアドレスとパスワードを同時に変更します。

 
変更後、ルートに戻ります。

 
新しいメールアドレスにトークン付URLメールが飛びます。

 
また、メールアドレス変更の通知も飛びます。

トークン付URLをクリックしてメールアドレスの確認をする前ですが通知が飛ぶようです。

 
他に、パスワード変更の通知も飛びます。

 

パスワードリセット画面の場合

パスワードリセットでメールアドレスを入力します。

 
トークン付URLメールが届きますので、パスワードを変更します。

 
変更後、パスワード変更のメールが届きます。

 

参考:Deviseのカスタマイズについて

ここまでDevise標準の機能を見てきました。

もし、Deviseの標準以外の機能を使いたい場合は、自分でカスタマイズすることになります。

カスタマイズ方法については、以下が詳しいです。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/devise_default_app

*1:DeviseではURLが sign_in となっているものの、Viewでは Login と表示されています。そのため、サインインとログインのどちらを使えばよいか迷いましたが、今回は「ログイン」を使います

Devise 4.8.0 でメールテンプレートを変更したい場合、カスタムメーラーの template_path ではなく headers_for をオーバーライドする

Rails + Deviseにて、Devise標準のメール受信確認用メールテンプレートを差し替えたいことがありました。

 
そこで Devise の Wiki How To: Use custom mailer · heartcombo/devise Wiki に従い、

% bin/rails g devise:views users
...
      create    app/views/users/mailer/confirmation_instructions.html.erb

と View を生成した後、 confirmation_instructions.html.erb

<p>Welcome <%= @email %>!</p>

<p>You can confirm your account email through the link below:</p>

<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>

<%# 以下を追加 %>
<p>token => <%= @token %></p>

と変更した上、カスタムメーラー app/mailers/devise_my_mailer.rb

class DeviseMyMailer < Devise::Mailer
  default template_path: 'users/mailer'
end

と定義し、合わせて config/initializer/devise.rb

Devise.setup do |config|
# ...
  config.mailer = 'DeviseMyMailer'
# ...
end

と編集しました。

 
その後、confirmableモジュールを有効にしたRails + Deviseを起動し、ユーザー登録時のメール受信確認を行ったところ、到着したメールには

と、追加したはずのトークンの記載がありませんでした。 template_path で設定したテンプレートではなく Devise 標準のメールテンプレートが使われているように見えました。

そこで調べた時のメモを残します。

 

環境

  • Rails 6.1.3.2
  • Devise 4.8.0

 

対応

issueがありました。
Unable to use custom views for emails · Issue #4842 · heartcombo/devise

また、以下の記事にも解説がありました。
週刊Railsウォッチ(20180820)Railsで構築されたサイト40選、Deviseはつらいよ、ARのスコープとクラスメソッドの使い分けほか|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社

 
そこで、issueに従ってカスタムメーラー app/mailers/devise_my_mailer.rb

class DeviseMyMailer < Devise::Mailer
  # これでは動かない
  # https://github.com/heartcombo/devise/issues/4842
  # default template_path: 'users/mailer'

  def headers_for(action, opts)
    super.merge!(template_path: 'users/mailer')
  end
end

として、再度ユーザー登録時のメール受信確認を行ったところ、到着したメールは想定通り

となっていました。

 
現時点では template_path ではなく headers_for をオーバーライドすると良いようです。

Rails 6.1 + Devise 4.8.0 で、ルーティングから一部のdeviseモジュールの URI Pattern を削除する

Rails + Deviseでは、ModelにDeviseのモジュールを組み込むと、自動的に routes へ URI Pattern が追加されます。

例えば、

Rails.application.routes.draw do
  root to: 'home#index'

  devise_for :users
end

な routes.rb の時、DeviseのModelが

class User < ApplicationRecord
  devise :database_authenticatable,
         :registerable,
         :recoverable,
         # :confirmable,
         :validatable,
         :lockable
end

な場合、

% bin/rails routes
...
    user_registration PATCH  /users(.:format)
                      PUT    /users(.:format)
                      DELETE /users(.:format)
                      POST   /users(.:format)
      new_user_unlock GET    /users/unlock/new(.:format)
          user_unlock GET    /users/unlock(.:format)

となります。

この状態で、Deviseの Confirmableコメントアウトを外し

class User < ApplicationRecord
  devise :database_authenticatable,
         :registerable,
         :recoverable,
         :confirmable,
         :validatable,
         :lockable
end

とすると

% bin/rails routes
...
    user_registration PATCH  /users(.:format)
                      PUT    /users(.:format)
                      DELETE /users(.:format)
                      POST   /users(.:format)
new_user_confirmation GET    /users/confirmation/new(.:format)  # 追加
    user_confirmation GET    /users/confirmation(.:format)  # 追加
                      POST   /users/confirmation(.:format)  # 追加
      new_user_unlock GET    /users/unlock/new(.:format)
          user_unlock GET    /users/unlock(.:format)

になります。

 
ただ、

  • Confirmable モジュールは使いたい
  • URI Pattern を追加したくない

の場合はどうすればよいか迷ったため、メモを残します。

 

目次

 

環境

  • Rails 6.1.3.2
  • Devise 3.8.0

 

対応

routes.rb の devise_forskip を使います。
ruby on rails 3 - How do I remove the Devise route to sign up? - Stack Overflow

 
例えば、Modelが

class User < ApplicationRecord
  devise :database_authenticatable,
         :registerable,
         :recoverable,
         :confirmable,
         :validatable,
         :lockable
end

の時に、routes.rbを

Rails.application.routes.draw do
  root to: 'home#index'

  devise_for :users, 
             skip: ['confirmations']  # 追加
end

とすると、

% bin/rails routes
...
    user_registration PATCH  /users(.:format)
                      PUT    /users(.:format)
                      DELETE /users(.:format)
                      POST   /users(.:format)
      new_user_unlock GET    /users/unlock/new(.:format)
          user_unlock GET    /users/unlock(.:format)

confirmableURI Patternがなくなりました。

Rails6.1で、セッションをキャッシュとは別のmemcachedへ保存する

Rails6.1でセッションをキャッシュとは別のmemcachedへ保存しようとした時、色々調べたことをメモに残します。

 
目次

 

環境

 

デフォルトのセッションストレージ

まずは、デフォルトのセッションストレージ CookieStore を確認してみます。
2.3 セッションストレージ | Rails セキュリティガイド - Railsガイド

 

Railsアプリの作成

Railsアプリをゼロから作成してきます。今回は rails_dalli_sample とします。

% rails new rails_dalli_sample

 
今回の動作確認ではwebpacker使いません。そのため、Gemfileでコメントアウトしておきます。

source 'https://rubygems.org'
...
# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker
#gem 'webpacker', '~> 5.0'  # コメントアウト

 
また、Railsでの memcached クライアントは Dalli gem のため、Gemfileに追加します。
petergoldstein/dalli: High performance memcached client for Ruby

gem 'dalli'

 
改めて bundle install します。

% bundle install

 
続いて、ControllerとViewを生成します。

今回は home Controller に index メソッドをもたせます。

% bin/rails g controller home index --helper=false --assets=false
Running via Spring preloader in process 21510
      create  app/controllers/home_controller.rb
       route  get 'home/index'
      invoke  erb
       exist    app/views/home
      create    app/views/home/index.html.erb
      invoke  test_unit
      create    test/controllers/home_controller_test.rb

 
作成したControllerの index メソッドで、セッションに値を入れます。
5.1 セッションにアクセスする | Action Controller の概要 - Railsガイド

今回は key を foo 、値を bar とします。

class HomeController < ApplicationController
  def index
    session[:foo] = 'bar'
  end
end

 
Viewは自動生成のものを流用します。

ただ、今回はwebpackerを使わないため、layoutファイル (app/views/layouts/application.html.erb) から javascript_pack_tag タグの部分をコメントアウトしておきます。

<%#= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

 
また、これは任意ですが、手元のRailsが複数ある場合はRailsの起動ポートを変えておきます。
rails s 時のデフォルトのポート番号を変更する - Qiita

# config/puma.rb
port ENV.fetch("PORT") { 3700 }

 

動作確認

準備ができたので、Railsを起動します。

% bin/rails s
=> Booting Puma
=> Rails 6.1.3.2 application starting in development 
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 5.3.2 (ruby 3.0.1-p64) ("Sweetnighter")
*  Min threads: 5
*  Max threads: 5
*  Environment: development
*          PID: 21698
* Listening on http://127.0.0.1:3700
* Listening on http://[::1]:3700
Use Ctrl-C to stop

 
ブラウザで http://localhost:3700/home/index へアクセスすると、Cookieに暗号化された値が保存されています。

f:id:thinkAmi:20210605143809p:plain

 

キャッシュの保存先を変更するための準備

セッションの保存先を変える前に、まずはRailsのキャッシュの保存先をmemcachedへと移動してみます。

 

フラグメントキャッシュを使うために erb を修正

今回はフラグメントキャッシュを有効化してみます。
Rails のキャッシュ機構 - Railsガイド Ruby on Rails のフラグメントキャッシュのキャッシュキーはどのように決まるか | by TAGAWA Takao | traveloco-tech | Medium

フラグメントキャッシュとして保存するよう、 View ( app/views/home/index.html.erb ) に追記します。

<% cache 'my_cache' do %>
<h1>Home#index</h1>
<p>Find me in app/views/home/index.html.erb</p>
<% end %>

 

環境ごとのDB設定を追加

今回は色々なパターンを確認することから、いくつもの環境設定を用意します。

DBは使わないものの、環境に応じた設定がされていないとエラーになることから、以下の設定を config/database.yml の末尾へ追加します。

cache:
  <<: *default
  database: db/development.sqlite3

cache_port:
  <<: *default
  database: db/development.sqlite3

memd_session:
  <<: *default
  database: db/development.sqlite3

memd_session_port:
  <<: *default
  database: db/development.sqlite3

session_cache_store:
  <<: *default
  database: db/development.sqlite3

 

docker composeによる memcached を用意

次に、memcachedを用意します。

今回は docker compose を使って memcached を3つたてます。

service名 mac上のポート 用途
default 11211 デフォルトポートで起動するmemcached
cache 17001 Cache用memcached
session 17002 Session用memcached

 
docker-compose.ymlはこんな感じです。

version: "3"
services:
  default:
    image: memcached
    ports:
      - 11211:11211
  cache:
    image: memcached
    ports:
      - 17001:11211
  session:
    image: memcached
    ports:
      - 17002:11211

 
docker compose で memcached を起動しておきます。
Compose CLI Tech Preview | Docker Documentation

% docker compose up -d

 

memcached の中身を確認するPythonスクリプトを作成

memcachedの中身の確認ですが、RubyMineではさくっとできなかったため、別の方法を探してみました。

Pythonのライブラリを探したところ

を組み合わせれば良さそうでした。

そこで、コマンドライン引数としてポートを渡せば中身を確認できるようなスクリプト

import sys
from pymemcache.client.base import Client
from memcached_stats import MemcachedStats

if __name__ == '__main__':
    port = sys.argv[1]
    mem = MemcachedStats('localhost', port)

    client = Client(f'localhost:{port}')
    for key in mem.keys():
        print(client.gets(key))

を作りました。

これで準備は完了です。

 

デフォルトポートの memcached へキャッシュを保存

環境ファイルの作成

まずは、docker compose 上にある、デフォルトポートの memcached へキャッシュを保存してみます。

今回の記事では色々なパターンを試すことから、 config/environments ディレクトリの中に、各パターンの環境を作成します。
3.20 Rails環境を作成する | Rails アプリケーションを設定する - Railsガイド

今回は development.rb をコピーし、各パターンの環境を作成します。

ここでは、 cache.rb を作成し、デフォルトポートの memcached へキャッシュを保存する設定を行います。

まずは既存の設定

# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join('tmp', 'caching-dev.txt').exist?
  config.action_controller.perform_caching = true
  config.action_controller.enable_fragment_cache_logging = true

  config.cache_store = :memory_store
  config.public_file_server.headers = {
    'Cache-Control' => "public, max-age=#{2.days.to_i}"
  }
else
  config.action_controller.perform_caching = false

  config.cache_store = :null_store
end

を削除します。

次に、デフォルトポートの memcached を使うよう設定します。
2.5 ActiveSupport::Cache::MemCacheStore | Rails のキャッシュ機構 - Railsガイド

config.cache_store = :mem_cache_store

 

動作確認

準備ができたので、 環境 cache を指定して起動します。
1.2 rails server | Rails のコマンドラインツール - Railsガイド

% bin/rails s -e cache

 
ブラウザで http://localhost:3700/home/index へアクセスした後、 memcached の状態をPythonスクリプトで確認します。

ポート 11211 のmemcached のみ、データが格納されていました。

% python display_memcached.py 11211
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x171622552901.4518201:\x10@expires_in0', b'1')
...

 
次の確認を行う前に、すべての memcached を再起動し、Pythonスクリプトでデータがないことを確認しておきます。

なお、以降の確認後も同じ作業を実施するものとします。

 

キャッシュをキャッシュ用memcachedへと移動

続いて、キャッシュをキャッシュ用memcachedへと移動します。

環境ファイルの作成

ホストとポートを指定するには、 config.cache_store の設定を追加すれば良さそうです。

キャッシュの初期化時には、クラスタ内の全memcachedサーバーのアドレスを指定する必要があります。指定がない場合、memcachedがローカルのデフォルトポートで動作していると仮定して起動しますが、この設定は大規模サイトには向いていません。

Rails のキャッシュ機構 - Railsガイド

 
そのため、上記で作成した cache.rb をコピーした cache_port.rb ファイルをenvironments の中に用意し、以下の設定に書き換えます。

config.cache_store = :mem_cache_store, 'localhost:17001'

 

動作確認

環境を指定して起動します。

% bin/rails s -e cache_port

 
ブラウザで http://localhost:3700/home/index へアクセスした後、 memcached の状態をPythonスクリプトで確認すると、ポート 17001 のmemcachedにキャッシュが保存されていました。

# 空
% python display_memcached.py 11211

# 指定したポートの memcached にキャッシュが保存
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554358.104258:\x10@expires_in0', b'3')

# こちらも空
% python display_memcached.py 17002

 

セッションストレージをデフォルトのmemcachedへ移動

Rails6.1では dalli_store を指定しても動作しない

キャッシュの移動ができたため、次はセッションストレージを移動します。

設定方法については、DalliのWikiに記載がありました。
Caching with Rails · petergoldstein/dalli Wiki

 
そこで、memd_session.rb という環境ファイルを用意し

config.session_store = :dalli_store, 'localhost:11211'

と設定します。

続いて、

% bin/rails s -e memd_session

とした後、ブラウザでアクセスしてみましたが、セッションストレージはCookie Storeのままでした。

 
調べてみたところ、Rails 5.2でワーニングが出るようになり、:dalli_store から :mem_cache_store へと変更されたようです。

 

Rails6.1では mem_cache_store を指定する

memd_session.rb を修正します。

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを memcached へ変更
config.session_store :mem_cache_store   # << 変更箇所

 

動作確認

再度 bin/rails s -e memd_session で起動し、ブラウザで http://localhost:3700/home/index へアクセスします。

Cookieのキー _rails_dalli_sample_session の代わりに _session_id がありました。また、値もセッションIDだけになっています。

 
続いて、memcachedの値を確認します。

フラグメントキャッシュは、指定通り 17001 ポートにありました。

% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622554618.821114:\x10@expires_in0', b'5')
...

 
一方、セッションの値はデフォルトポートのmemcachedに入っていました。

\x07I"\x08foo\x06:\x06EFI"\x08bar\x06; のようにして、 foo=bar なセッションの値が確認できました。

% python display_memcached.py 11211
(b'\x04\x08{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x00TI"\x10_csrf_token\x06;\x00FI"1D7iFliPuRIrsP1D1GfYM0lr8WhM3xKdaPWDRod9Uhs0=\x06;\x00F', b'6')
...

 

セッションストレージをsession用のmemcachedへ移動

コードを読んで、設定方法を調査

ポートを指定する方法がRailsガイドでは分かりませんでした。

そこで、Dalliの公式Wikiにあったように、

config.session_store :mem_cache_store, 'localhost:17002'

と、第2引数にホストとポートを指定して起動してみたところ

`session_store': wrong number of arguments (given 2, expected 0..1) (ArgumentError)

というエラーになりました。

 
そこでRailsガイドのセッションストレージの記載

ActionDispatch::Session::MemCacheStore :データをmemcachedクラスタに保存する (この実装は古いのでCacheStoreを検討すべき)

5 セッション | Action Controller の概要 - Railsガイド

より、 ActionDispatch::Session::MemCacheStore の実装を見ると

# https://github.com/rails/rails/blob/v6.1.3.2/actionpack/lib/action_dispatch/middleware/session/mem_cache_store.rb#L17

class MemCacheStore < Rack::Session::Dalli
# ...

となっていました。

次に Rack::Session::Dalli の実装を見てみると

# https://github.com/petergoldstein/dalli/blob/v2.7.11/lib/rack/session/dalli.rb#L10

module Rack
  module Session
    class Dalli < defined?(Abstract::Persisted) ? Abstract::Persisted : Abstract::ID
      attr_reader :pool, :mutex

      DEFAULT_DALLI_OPTIONS = {
        :namespace => 'rack:session',
        :memcache_server => 'localhost:11211'
      }
# ...

と、オプション :memcache_server としてセッション用のmemcachedを渡せそうでした。

 

設定

調査結果をもとに、環境ファイル memd_session_port.rb

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを memcached へ変更し、ポートも指定する
config.session_store :mem_cache_store, memcache_server: 'localhost:17002'

と設定しました。

 

動作確認

上記で作成した環境ファイルを指定して

% bin/rails s -e memd_session_port

と起動すると

  • キャッシュはキャッシュ用memcached (17001ポート)
  • セッションはセッション用memcached (17002ポート)

に保存されていました。

# 無い
% python display_memcached.py 11211

# Cache用
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x151622644680.48053:\x10@expires_in0', b'1')
...

# セッション用
% python display_memcached.py 17002
(b'\x04\x08{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x00TI"\x10_csrf_token\x06;\x00FI"1IseQM7XUAmDJwXYDllu0OwpS8DpoLCTRjATi8kIzOGE=\x06;\x00F', b'2')
...

 
想定通りの設定ができたようです。

 

キャッシュとセッションを同じ memcached へ保存する

当初の目的 セッションをキャッシュとは別のmemcachedへ保存する は達成したものの、先ほど見たRailsガイド

ActionDispatch::Session::MemCacheStore :データをmemcachedクラスタに保存する (この実装は古いのでCacheStoreを検討すべき)

5 セッション | Action Controller の概要 - Railsガイド

この実装は古いのでCacheStoreを検討すべき が気になりました。

また、「Action Controller の概要」にも

ユーザーセッションに重要なデータが含まれていない場合、またはユーザーセッションを長期間保存する必要がない場合 (flashメッセージで使いたいだけの場合など) は、ActionDispatch::Session::CacheStoreを検討してください。この方式では、Webアプリケーションに設定されているキャッシュ実装を利用してセッションを保存します。この方法のよい点は、既存のキャッシュインフラをそのまま利用してセッションを保存できることと、管理用の設定を追加する必要がないことです。この方法の欠点はセッションが短命になり、セッションがいつでも消える可能性がある点です。

5 セッション | Action Controller の概要 - Railsガイド

とありました。

そこで CacheStore も試してみます。

 

設定

Railsガイドによると、

config.session_store: セッションの保存に使うクラスを指定します。指定できる値は:cookie_store(デフォルト)、:mem_cache_store、:disabledです。:disabledを指定すると、Railsでセッションが扱われなくなります。デフォルトでは、アプリケーション名と同じ名前のcookieストアがセッションキーとして使われます。カスタムセッションストアを指定することもできます。

config.session_store :my_custom_store

カスタムストアはActionDispatch::Session::MyCustomStoreとして定義する必要があります。

3.1 Rails全般の設定 | Rails アプリケーションを設定する - Railsガイド

とありました。

 
ActionDispatch::Session::CacheStore はすでに存在していることから、Railsガイドに従って環境ファイル session_cache_store.rb

# 指定したポートにある memcached へCacheを保存
config.cache_store = :mem_cache_store, 'localhost:17001'

# セッションストアを "ActionDispatch::Session::CacheStore" にする
config.session_store :cache_store

# ちなみに、以下の書き方でも動作した
# config.session_store ActionDispatch::Session::CacheStore

に、キャメルケースをスネークケースに変換したシンボル :cache_store で指定します。

 

動作確認

上記で作成した環境ファイルを指定して

% bin/rails s -e session_cache_store

と起動してアクセス後に確認したところ、

# 無い
% python display_memcached.py 11211

# キャッシュとセッションが同居
% python display_memcached.py 17001
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@value{\x07I"\x08foo\x06:\x06EFI"\x08bar\x06;\x07TI"\x10_csrf_token\x06;\x07FI"1-z6lVxxcPeSrD6sBI0LsfxUWlmsIVr7ybExtJix2uX8=\x06;\x07F:\r@version0:\x10@created_atf\x161622879435.064419:\x10@expires_in0', b'2')
...
(b'\x04\x08o: ActiveSupport::Cache::Entry\t:\x0b@valueI"I<h1>Home#index</h1>\n<p>Find me in app/views/home/index.html.erb</p>\n\x06:\x06ET:\r@versionI"\x00\x06;\x07F:\x10@created_atf\x161622879435.008329:\x10@expires_in0', b'1')
...

# 無い
% python display_memcached.py 17002

と、キャッシュとセッションが同居していました。

 
ちなみに、

config.cache_store = :file_store, Rails.root.join('tmp', 'cache', 'files')
config.session_store :cache_store

とすると、キャッシュとはファイルが別だったものの、セッションもファイルストレージに保存されていました。

こんな感じです。

^D^Ho: ActiveSupport::Cache::Entry      :^K@value{^GI"^Hfoo^F:^FEFI"^Hbar^F;^GTI"^P_csrf_token^F;^GFI"1Va3rVZwaQqxekgbKJHpN6dyA5JEyFtrbrO9kWqmOdfs=^F;^GF:^M@version0:^P@created_atf^W1622870283.7916899:^P@expires_in0

 

ソースコード

Githubに上げました。
thinkAmi-sandbox/rails_session_of_memcached-sample