Rails + Active Adminで、Active Admin Controllerのcreate/update/destroyをオーバーライドする

Railsでは、Active Adminを使うことで管理者画面を容易に作ることができます。
Active Admin | The administration framework for Ruby on Rails

 
そんな中、Active AdminでModelの作成・更新・削除を行うと同時に、Modelとは関係ないAPIを呼ぶ処理を追加したくなったため、調べた時のメモを残します。

 
目次

 

環境

  • Rails 6.1.4
  • Active Admin 2.9.0
  • Devise 4.8.0
    • Active Adminの管理ページをログイン必須にするために使用

 

ベースとなるActive Adminアプリの作成

Active Adminのセットアップ

まずは、追加する前のActive Adminアプリを作成します。

今回はRails6.1系のため、assets generatorにWebpackerを使ったアプリを作成します。

 
rails new します。

% rails new rails_with_active_admin_app

 
Gemfileにactive adminとdeviseを追加して、 bundle install します。

gem 'activeadmin'
gem 'devise'

 
このまま起動してもWebpackerまわりでエラーになるため、Webpackerのセットアップを行います。

% bin/rails webpacker:install
      create  config/webpacker.yml
Copying webpack core config
      create  config/webpack
      create  config/webpack/development.js
      create  config/webpack/environment.js
      create  config/webpack/production.js
      create  config/webpack/test.js
Copying postcss.config.js to app root directory
      create  postcss.config.js
Copying babel.config.js to app root directory
      create  babel.config.js
Copying .browserslistrc to app root directory
      create  .browserslistrc
The JavaScript app source directory already exists
       apply  path/to/rails_with_active_admin_app/vendor/bundle/ruby/3.0.0/gems/webpacker-5.4.0/lib/install/binstubs.rb
  Copying binstubs
       exist    bin
      create    bin/webpack
      create    bin/webpack-dev-server
      append  .gitignore
Installing all JavaScript dependencies [5.4.0]
         run  yarn add @rails/webpacker@5.4.0 from "."
...
✨  Done in 6.49s.
Webpacker successfully installed 🎉 🍰

 
Active Adminの初期化を行います。

Webpackerはオプトインのため、 use_webpacker オプションを追加してWebpackerを使うようにします。
webpacker | Installation | Active Admin | The administration framework for Ruby on Rails

% bin/rails g active_admin:install --use_webpacker
Running via Spring preloader in process 11165
      create  config/initializers/active_admin.rb
      create  app/admin
      create  app/admin/dashboard.rb
       route  ActiveAdmin.routes(self)
    generate  active_admin:webpacker
       rails  generate active_admin:webpacker 
Running via Spring preloader in process 11173
      create  app/javascript/packs/active_admin.js
      create  app/javascript/stylesheets/active_admin.scss
      create  app/javascript/packs/active_admin/print.scss
      create  config/webpack/plugins/jquery.js
      insert  config/webpack/environment.js
      insert  config/webpack/environment.js
         run  yarn add @activeadmin/activeadmin from "."
yarn add v1.22.10
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 4 new dependencies.
info Direct dependencies
└─ @activeadmin/activeadmin@2.9.0
info All dependencies
├─ @activeadmin/activeadmin@2.9.0
├─ jquery-ui@1.12.1
├─ jquery-ujs@1.2.3
└─ jquery@3.6.0
✨  Done in 6.09s.
      create  db/migrate/20210715221110_create_active_admin_comments.rb

 
ちなみに、今回はDeviseを使っているため関係ないですが、Deviseを使っていない場合は --skip-users オプションを追加してActiveAdminを初期化します。

bin/rails g active_admin:install --use_webpacker --skip-users

 
続いてDBまわりのセットアップを行います。
Setting up Active Admin | Active Admin | The administration framework for Ruby on Rails

% bin/rails db:migrate

% bin/rails db:seed

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

% bin/rails s

この状態で http://localhost:3000/admin にアクセスし、以下の情報でログインできればOKです。

 

Modelを作成

今回は以下の項目を持つ Fruit モデルを用意します。

論理名 物理名 制約
名前 name string unique
color string -
販売開始日時 start_of_sales datetime -

 
Model Fruit を作成します。

% bin/rails g model Fruit name:string:unique color:string start_of_sales:datetime

 
マイグレーションします。

% bin/rails db:migrate

 

Active Adminで管理できるようにする

Modelができたため、次はActive Adminで管理できるようにします。

app/admin/fruit.rb を作成します。

今回、Active Adminの画面では

  • name
  • color

のみ編集可能とするため、 permit_params を使って指定します。
Setting up Strong Parameters | Working with Resources | Active Admin | The administration framework for Ruby on Rails

ActiveAdmin.register Fruit do
  permit_params :name, :color
end

これで、Controller Admin::Fruit が生成されます。
Content rendering API · activeadmin/activeadmin Wiki

 

動作確認

http://localhost:3000/admin/fruits/new にアクセスします。

color は color pickerになっています。もし color pickerを使いたくない場合は、以下のようにしてカスタマイズできそうです。
ruby on rails - ActiveAdmin: how to have a text field instead a color picker to input a color? - Stack Overflow

また、permit_paramsで指定していない start_of_sales も表示されています。

 
入力して作成します。

 
作成した後です。 start_of_sales は入力したはずですが、登録されていません。

 

Controllerのcreate/update/destroyをオーバーライドする

本題になります。APIを呼ぶ処理を追加するために、Controllerのメソッドをオーバーライドしていきます。

まず、上で見たController Admin::Fruit の親の定義を見たところ、以下にありました。
https://github.com/activeadmin/activeadmin/blob/v2.9.0/lib/active_admin/base_controller.rb#L7

module ActiveAdmin
  # BaseController for ActiveAdmin.
  # It implements ActiveAdmin controllers core features.
  class BaseController < ::InheritedResources::Base

 
さらに、 InheritedResources::Base は、別のgem inherited_resources にて定義してあります。
https://github.com/activeadmin/inherited_resources/blob/v1.13.0/app/controllers/inherited_resources/base.rb#L11

さらにこの中でincludeされているのが InheritedResources:: Actions です。
https://github.com/activeadmin/inherited_resources/blob/v1.13.0/lib/inherited_resources/actions.rb

そのため、 InheritedResources:: Actions の各メソッドをオーバーライドすることで、APIを呼ぶ処理を追加できそうです。

 
オーバーライドは controller メソッドのブロックに定義します。

 
今回は inherited_resources でのオーバーライド方法にならい、 super do な形にしてみます。
Overwriting actions | activeadmin/inherited_resources

ActiveAdmin.register Fruit do
  permit_params :name, :color

  # コントローラのcreate/update/destroyをオーバーライド
  controller do
    def create
      super do |format|
        call_api(:create)
      end
    end

    def update
      super do |format|
        call_api(:update)
      end
    end

    def destroy
      super do |format|
        call_api(:destroy)
      end
    end

    private

    def call_api(method)
      # 外部APIを呼んだつもり
      # (今回はログに出力する)
      logger.info("======> called api by #{method}")
    end
  end
end

 

動作確認

Fruitに対して作成・更新・削除を行ったところ、以下のようなログが出力されていました。

メソッドをオーバーライドできました。

# 作成
Started POST "/admin/fruits" for 127.0.0.1 at 2021-07-17 21:34:04 +0900
...
  Fruit Create (1.3ms)  INSERT INTO "fruits" ("name", "color", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["name", "みかん"], ["color", "#f2ad18"], ["created_at", "2021-07-17 12:34:04.759054"], ["updated_at", "2021-07-17 12:34:04.759054"]]
  ↳ app/admin/fruit.rb:7:in `create'
  TRANSACTION (1.3ms)  commit transaction
  ↳ app/admin/fruit.rb:7:in `create'
======> called api by create


# 更新
Started PATCH "/admin/fruits/9" for 127.0.0.1 at 2021-07-17 21:34:13 +0900
...
  Fruit Update (0.5ms)  UPDATE "fruits" SET "name" = ?, "updated_at" = ? WHERE "fruits"."id" = ?  [["name", "夏みかん"], ["updated_at", "2021-07-17 12:34:13.249457"], ["id", 9]]
  ↳ app/admin/fruit.rb:13:in `update'
  TRANSACTION (0.9ms)  commit transaction
  ↳ app/admin/fruit.rb:13:in `update'
======> called api by update


# 削除
Started DELETE "/admin/fruits/9" for 127.0.0.1 at 2021-07-17 21:34:16 +0900
Processing by Admin::FruitsController#destroy as HTML
...
  ↳ app/admin/fruit.rb:19:in `destroy'
======> called api by destroy
Redirected to http://localhost:3000/admin/fruits

 

ソースコード

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

 
関係するプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_with_active_admin_app/pull/1

Railsで、同一のbefore_actionを別オプションで複数定義した場合、最後の定義が有効になる

Railsbefore_action などのフィルタでは、 only などのオプションを追加することで対象のActionを制限できます。
8 フィルタ | Action Controller の概要 - Railsガイド

 
そんな中、同一の before_action を別オプションで複数定義してしまったことがあったため、挙動をメモしておきます。

 
目次

 

環境

 

Controllerの場合

Controllerで before_action を実装する時、

class BeforeActionFilterController < ApplicationController
  before_action :set_template_value, except: [:index3]
  before_action :set_template_value
  before_action :set_template_value, only: [:index3]

  def index1
  end

  def index2
  end

  def index3
  end

  private

  def set_template_value
    @template_value = 'foo'
  end
end

と定義してしまったとします。

この場合は、 index3 のみ before_action が動作します。

index1の場合

index2の場合

index3の場合

 
この挙動は

同じフィルタを異なるオプションで複数回呼び出しても期待どおりに動作しません。最後に呼び出されたフィルタ定義によって、それまでのフィルタ定義は上書きされます。

8 フィルタ | Action Controller の概要 - Railsガイド

のためです。

 

Mailerの場合

Mailerにも before_action などのコールバックがあります。

こちらも挙動は同じです。

そのため、

class BeforeActionCallbackMailer < ApplicationMailer
  before_action :set_template_value, except: [:welcome3]
  before_action :set_template_value
  before_action :set_template_value, only: [:welcome3]

  def welcome1
    mail(to: 'welcome1@example.com')
  end

  def welcome2
    mail(to: 'welcome2@example.com')
  end

  def welcome3
    mail(to: 'welcome3@example.com')
  end

  private

  def set_template_value
    @template_value = 'bar'
  end
end

というMailerを使い

class BeforeActionFilterController < ApplicationController
  def send_mail
    BeforeActionCallbackMailer.welcome1.deliver_now
    BeforeActionCallbackMailer.welcome2.deliver_now
    BeforeActionCallbackMailer.welcome3.deliver_now
  end

のようにメールを送信したとします。

この場合も、 welcome3 のみ before_action が動作しテンプレートに値が渡されます。

 
welcome1の場合

welcome2の場合

welcome3の場合

 

ソースコード

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

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_miscellaneous_app/pull/1

Rails + 名前空間付きのActionMailerを使う場合に、件名をi18nのロケールファイルで指定してみた

Railsガイドによると、Action Mailer の件名は

mailメソッドに件名が渡されなかった場合、Action Mailerは既存の訳文の利用を試みます。「<mailer_scope>.<action_name>.subject」というパターンでキーが構築されます。

4.6 Action Mailerメールの件名を訳文に置き換える | Rails 国際化 (i18n) API - Railsガイド

となります。

ただ、

module Foo
  class BarMailer < ApplicationMailer
# ...

のような名前空間付きのAction Mailerの場合、ロケールファイルではどのように指定すればよいか迷ったためメモを残します。

 

目次

 

環境

 

名前空間付きのAction Mailerに対するロケールファイルの書き方

例えば

module Foo
  class BarMailer < ApplicationMailer
    def hello
      mail(to: 'baz@example.com')
    end
  end
end

というAction Mailerに hello というメソッドがあり、Controllerで

class HomeController < ApplicationController
  def index
    Foo::BarMailer.hello.deliver_now
  end
end

のように呼ぶとします。

 
この場合は、 config/locales/en.yml などのロケールファイルに

en:
  hello: "Hello world"
  foo:
    bar_mailer:
      hello:
        subject: My Subject

と、module Foo に対して1階層 foo を用意します。

 
ちなみに、Controllerでの呼び出しでは

::Foo::BarMailer.hello.deliver_now

でも良いです。

先頭の :: については、以下が参考になりました。

 
さらに、以降も余談を続けます。

 

Mailerのメソッドがネストしている場合

例えば、Action Mailerが

module Foo
  class BarMailer < ApplicationMailer
    def hey
      send_mail
    end

    def send_mail
      mail(to: 'send_mail@example.com')
    end
  end
end

と、 hey メソッドの中で別のpublicなメソッド send_mail を呼び出しているとします。

また、Controllerでは

class HomeController < ApplicationController
  def index
    Foo::BarMailer.hey.deliver_now
  end
end

hey メソッドを呼ぶとします。

 
この時、件名に対応するロケールファイルのキーは

  • hey
  • send_mail

のどちらになるか迷いました。

 
試してみたところ、ロケールファイルでは

en:
  hello: "Hello world"
  foo:
    bar_mailer:
      hey:
        subject: Hey subject

と、Controllerで呼ぶ時のメソッド名 hey をキーとして作れば良さそうです。

 

Mailerに対するロケールファイルのキーが存在しない場合

例えば

module Foo
  class BarMailer < ApplicationMailer
    def no_subject
      mail(to: 'no_subject@example.com')
    end
  end
end

というメソッド no_subject があるものの、ロケールファイルにキーが存在しない場合はどうなるか気になりました。

 
この時はメソッドの名前が件名に使われるようです。

 

ソースコード

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

Railsで、DeviseのMailerにControllerから値を渡す方法を調べてみた

Deviseの

  • recoverable
  • confirmable
  • lockable

の各モジュールでは、各種確認のためにメールを送信します。

メール送信時に使われるのが Devise::Mailer にある reset_password_instructions などのメソッドです。
https://github.com/heartcombo/devise/blob/master/app/mailers/devise/mailer.rb

 
ただ、それらのメソッドはControllerから直接呼ばれていません。

例えば、 recoverable モジュールによるパスワードリセットメールの送信では

No モジュール メソッドの種類 メソッド コード
1 PasswordsController インスタンスメソッド create ここ
2 recoverable クラスメソッド send_reset_password_instructions ここ
3 recoverable インスタンスメソッド send_reset_password_instructions ここ
4 recoverable protected インスタンスメソッド send_reset_password_instructions_notification ここ
5 authenticatable protected インスタンスメソッド send_devise_notification ここ

を経て、Devise::Mailerの reset_password_instructions が呼ばれます (ここ)。

そのため、Controllerから直接値を渡すのは難しそうでした。

 
そこで、Controllerから値を渡すにはどうしたらよいかを、同僚に教わりつつ調べた時のメモを残します。

 
目次

 

環境

  • Rails 6.1.4
  • Devise 4.8.0

 
また、今回使用するRails + Deviseアプリは、以下のコードのmainブランチのものです。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer

このアプリに対し、 recoverable モジュールを使った時にControllerから値を渡す方法を調べていきます。

なお、今回は recoverable モジュールを例にしますが、confirmablelockable モジュールでも同じ方法が取れそうです。

 

Deviseのモンキーパッチ + 各メソッドに引数を追加した実装

StackOverflowに回答がありました。
ruby on rails - How to pass additional data to devise mailer? - Stack Overflow

initializerでDeviseにモンキーパッチし、Controllerの値を引数として渡せるようにメソッドをオーバーライドすれば良いようです。

ただ、モンキーパッチやメソッド変更の影響で、将来のDeviseのバージョンアップがしづらくなる可能性もあったため、他の方法も考えてみました。

 

Deviseのモンキーパッチ + Modelのattr_accessorを追加した実装

上記のStackOverflowにおいて、 send_reset_password_instructions メソッドのオーバーライドは

Devise::Models::Recoverable::ClassMethods
  # extract data from attributes hash and pass it to the next method
  def send_reset_password_instructions(attributes = {})
    # ここでControllerからの値を取り出す
    data = attributes.delete :data
    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)

    # ここで引数に渡す
    recoverable.send_reset_password_instructions(data) if recoverable.persisted?
    recoverable

という実装でした。

変数 recoverable には何が入るか調べようとメソッド find_or_initialize_with_errors を見たところ、Modelのインスタンスでした (このあたり)。

であれば、Modelに attr_accessor を追加することにより、各メソッドにおける引数の追加が不要になりそうだったので、試してみることにしました。

 

attr_accessorの追加

Modelに attr_accessor を追加します。

class User < ApplicationRecord
  devise :database_authenticatable, :recoverable, :validatable

  attr_accessor :controller_value  # 追加
end

 

Deviseのモンキーパッチ

次に、Deviseのモンキーパッチを以下のように修正します。

# config/initializers/devise_monkey_patch.rb

module DeviseMonkeyPatch
  def send_reset_password_instructions(attributes = {})
    # attributesより、Controllerから渡された値を取り出す
    controller_value = attributes.delete :controller_value

    recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)

    if recoverable.persisted?
      # attr_accessorとして用意した項目に入れる
      recoverable.controller_value = controller_value

      recoverable.send_reset_password_instructions
    end

    recoverable
  end
end


Devise::Models::Recoverable::ClassMethods.module_eval do
  prepend DeviseMonkeyPatch
end

 
ここで、 ClassMethod.module_eval については

です。

 
また、 prepend DeviseMonkeyPatch については

です。

 

Mailerの準備

続いて、Devise::Mailerを継承したMailerを用意し、メールの件名や本文にControllerの値を設定できるようにします。

# app/mailers/my_devise_mailer.rb

class MyDeviseMailer < Devise::Mailer
  def reset_password_instructions(record, token, opts = {})
    # attr_accessorに設定したControllerの値を取り出し、テンプレートで使えるようインスタンス変数にセット
    @controller_value = record.controller_value

    super(record, token, opts)
  end

  protected

  def subject_for(key)
    # 件名の先頭に、インスタンス変数に入れた controller_value をセット
    subject = super(key)
    "[#{@controller_value}] #{subject}"
  end
end

 
このMailerをDeviseで使うように差し替えます。

# config/initializers/devise.rb

config.mailer = 'MyDeviseMailer'

 

Controllerの用意

また、 Devise::PasswordsController を継承したControllerを用意し、値を渡します。

class Users::PasswordsController < Devise::PasswordsController
  def create
    params[:user][:controller_value] = 'from controller'
    super
  end

 

routeの設定

最後に、routeで差し替えたControllerを使うように設定します。

# config/routes.rb

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

 
これで完成です。

 

ここまでのソースコード

ソースコード全体はこちらのプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/1/files

 

Modelのattr_accessorのみで実装

上記で、メソッドの引数追加は避けられたものの、それでもモンキーパッチが残っているのが気になりました。

そこで、もう少しDeviseのソースコードを見たところ、

# https://github.com/heartcombo/devise/blob/v4.8.0/lib/devise/models/recoverable.rb#L135

def send_reset_password_instructions(attributes = {})
  recoverable = find_or_initialize_with_errors(reset_password_keys, attributes, :not_found)
  recoverable.send_reset_password_instructions if recoverable.persisted?
  recoverable
end

のように、メソッド find_or_initialize_with_errors がメールを送信するメソッドの中ではいつも呼ばれていることに気づきました。

メソッド find_or_initialize_with_errors を見たところ、引数を元にModelのインスタンスを作っているようでした( このあたり)。

 

find_or_initialize_with_errorsのオーバーライド

そこで、 find_or_initialize_with_errors をオーバーライドし、attr_accessorに値を設定します。

def self.find_or_initialize_with_errors(required_attributes, attributes, error = :invalid)
  # 値を取り出す
  controller_value = attributes.delete(:controller_value)

  user = super(required_attributes, attributes, error)
  user.tap do |u|
    # 値を設定する
    u.controller_value = controller_value
  end
end

 
これでControllerの値の取り出しとModelのattr_accessorへの値設定ができたので、同じような処理をしていたモンキーパッチは不要となりました。

 

ここまでのソースコード

ソースコード全体はこちらのプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/2

 

Modelのattr_accessor + Mailerのparamsによる、メールの非同期配信に対応した実装

話が変わりますが、ここまでの実装ではDeviseからのメールは同期配信となっていました。

しかし、メールの配信は非同期で行いたい場合もあります。

その場合、Deviseの authenticatable モジュールのコメントのように実装することで、Deviseからのメール送信をActive Jobを使って非同期配信できるようになります。
https://github.com/heartcombo/devise/blob/v4.8.0/lib/devise/models/authenticatable.rb#L137

 
ただ、ここで問題になるのが、 attr_accessor です。

Active JobでModelを扱う場合、GlobalIDにシリアライズされるため、 attr_accessor に設定されている値が消えてしまいます。
ruby on rails - attr_accessor not accessible in mailer when using deliver_later - Stack Overflow

そのため、先ほどの実装のままでは、メール送信を非同期にしたとたんControllerからの値が失われてしまいます。

 
そこで、非同期に変更してもMailerに値を渡せるよう、呼び出し側で with を使って値を渡し、Mailerの中で params を使って受け取る実装へと変更します。

なお、 params については、Railsガイドより引用します。

with に渡されるキーの値は、メイラーアクションでは単なる params になります。つまり、 with(user: @user, account: @user.account) とすることでメイラーアクションで params[:user]params[:account] を使えるようになります。ちょうどコントローラのparamsと同じ要領です。

https://railsguides.jp/action_mailer_basics.html#%E3%83%A1%E3%82%A4%E3%83%A9%E3%83%BC%E3%82%92%E5%91%BC%E3%81%B3%E5%87%BA%E3%81%99

 

Active JobのバックエンドとしてDelayed Jobを使う

まずはActive Jobを使えるようにします。今回バックエンドは Delayed Job にします。
collectiveidea/delayed_job: Database based asynchronous priority queue system -- Extracted from Shopify

 
Gemfileに追加して bundle install した後、初期設定を行います。

# 初期設定
% bin/rails generate delayed_job:active_record
Running via Spring preloader in process 56881
      create  bin/delayed_job
       chmod  bin/delayed_job
      create  db/migrate/20210707145116_create_delayed_jobs.rb

# マイグレーション
% bin/rake db:migrate
Running via Spring preloader in process 57186
== 20210707145116 CreateDelayedJobs: migrating ================================
-- create_table(:delayed_jobs)
   -> 0.0023s
-- add_index(:delayed_jobs, [:priority, :run_at], {:name=>"delayed_jobs_priority"})
   -> 0.0008s
== 20210707145116 CreateDelayedJobs: migrated (0.0032s) =======================

 
また、Active JobのバックエンドとしてDelayed Jobを使えるよう設定します。

# config/environments/development.rb

config.active_job.queue_adapter = :delayed_job

 

Deviseからのメールを非同期化する

続いて、Deviseからのメールを非同期化します。

なお、Modelにそのまま書いてもよいのですが、今回はConcernに切り出します。

Concernに切り出すために変える部分としては

です。  

# app/models/concerns/devise_deliver_later.rb

module DeviseDeliverLater
  extend ActiveSupport::Concern

  included do
    after_commit :send_pending_devise_notifications
  end
# ...

 
あとは、ModelにConcernをincludeします。

class User < ApplicationRecord
  devise :database_authenticatable, :recoverable, :validatable

  # 追加
  include DeviseDeliverLater

  attr_accessor :controller_value

 

paramsにControllerからの値を渡す

先ほどのConcernに対し、修正を加えます。

render_and_send_devise_message メソッドの中でメールを送信しているため、

def render_and_send_devise_message(notification, *args)
  message = devise_mailer.with(controller_value: controller_value).send(notification, self, *args)

のように with(controller_value: controller_value) を使うことで、Controllerの値をMailerに渡せます。

なお、引数として渡している controller_value は Model の attr_accessor です。この時点の attr_accessor にはControllerの値が設定されています。

 

Controllerの修正

Deviseのコメントにある通り、一連のリクエストをトランザクションで囲う必要があります。

今回はControllerの create メソッドにトランザクションを実装します。

def create
  ApplicationRecord.transaction do
    params[:user][:controller_value] = 'from controller'
    super
  end

 
これで、メール送信を非同期化しつつ、Controllerからの値を渡せるようになっています。

 

ここまでのソースコード

以下のプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/3

なお、このプルリクでマージする先は main ではなく、非同期化していない Modelのattr_accessorのみで実装 のブランチです。

 

Mailer全体でControllerからの値を使えるようにする実装

ここまででは、DeviseのMailerだけでControllerの値を使えるようにしてきました。

ただ、1回のHTTPリクエストで

  • DeviseのMailerによる通知
  • 独自のカスタムMailerによる通知

の両方で、同じControllerからの値を引き渡す方法についても調べてみました。

 

Devise::Mailerの親をApplicationMailerにする

Devise::Mailer の実装を見ると、親は ActionMailer::Base となっています。

 
このままではMailerでの共通化がしづらいため、Devise::Mailer の親をカスタムMailerと同じ ApplicationMailer へと変更します。
ruby on rails - How do I configure devise to use a custom email layout? - Stack Overflow

# config/initializers/devise.rb

config.parent_mailer = 'ApplicationMailer'

 

before_actionを使って、paramsをインスタンス変数へ設定

続いて、paramsとして渡されたControllerの値をApplicationMailerのインスタンス変数として設定するよう、 before_action を使って実装します。
4 Action Mailerのコールバック | Action Mailer の基礎 - Railsガイド

また、メールの件名にControllerの値を設定できるよう、メソッド add_prefix も作っておきます。

class ApplicationMailer < ActionMailer::Base
  default from: 'from@example.com'
  layout 'mailer'

  before_action :set_controller_value, if: -> { params.present? }

  protected

  def add_prefix(subject)
    "[#{@controller_value}] #{subject}"
  end

  private

  def set_controller_value
    @controller_value = params&.fetch(:controller_value)
  end
end

 

DeviseMailerの不要な実装を削除

params からControllerの値を取り出してインスタンス変数に設定する」という処理がApplicationMailerに移動したため、不要になった実装をDeviseMailerから削除します。

# この実装を削除
def reset_password_instructions(record, token, opts = {})
  @controller_value = params&.fetch(:controller_value)

  super(record, token, opts)
end

 

独自のカスタムMailerによる通知を追加

Mailerを追加します。

# app/mailers/my_custom_mailer.rb

class MyCustomMailer < ApplicationMailer
  def notify_manager
    mail(to: params[:email], subject: add_prefix('hello'))
  end
end 

また、ここでは省略しますが、テンプレートも追加・修正します。

 

ControllerにカスタムMailerの処理を追加

ControllerにカスタムMailerを使ってメールを送信する処理を追加します。

class Users::PasswordsController < Devise::PasswordsController
  def create
    ApplicationRecord.transaction do
      params[:user][:controller_value] = 'from controller'
      super

      # 以下を追加
      MyCustomMailer.with(email: 'manager@example.com', controller_value: 'from controller').notify_manager.deliver_later
    end
  end
# ...

 
以上で、Mailer全体でControllerからの値を使えるようにする実装となりました。

 

ここまでのソースコード

以下のプルリクになります。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer/pull/4

 

ソースコード

今までの文中にもありましたが、Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_devise_pass_controller_value_to_mailer

各パターンでプルリクを分けています。

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