SmartHR Tech Blog

SmartHR 開発者ブログ

ActiveRecord::Bitemporalとの歩み方

こんにちは!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 を作りあげていく仲間を募集中です!

hello-world.smarthr.co.jp