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

概要

原著者の許諾を得て翻訳・公開いたします。

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

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以降で修正されましたが、スタックトレースは以前ほど良好ではないかもしれません。

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

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

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バリデーションをやめてセッターメソッドにしよう(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ