Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

参考: Domain Event
参考: ドメイン イベント: 設計と実装 | Microsoft Docs

  • 2018/08/03: 初版公開
  • 2022/09/27: 更新

Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

最近Marcinの書いた「Railsアプリの最大のコードの臭いはActiveRecordのコールバックである」記事にあるように、コールバックはあっという間に制御不能になってしまう可能性があります。この記事がRedditに投稿された後、コメントで実に興味深いやりとりを見かけました。

重要なモデルがひとつあり、そこにはビジネス上重大なデータがあってめったに変更されることはないが、さまざまなコントローラに散らばった自動化ビジネスロジックによって変更されることがあるとしよう。

このデータが変更されたときに何らかのアラートや通知を送信したいとする(メールとかチャットとか別テーブルにエントリを足すなど)。理由は、非常に重要かつ変更のまれなデータであり、多くの人が変更を知っておくべきだから。

こんなとき次のAとBのどちらを選ぶ?

A: 変更のたびにそのモデルでメールを送信することを許可し、「変更時に通知」というコア機能をカプセル化して、変更が発生する実際の場所に配置する。

B: コントローラ内で指定のモデルファイルが変更されるあらゆる箇所に個別に呼び出しを挿入する。

私ならAを選ぶだろう。こちらの方が将来の変化に強く、しかも的確だからだ。さらに今後プログラマーがエラーを埋めるリスクも軽減でき、「変更時に外部に通知する」という単体の責務を僅かなコストでそのモデルファイルに追加できる。

コメ主の指摘は非常に興味深く、かつ非常に有益です。私自身、数か月前に以下のようなコールバックを使っていました。

class Order < ActiveRecord::Base
  after_commit do |order|
    Resque.enqueue(IndexOrderJob,
      order.id,
      order.shop_id,
      order.buyer_name,
      order.buyer_email,
      order.state,
      order.created_at.utc.iso8601
    )
  end
end

Elasticsearchデータベースのインデックス作成をスケジューリングするために、問題を最速の方法で解決しましたが、私たちのコードベースにそれ以上の改善がもたらされないことは承知の上でした。しかしこのときは、今後このコードを取り除くのに役立つ可能性のある作業を同時に並行してやっていたことも承知していました。

こうしたコールバックには否定し難いメリットがあると同時に、いくつもの問題を抱え込んでしまいます。それについて書きたいと思います。

コールバックを正しく書くのは簡単ではない

上ととてもよく似た、以下のようなコードがあるとします。

class Order < ActiveRecord::Base
  after_save do |order|
    Elasticsearch::Model.client.index(
      id: id,
      body: {
        id:              id.to_s,
        shop_id:         shop_id,
        buyer_name:      buyer_name,
        email:           buyer_email,
        state:           state,
        created_at:      created_at
    })
  end
end

パッと見には何の問題もなさそうですが、もしこのトランザクションがロールバックされると、(手動でオープンした巨大なトランザクションの中でOrderがsaveされて)2番目のデータベースのインデックス化されたステートに誤りが生じるかもしれません。このままにするか、after_commitに切り替える方法が考えられます。

さらに、Elasticsearchで例外が発生すればただちにDBトランザクションもロールバックされるでしょう。それをよしとする考え方(DB不整合が発生せず、ElasticsearchにもSQL DBにも何も残らない)もあれば、よくないとする考え方(重要性の低いDBエラーのせいでユーザーの発注が止まってしまい利益が損なわれる)もあります。

そこで今度はafter_commitに乗り換えてみましょう。この特定のニーズにはこちらの方が合っていそうです。そしてドキュメントには次のように書かれています。

これらのコールバックが他のシステムとのやりとりに有用なのは、データベースのステートが不変な場合にのみ実行されることが保証されるからです。たとえばafter_commitがキャッシュをクリアするフックの置き場所にふさわしい理由は、キャッシュをトランザクション内でクリアすると、データベース更新が完了する前にキャッシュの再生成がトリガされてしまう可能性があるからです。

つまり言い換えると、そうしたフックをサードパーティのシステムやAPIやDBと統合するのであればafter_commitの方がより安全性の高い選択肢となります。副作用がSQL DBにも保存されるのであれば、after_saveafter_updateでも十分です。

class Order < ActiveRecord::Base
  after_commit do |order|
    Elasticsearch::Model.client.index(
      id: id,
      body: {
        id:              id.to_s,
        shop_id:         shop_id,
        buyer_name:      buyer_name,
        email:           buyer_email,
        state:           state,
        created_at:      created_at
    })
  end
end

こうして私たちはafter_commitを使うことを覚えました。

さて、おそらくテストの大半はトランザクションで占められているので、テストはDBトランザクション内で実行するのが最も高速です(そのテストではフックが発火しないため)。現在関心のあるテストがほんの数件しかない場合、これは嬉しい点です。嬉しくないのは、Elasticsearchに保存されているデータが多くのユースケースで必要になる場合です。こうなったら、トランザクションを用いない方式でテストを実行するか、test_after_commit gemを使うか、Rails 5にアップグレードしなければなりません。

歴史的には(レガシーRailsアプリから読み取った範囲では)after_commitコールバックで発生した例外は握りつぶされて単にロガーに出力されます。すべてがコミットされてしまった後では何もできないからです。これはRails 4.2以降で修正されましたが(#14488)、スタックトレースは以前ほど良好ではないかもしれません。

ほとんどの技術的問題への対応方法はいくつもあるので、それらについてひととおり知っておく必要があります。これらの例外は最も困った問題であり、何らかの形で取り扱う必要があります。

コールバックは癒着を増やす

Railsの多くの問題は癒着から来ていると私は直感します。Railsの技術的な層はデフォルトでは不十分です。私たちにあるのはビュー(ビューは本記事と何の関係もありませんが)とコントローラとモデルです。すなわち、操作で副作用をトリガしたいと思った場合、そのコードの置き場所はコントローラかモデルが唯一のデフォルト選択肢となります。コードをどちらに置いてもそれなりに問題があります。

副作用(API呼び出し、キャッシュ、セカンダリDBの統合、メール送信)をコントローラに配置すると、テストを正しく行おうとするときに問題が生じる可能性があります。理由は2つあります。コントローラはHTTPインターフェイスと密結合しているので、副作用をトリガーするにはテストでHTTP層を用いて副作用とやりとりする必要があります。コントローラはフレームワークが管理している領域なので、テストでコントローラをインスタンス化してメソッドを直接呼び出すのは簡単ではありません。

かといって副作用をモデルに配置すると、今度は別の問題が生じます。副作用がハードコードされてしまうので、別途結合テストを(明示的に)用いない限りこのドメインモデルのテストは困難です。つまり「遅いテストで我慢する」か「テストを毎回モックやスタブで塞ぐ」かのどちらかしかありません。

RailsコミュニティでService Objectに関するおびただしい記事が見つかる理由はこれです。アプリが複雑になり始めると、開発者はメール送信やサードパーティAPIへの通知などの関心事を「after save」的な副作用のところに置きたがります。別のコミュニティやアーキテクチャではこの種のコード部品をTransaction Script(訳注: Martin Fawler氏の用語)と呼んだりAppplication/Domain/Infrastructure Service(訳注: ドメイン駆動開発(DDD)の用語)と呼んだりすることがあります。しかしどちらもデフォルトのRailsにはありません。そうした機能を必要とする開発者がこぞってブログ記事やgem(1つや2つではありません)あるいはこの層をしっかり備えている新しいフレームワーク(hanamitrailblazer)に頼ってサービスの「車輪の再発明」に走る理由はここにあります。新しいフレームワークに移行せずにこの層をアプリのコードに導入する方法については弊社のFearless Refactoring bookをぜひご覧ください。本書は、システムに高度な概念を導入する前の重要なステップです。

コールバックはデータ変更の意図がわからなくなる

コールバックが呼び出されればデータが変更されることはわかりますが、変更された理由まではわかりません。ユーザーによる発注のせいなのか、別の処理でPOSを操作した人のせいなのか、それとも支払いが原因か、返金が原因か、キャンセルが原因なのか、知りようがありません。あえてstate属性に基づいて変更をかけるのは、多くの場合アンチパターンです。変更された理由がわからなくても(コールバックで何らかのデータを送信しているという理由で)問題にならないこともありますが、それ以外の場合は問題になる可能性があります。

モバイルからのAPI呼び出しや、Webブラウザから別のエンドポイントを介してUserが登録されたときに、ユーザーに「ようこそメール」を送信したいとしましょう。さらにユーザーがFacebookから登録した場合にも送信したいとします。ただしユーザーをシステムにインポートするときには送信したくない(新しいパートナー企業が顧客を連れてこちらのプラットフォームに移籍することを決めたという理由で)とします。つまりこの4つの状況のうち3つについては(メール送信という)副作用が欲しいのですが、残りの1つではこの副作用は欲しくありません。発生したイベントに対して何を行いたいかという意図がわかる方がよいでしょう。after_createはその目的には向いていません。

Domain Eventとは

Active Recordのコールバックの代わりに私が推奨しているのは、UserRegisteredViaEmailUserJoinedFromFacebookUserImportedOrderPaidといった「Domain Event」をパブリッシュして、イベントに対して応答するハンドラをそこにサブスクライブするという方法です。

この方法ではさまざまなpub/sub gem(whisperなど)を利用できますし、rails_event_store gemを用いてデータベースへの保存や今後のインスペクション/デバッグ/ログ出力なども行えます。

このアプローチについて詳しくお知りになりたい方は、2 years after the first domain event - the Saga patternのスライドや動画をご覧いただけます。Domain Eventのパブリッシュ方法や、それを用いた副作用のトリガ方法について解説しています。このアプローチはActive Recordコールバックの代わりに用いることができます。

今後アプリで何か変更が生じれば必ずイベントがパブリッシュされるようになります。変更が、指定のモデルのどこで生じるのかを探し回ることもありません。変更が生じる場所はすべて明らかだからです。

追伸: Rails 5では更に深刻です

関連記事

Ruby: gemが生成するコードを無名モジュールとprependで動かす(翻訳)

Rails: beforeバリデーションをやめてセッターメソッドにしよう(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。