こんにちは。Enjoy IT Life管理人の@nishina555です。
Railsにはコールバックという便利な仕組みがありますが、多用しすぎるとコードの見通しが悪くなり、バグの温床になることがあります。
この記事では、コールバックの多用がなぜ問題になるのか、どのように解消すべきか、そしてコールバックが適している場面はどこかをまとめます。
コールバック多用のアンチパターン
よく見られるアンチパターンとして、インスタンス変数をセットするためにbefore_actionを多用するケースがあります。
一つ一つが独立していればまだマシですが、複数のbefore_actionが依存し合っている場合はロジックの把握が困難になります。機能追加の際にバグを混入させる原因にもなりますし、各アクションで何がインスタンス変数としてセットされるのかを読み解くことも難しくなります。
解決方法
事前準備系コールバック(before_action)の解消
インスタンス変数のセットにはbefore_actionを使わず、各アクション内で直接セットするのが基本方針です。
特に、メソッドに分割したくなるほどインスタンス変数のセットが複雑な場合は、コールバックを利用しないようにしましょう。
副作用系コールバック(after_create等)の解消
after_createのような副作用を伴うコールバックの解消には、大きく分けて2つのアプローチがあります。
アプローチ1: コールバックの処理を別クラスに分割する
コールバックに書いていた処理を、専用のクラスに切り出す方法です。
具体的には、run、call、executeなどのコマンドを実行するメソッドが1つだけ公開されている**コマンドクラス(PORO: Plain Old Ruby Object)**を作成します。複数のモデルが関わるような複雑な操作が必要になった場合は、それぞれ適切なコマンドクラスに分割します。
コントローラーでコールバック対象のインスタンスを引数にサービスクラスのインスタンスを生成し、サービスクラス内でトリガーおよび後続処理を実行する形です。
アプローチ2: pub/subパターン(ドメインイベント)で実現する
コールバックを使うとモデル自体に副作用が埋め込まれてしまいますが、ドメインイベントを使えば副作用の内容(メールを送る、外部サービスを更新する等)をモデルが意識する必要がなくなり、疎結合な設計になります。
「Domain Event」をパブリッシュし、イベントに対して応答するハンドラをサブスクライブするという構成です。Railsでpub/subを実現するgemとしては以下のようなものがあります。
- wisper
- rails_event_store — データベースへの保存やログ出力にも対応
- dry-events
コールバックが適している場面
コールバックがすべて悪いわけではありません。以下のような場面ではコールバックの利用が適切です。
認証・認可
認証・認可はコントローラー全体で利用するため、before_actionでの実装が適しています。ただし、onlyやexceptオプションを使い出したら「設計が間違っているかも」と疑いましょう。onlyやexceptが必要な場合は、コールバックをやめるか、コントローラーの分割を検討します。
ネストしたルーティングの親リソースのセット
親となるリソースのセットはコントローラー全体で行うため、こちらもbefore_actionが適しています。認証・認可と同様にonly・exceptが必要になったら設計を見直すサインです。
「連鎖がない」「単独で完結する」アクション
漏れなく実行されることを期待するアクションもコールバック向きです。具体例としてはバリデーション、ログ出力、メールやチャットへの通知などがあります。
ただし、「テストの時は実行されたくない」「バッチの時は実行されたくない」のように発火条件があるのであれば、コールバックで定義するのは適切ではありません。
コールバックの実行を制御する方法
どうしてもコールバックを使う場合は、実行有無を制御するアトリビュートを用意し、デフォルトをfalseにすることでコールバックの誤爆を防げます。
class User < ApplicationRecord
attr_accessor :can_send_mail
after_create :send_confirm_to_user, if: :can_send_mail?
private
def can_send_mail?
can_send_mail.present?
end
def send_confirm_to_user
UserMailer.signup_confirmation(to: mail).deliver
end
end
別の例として、条件メソッドを使ってコールバックの発火を制御するパターンもあります。
class Message < ApplicationRecord
has_many :mentions
belongs_to :creator, class_name: 'User'
belongs_to :channel
after_create :create_here_mention, if: :here?
after_create :create_channel_mention, if: :channel?
def create_here_mention
HereMentionCreator.call(message: self)
end
def create_channel_mention
ChannelMentionCreator.call(message: self)
end
end
まとめ
before_actionでのインスタンス変数セットの多用は避け、各アクション内で直接セットするafter_create等の副作用系コールバックは、コマンドクラスへの切り出しやpub/subパターンで解消する- 認証・認可やバリデーションなど、コントローラー全体で漏れなく実行したい処理にはコールバックが適している
- コールバックを使う場合は、実行制御用のアトリビュートを用意して誤爆を防ぐ
参考
- Railsアプリケーションの実装で気をつけている8つのこと
- ActiveSupport::Callbackを使うのをやめろ
- 続・ActiveSupport::Callbackを使うのをやめろ
- Railsで大規模アプリケーションを正しく設計するために避けるべき3つの機能
- てめえらのRailsはオブジェクト指向じゃねえ!まずはCallbackクラス、Validatorクラスを活用しろ!
- Railsのモデルのコールバックをどうにかしたい
- 7 Patterns to Refactor Fat ActiveRecord Models
- Fat Modelの倒し方についてまとめてみた
- ドメインイベントを使ったコールバックのリファクタリング