Rails のコールバックに ActiveSupport::Subscriber を使用する

# この記事は？

- Rails のコールバックに [ActiveSupport::Subscriber](https://api.rubyonrails.org/classes/ActiveSupport/Subscriber.html) を使用する方法を記載します。
  - この方法により以下のメリットがあります。
    - コールバックの呼び出し元と呼び出し先をより疎結合にすることができる。
    - 単体テスト時などコールバックの処理を動かしたくない場合にスキップしやすい。
- [ruby-jp](https://ruby-jp.github.io/) Slack の #support チャンネルの @wakaba260 さんの投稿でこの方法を知りました。ありがとうございます :pray: 

# バージョン情報

```shell
$ ruby -v
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin21]

$ bin/rails -v
Rails 7.0.3.1
```

# 方法

以下のモデルがあるとします。Post モデルは作成時と更新時にコールバックを定義しています。

```app/models/user.rb
class User < ApplicationRecord
end
```

```app/models/post.rb
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
```

```rb
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
```

ここでコールバックをスキップしたい場合は以下のように行います。

```rb
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 はインターフェイスが分かりづらいですよね :sweat: また、モデルがどのようなタイミングと名前のコールバックを実装しているかを呼び出し側が詳しく知っている必要があります。

そこで ActiveSupport::Subscriber を使用します。具体的には `app/subscribers` というディレクトリを新規作成して、そこに ActiveSupport::Subscriber を継承したクラスを用意します。

まず `app/subscribers` ディレクトリを Rails の自動読み込みの対象にします。

```config/application.rb
config.eager_load_paths << Rails.root.join('app/subscribers')
```

次に ActiveSupport::Subscriber を継承したクラスを用意します。

```app/subscribers/post_subscriber.rb
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](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html#method-c-instrument) メソッドを使用します。

```app/models/post.rb
# 遅延読み込み (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 メソッドには `イベント名.名前空間` という形式の文字列を渡します。これで先程と同じ挙動を実現できます。

```rb
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](https://api.rubyonrails.org/classes/ActiveSupport/Notifications/Event.html) オブジェクトが渡されます。そして ActiveSupport::Notifications::Event#payload で発行側でハッシュ形式で渡した値を取得することができます。

```rb
# PostSubscriber より
def after_create(event)
  post = event.payload.fetch(:post)

  puts("#{post.user.name}さんが「#{post.title}」を投稿したよ 🚀")
end
```

コールバックの処理を実行したくない場合は、skip_callback メソッドを使ってコールバックをスキップする代わりに detach_from メソッドを使ってイベントの購読をやめます。

```rb
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 がどのようなイベントを購読しているかやどのようなメソッドを持っているかを知る必要がないのはありがたいです。

# 参考

- Rails ガイド
  - [Active Support の Instrumentation 機能 - Railsガイド](https://railsguides.jp/active_support_instrumentation.html)
- Ruby on Rails API
  - [ActiveSupport::Callbacks::ClassMethods](https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html)
  - [ActiveSupport::Subscriber](https://api.rubyonrails.org/classes/ActiveSupport/Subscriber.html) 
  - [ActiveSupport::Notifications](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html)
  - [ActiveSupport::Notifications::Event](https://api.rubyonrails.org/classes/ActiveSupport/Notifications/Event.html)