はじめに
この記事は、Railsアプリで使われるデザインパターンを解説するシリーズの第4回です。
第1回から読むことをお勧めします。
| 記事 | 何を解決するか |
|---|---|
| 記事1: Value Object | ロジックの分散 |
| 記事2: Validator Object + Form Object | インラインバリデーション |
| 記事3: Query Object + Service Object | インラインクエリ・集計ロジック |
| 記事4: Adapter + ActiveJob + Pub/Sub | 通知のハードコード・同期処理 |
| 記事5: Workflow + Command | ファットコントローラ・トランザクション管理 |
| 記事6: Policy Object + Concern + Callback | 認可・共通スコープ・監査ログ |
| 記事7: Presenter + ViewComponent | モデルへの表示ロジック混入 |
Adapter + ActiveJob + Pub/Sub パターン
対応PR:
概要
この記事では3つのパターンを扱います。
| パターン | 解決する問題 |
|---|---|
| Adapter |
case/when による通知チャネル分岐をなくす |
| ActiveJob | 通知処理を非同期化してレスポンス時間を短縮 |
| Pub/Sub | 処理フローとロギング・監視を疎結合にする |
問題:アンチパターン
main ブランチでは、通知ロジックがコントローラにハードコードされています。
# app/controllers/weekly_reports_controller.rb (main ブランチ)
# インライン 同期通知
case channel
when "email"
WeeklyReportMailer.report_ready(@report).deliver_now
when "slack"
Rails.logger.info "[Slack] Weekly report ready: #{@report.id}"
end
@report.update!(notified_at: Time.current)
問題点
-
開放/閉鎖原則に違反: 新しいチャネル(LINE, SMS等)を追加するたびに
case/whenを修正する必要がある -
同期処理でレスポンスが遅い: メール送信(
deliver_now)がHTTPレスポンスをブロックする - 関心の混在: 「レポート生成」と「ロギング」が同じコードに混在している
解決策1:Adapter パターン
# app/adapters/notification_adapter.rb
class NotificationAdapter
# Factory Method: チャネル名からサブクラスを返す
def self.for(channel)
case channel
when "email" then MailNotificationAdapter.new
when "slack" then SlackNotificationAdapter.new
else raise ArgumentError, "Unknown channel: #{channel}"
end
end
def deliver(report:)
raise NotImplementedError, "#{self.class}#deliver is not implemented"
end
end
# app/adapters/mail_notification_adapter.rb
class MailNotificationAdapter < NotificationAdapter
def deliver(report:)
WeeklyReportMailer.report_ready(report).deliver_now
end
end
# app/adapters/slack_notification_adapter.rb
class SlackNotificationAdapter < NotificationAdapter
def deliver(report:)
Rails.logger.info "[Slack] Weekly report ready: #{report.id}"
end
end
コントローラは case/when の代わりに NotificationAdapter.for(channel).deliver(report:) と
書くだけになります。新しいチャネルを追加する場合は、新しいサブクラスを追加するだけでよく、
既存のコードを変更する必要がありません。
解決策2:ActiveJob による非同期化
# app/jobs/send_weekly_report_job.rb
class SendWeeklyReportJob < ApplicationJob
queue_as :default
def perform(weekly_report_id, channel)
report = WeeklyReport.find(weekly_report_id)
case channel
when "email"
WeeklyReportMailer.report_ready(report).deliver_now
when "slack"
Rails.logger.info "[Slack] Weekly report ready: #{report.id}"
end
report.update!(notified_at: Time.current)
end
end
コントローラからの呼び出しは deliver_now から perform_later に変わります。
# Before: 同期(HTTPレスポンスをブロック)
WeeklyReportMailer.report_ready(@report).deliver_now
@report.update!(notified_at: Time.current)
# After: 非同期(すぐにレスポンスを返す)
SendWeeklyReportJob.perform_later(@report.id, channel)
@report.id を渡す点がポイントです。@report オブジェクトをそのまま渡すと
シリアライズの問題が起きる可能性があるため、IDを渡してジョブ内で再度 find します。
解決策3:Pub/Sub(ActiveSupport::Notifications)
# config/initializers/event_subscriber.rb
ActiveSupport::Notifications.subscribe("weekly_report.generate") do |event|
Rails.logger.info "[Event] weekly_report.generate: #{event.payload.inspect} (#{event.duration.round(2)}ms)"
end
ActiveSupport::Notifications.subscribe("weekly_report.notified") do |event|
Rails.logger.info "[Event] weekly_report.notified: #{event.payload.inspect}"
end
イベントを発行するのはコントローラ(またはワークフロー)です。
# コントローラ(または Workflow)でイベントを instrument する
ActiveSupport::Notifications.instrument("weekly_report.generate", user_id: current_user.id) do
# レポート生成処理...
@report = WeeklyReport.create!(...)
end
# 通知完了後にイベントを発行
ActiveSupport::Notifications.instrument("weekly_report.notified",
report_id: @report.id, channel:)
Before / After 比較
通知チャネルの追加コスト
# Before: case/when を修正(既存コードに触れる)
case channel
when "email" then WeeklyReportMailer.report_ready(@report).deliver_now
when "slack" then Rails.logger.info "..."
when "line" then LineNotifier.send(...) # ← 追加のたびに修正
when "sms" then SmsClient.send(...) # ← 追加のたびに修正
end
# After: 新しいクラスを追加するだけ(既存コードに触れない)
class LineNotificationAdapter < NotificationAdapter
def deliver(report:)
LineNotifier.send(...)
end
end
# NotificationAdapter.for("line") で自動的に使われる(要: self.for に追記)
同期 vs 非同期
# Before: メール送信がレスポンスをブロック(数秒かかる可能性)
WeeklyReportMailer.report_ready(@report).deliver_now # ← ここでブロック
redirect_to weekly_report_path(@report) # ← 完了後にリダイレクト
# After: ジョブをキューに積んですぐにリダイレクト
SendWeeklyReportJob.perform_later(@report.id, channel) # ← すぐに返る
redirect_to weekly_report_path(@report) # ← 即座にリダイレクト
イベント購読の疎結合
# Before: ログ出力がビジネスロジックに混在
def create
# ...レポート生成...
Rails.logger.info "Report generated: #{@report.id}" # ← ログがここに
# ...通知...
Rails.logger.info "Notified: #{@report.id}" # ← ログがここにも
end
# After: ビジネスロジックはイベントを発行するだけ
# ログ出力は initializer の subscriber が担当(関心の分離)
ActiveSupport::Notifications.instrument("weekly_report.generate", ...) do
# ...レポート生成のみ...
end
まとめ
Adapter パターンのメリット
- 通知チャネルの切り替えを実装の詳細から隠蔽
- 開放/閉鎖原則: 新しいチャネルの追加がコードの修正なしにできる
- 各アダプターを独立してテスト可能
ActiveJob のメリット
- 時間のかかる処理をバックグラウンドに移してUXを向上
- 失敗時の自動リトライ機能
- キューの優先度設定が可能
Pub/Sub(ActiveSupport::Notifications)のメリット
- 計測・ロギング・監視をビジネスロジックから完全に分離
-
instrumentブロックは自動的に実行時間を計測する - 購読側(subscriber)を追加・削除しても発行側のコードを変えなくてよい
3パターンの組み合わせ
これら3つのパターンは独立して使えますが、組み合わせると強力です。
コントローラ
→ instrument でイベント発行(Pub/Sub)
→ レポート生成
→ SendWeeklyReportJob.perform_later(ActiveJob)
→ NotificationAdapter.for(channel).deliver(Adapter)
→ イベント完了をログに記録(subscriber)