この記事は?
- Rails のコールバックに ActiveSupport::Subscriber を使用する方法を記載します。
- この方法により以下のメリットがあります。
- コールバックの呼び出し元と呼び出し先をより疎結合にすることができる。
- 単体テスト時などコールバックの処理を動かしたくない場合にスキップしやすい。
- この方法により以下のメリットがあります。
- ruby-jp Slack の #support チャンネルの @wakaba260 さんの投稿でこの方法を知りました。ありがとうございます
バージョン情報
$ ruby -v
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin21]
$ bin/rails -v
Rails 7.0.3.1
方法
以下のモデルがあるとします。Post モデルは作成時と更新時にコールバックを定義しています。
class User < ApplicationRecord
end
class Post < ApplicationRecord
after_create :notify_of_creating
after_update :notify_of_updating
belongs_to :user
private
def notify_of_creating
puts("#{user.name}さんが「#{title}」を投稿したよ 🚀")
end
def notify_of_updating
puts("#{user.name}さんが「#{title}」を更新したよ 🚀")
end
end
user = User.find_or_create_by!(name: '佐倉杏子')
#=> #<User:0x000000010a8902f0 ...>
# コールバックの処理により、標準出力にメッセージが出力される。
post = Post.create!(user: user, title: 'あんた、マジなんだな?', body: 'ほげほげ')
# 佐倉杏子さんが「あんた、マジなんだな?」を投稿したよ 🚀
#=> #<Post:0x00000001063d0160 ...>
post.update!(user: user, title: 'あんた、マジなんだな?', body: 'ふがふが')
# 佐倉杏子さんが「あんた、マジなんだな?」を更新したよ 🚀
#=> true
ここでコールバックをスキップしたい場合は以下のように行います。
Post.skip_callback(:create, :after, :notify_of_creating)
#=> [Post(id: integer, user_id: integer, title: string, body: text, created_at: datetime, updated_at: datetime)]
Post.skip_callback(:update, :after, :notify_of_updating)
#=> [Post(id: integer, user_id: integer, title: string, body: text, created_at: datetime, updated_at: datetime)]
個人的な感想ですが、skip_callback はインターフェイスが分かりづらいですよね また、モデルがどのようなタイミングと名前のコールバックを実装しているかを呼び出し側が詳しく知っている必要があります。
そこで ActiveSupport::Subscriber を使用します。具体的には app/subscribers
というディレクトリを新規作成して、そこに ActiveSupport::Subscriber を継承したクラスを用意します。
まず app/subscribers
ディレクトリを Rails の自動読み込みの対象にします。
config.eager_load_paths << Rails.root.join('app/subscribers')
次に ActiveSupport::Subscriber を継承したクラスを用意します。
class PostSubscriber < ActiveSupport::Subscriber
attach_to :post
def after_create(event)
post = event.payload.fetch(:post)
puts("#{post.user.name}さんが「#{post.title}」を投稿したよ 🚀")
end
def after_update(event)
post = event.payload.fetch(:post)
puts("#{post.user.name}さんが「#{post.title}」を更新したよ 🚀")
end
end
このとき、attach_to メソッドの引数が名前空間、メソッド名がイベント名に相当します。
最後にモデルのコールバックで PostSubscriber が購読しているイベントを発行するようにします。発行には ActiveSupport::Notifications.instrument メソッドを使用します。
# 遅延読み込み (development 環境の場合など) でも PostSubscriber が読み込まれるようにする。
# PostSubscriber を読み込んだ時点で ActiveSupport::Subscriber.subscribers に登録され、イベントの購読が可能になる。
require Rails.root.join('app/subscribers/post_subscriber')
class Post < ApplicationRecord
after_create :notify_of_creating
after_update :notify_of_updating
belongs_to :user
private
def notify_of_creating
ActiveSupport::Notifications.instrument('after_create.post', post: self)
end
def notify_of_updating
ActiveSupport::Notifications.instrument('after_update.post', post: self)
end
end
このとき instrument メソッドには イベント名.名前空間
という形式の文字列を渡します。これで先程と同じ挙動を実現できます。
user = User.find_or_create_by!(name: '佐倉杏子')
#=> #<User:0x000000010a73ba30 ...>
post = Post.create!(user: user, title: 'あんた、マジなんだな?', body: 'ほげほげ')
# 佐倉杏子さんが「あんた、マジなんだな?」を投稿したよ 🚀
#=> #<Post:0x00000001063d0160 ...>
post.update!(user: user, title: 'あんた、マジなんだな?', body: 'ふがふが')
# 佐倉杏子さんが「あんた、マジなんだな?」を更新したよ 🚀
#=> true
なお、PostSubscriber の各メソッドの引数 event には ActiveSupport::Notifications::Event オブジェクトが渡されます。そして ActiveSupport::Notifications::Event#payload で発行側でハッシュ形式で渡した値を取得することができます。
# PostSubscriber より
def after_create(event)
post = event.payload.fetch(:post)
puts("#{post.user.name}さんが「#{post.title}」を投稿したよ 🚀")
end
コールバックの処理を実行したくない場合は、skip_callback メソッドを使ってコールバックをスキップする代わりに detach_from メソッドを使ってイベントの購読をやめます。
user = User.find_or_create_by!(name: '佐倉杏子')
#=> #<User:0x000000010a7836f0 ...>
post = Post.create!(user: user, title: 'あんた、マジなんだな?', body: 'ほげほげ')
# 佐倉杏子さんが「あんた、マジなんだな?」を投稿したよ 🚀
#=> #<Post:0x000000010a903b38 ...>
post.update!(user: user, title: 'あんた、マジなんだな?', body: 'ふがふが')
# 佐倉杏子さんが「あんた、マジなんだな?」を更新したよ 🚀
#=> true
PostSubscriber.detach_from(:post)
#=> nil
post = Post.create!(user: user, title: 'あんた、マジなんだな?', body: 'ほげほげ')
#=> #<Post:0x0000000109af25f8 ...>
post.update!(user: user, title: 'あんた、マジなんだな?', body: 'ふがふが')
#=> true
# 再び購読することも可能。
PostSubscriber.attach_to(:post)
#=> [:after_update, :after_create]
post = Post.create!(user: user, title: 'あんた、マジなんだな?', body: 'ほげほげ')
# 佐倉杏子さんが「あんた、マジなんだな?」を投稿したよ 🚀
#=> #<Post:0x000000010a63bdd8 ...>
post.update!(user: user, title: 'あんた、マジなんだな?', body: 'ふがふが')
# 佐倉杏子さんが「あんた、マジなんだな?」を更新したよ 🚀
#=> true
個別に購読をやめることはできません。しかし、detach_from メソッドを呼び出す側が PostSubscriber がどのようなイベントを購読しているかやどのようなメソッドを持っているかを知る必要がないのはありがたいです。