こんにちは!SmartHR基本機能でプロダクトエンジニアをしているdooorです。
SmartHRでは履歴を表すデータモデルにBitemporal Data Modelを採用していて、Active Recordで扱うために ActiveRecord::Bitemporal を開発しています。
BiTemporal Data Modelとは、データを「有効時間(データが現実世界で有効である期間)」と「システム時間(データがデータベースに格納された時間)」の2つの時間軸で管理する方法です。変遷するデータの履歴を残すことができ、ある時点のデータを一覧することや過去の情報を更新することが可能になります。BiTemporal Data Modelが気になる方は 操作履歴/時点指定アクセスの実現 - BiTemporal Data Model の実践 をご一読ください。
ActiveRecord::BitemporalをincludeしたモデルはActive Recordとは異なる振る舞いをするので、これまでのRails開発経験から「こうすれば動くよね」と思っても、期待した結果にならないことが稀によくあります。今回は最近遭遇した例と、解決するまでの試行錯誤を紹介します。
ActiveRecord::BitemporalとActiveRecord::Callbacks
ある機能を開発している時に「従業員情報が作成・更新・削除されたらログを残したい」という要件が出てきました。この要件を実現するために「Active Recordのコールバックを利用する」という案が思いついたので早速試してみました。
class Employee < ApplicationRecord include ActiveRecord::Bitemporal after_create { pp "従業員情報が作成されました。" } after_update { pp "従業員情報が更新されました。" } after_destroy { pp "従業員情報が削除されました。" } end employee = Employee.create(emp_code: "001", name: "Jane") # => "従業員情報が作成されました。" employee.update(name: "Tom") # => "従業員情報が作成されました。" # "従業員情報が作成されました。" # "従業員情報が更新されました。" employee.destroy # => "従業員情報が作成されました。" # "従業員情報が削除されました。"
更新や削除を実行した時にも「従業員情報が作成されました。」と出力されてしまい、期待した結果にはなりませんでした。
これはActiveRecord::Bitemporalが暗黙的に生成する履歴レコードが影響しています。ActiveRecord::Bitemporalの特徴として、
- モデルを更新すると、暗黙的に2つの履歴レコードを生成する
- モデルを削除すると、暗黙的に1つの履歴レコードを生成する
があり、ActiveRecord::Bitemporalが暗黙的に生成する履歴レコードのコールバックが発火しているのが原因です。(詳しい挙動はActiveRecord::BitemporalのREADMEをご覧ください。)
Active Recordのコールバックをうまく制御できないか
このままでは余計な作成のログが出力されてしまうので、暗黙的に生成される履歴レコードに対するコールバックを発火させない方法がないかを探してみます。
Active Recordには条件付きコールバックがあるので、暗黙的に生成される履歴レコードかどうかの判定があれば実現できそうです。 しかし、Gemが暗黙的な処理かどうかを判定できるAPIを公開したり、Gemの内部処理をモデルが知ることになるのはどうなのか、という気持ちになります。
class Employee < ApplicationRecord include ActiveRecord::Bitemporal after_create { pp "従業員情報が作成されました。" }, unless: -> { 暗黙的な履歴レコードの作成? } after_update { pp "従業員情報が更新されました。" } after_destroy { pp "従業員情報が削除されました。" } end
他にコールバックを制御する方法にskip_callback
メソッドがありそうです。しかし、skip_callback
も条件付きコールバックと同様に暗黙的な処理かどうかの判定が必要になったり、set_callback
メソッドと組み合わせて実現できたように見えても、スレッドセーフではなくなってしまうなどの問題がありました。
class Employee < ApplicationRecord include ActiveRecord::Bitemporal after_create :log_on_create after_update :log_on_update def log_on_create pp "従業員情報が作成されました。" end def log_on_update pp "従業員情報が更新されました。" end end employee = Employee.create(emp_code: "001", name: "Jane") # => "従業員情報が作成されました。" Employee.skip_callback(:create, :after, :log_on_create) employee.update(name: "Tom") # => "従業員情報が更新されました。" Employee.set_callback(:create, :after, :log_on_create)
Active Recordのコールバックを制御するのはどうやら筋が悪いようでした。
ActiveRecord::Bitemporalに手を加える
Active Recordのコールバック周りの実装を読んでいると、ActiveRecord::Bitemporalの操作単位で発火するコールバックをGemが新しく提供できそうだなと思い始め、
define_model_callbacks
で独自のコールバックを定義するrun_callbacks
を暗黙的に生成される履歴レコードでは実行しない
という方針で、ActiveRecord::Bitemporal::Callbacksをつくってみました。 https://github.com/kufu/activerecord-bitemporal/pull/123
class Employee < ApplicationRecord include ActiveRecord::Bitemporal after_bitemporal_create { pp '従業員情報が作成されました。' } after_bitemporal_update { pp '従業員情報が更新されました。' } after_bitemporal_destroy { pp '従業員情報が削除されました。' } end employee = Employee.create(emp_code: "001", name: "Jane") # => "従業員情報が作成されました。" employee.update(name: "Tom") # => "従業員情報が更新されました。" employee.destroy # => "従業員情報が削除されました。"
ついにActive Recordのコールバックと似た形で、ActiveRecord::Bitemporal用のコールバックを登録できるようになりました。
おわりに
SmartHR基本機能のコードでは、開発者はActiveRecord::Bitemporalのおかげで履歴を意識することなく、通常のActive Recordと変わらないインターフェースで扱うことができます。
ただし、ActiveRecord::Bitemporalの中で何が起きているのかを理解していないと、「おや?」と思う挙動に直面した際、原因の理解や問題解決に苦労したりします。
ActiveRecord::BitemporalはSmartHRの履歴を扱う上では欠かせないGemなので、チームのメンバーとGemのコードリーディングをして理解を深めたり、欲しい機能や不可解な挙動を見つけたらこうして手を加えながら日々開発しています。
We Are Hiring!
SmartHR では一緒に SmartHR を作りあげていく仲間を募集中です!