概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: On ActiveRecord callbacks, setters and derived data | Arkency Blog
- 原文公開日: 2017/12/13
- 著者: Robert Pankowecki
- サイト: Arkency Blog
Rails: ActiveRecordのコールバック/セッター/派生データについて再び(翻訳)
Arkencyではこれまで数度に渡って、Railsで使えるコールバック以外の手法についての記事をお送りし、どんな問題が発生する可能性があるかを考察してきました。
- Rails: beforeバリデーションをやめてセッターメソッドにしよう(翻訳)
- Ruby: gemが生成するコードを無名モジュールとprependで動かす(翻訳)
- Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)
しかし、まだまだ多くのシナリオでコールバックが使われているのを今も目にします。そこで今回はもう少し別の例を使って、今一度コールバックについて書いてみたいと思います。
コントローラでメソッドが2回呼び出される
class Controller
def update
@cart = Cart.find(params[:id])
@cart.update_attributes!(...)
@cart.update_tax
head :ok
end
end
私はこの手のパターンをかなり見かけてきました。ActiveRecord::Base
の(既存の)定義済みメソッドが一部の属性の設定に使われています。ツリーの奥深くのオブジェクトを編集するためにaccepts_nested_attributes_for
と組み合わせて使われることもしばしばです。
そして、後になってtax
やsum
やcounter
やdiscount
などの派生データを再計算しなければならなくなった場合の対応方法は、アプリによってまちまちです。たとえば、米国の売上税(sales tax)は出荷元の住所によって変動することがあります。そのため、出荷元住所を設定したら、アプリで使われているOrder
やSale
やCart
などの税金も再計算したいことがあります。
普通これらの計算結果をデータベースに保持しておく理由は、価格や税金やディスカウント額などが将来変更された場合に金額が変わっては困るからです。そのため、現在の値から派生するデータを算出して保存します。他に、データベース上の計算を簡単かつ高速に出力したいという理由付けもあります。
そのような場合には、update_attributes!
やattributes=
で出荷元住所を設定してからupdate_tax
を読んで再計算をトリガするよりも、shipping_address=
セッターのように意図が明白なpublicメソッドを1つだけ使う方がよいのです。
私がpublicなインターフェイスを使うときには必ず、メソッドの呼び出し順序や引数がどのように変わったとしても最終的に正しいステートを得られる(あるいは、オブジェクトの使い方が誤った場合にはそれがはっきりわかる例外を発生する)よう自分に課していることを申し上げておかなければなりません。オブジェクトを書くときは、順序に依存しないようにするか、内部ステートを保つことで誤った順序を防止できるようにしましょう。私が求めるのは「可換(commutative)」すなわち交換可能性であると信じています。
こうしておけば、リファクタリングもずっと簡単になります。派生データが常に正しく再計算されるので、たとえば住所の前または後でディスカウントを設定するようチェックアウト処理を変更しても大丈夫です。10個の値を変更できる1つの大画面を2つの小画面に分割して、値をそれぞれに振り分ける場合にも問題なく動作するので、安心して作業できます。
モデル内で値を再計算するコールバック
典型的な例をもうひとつご紹介しましょう。
class Order
before_save :set_amount
def add_line(...)
# ...
end
private
def set_amount
self.amount = line_items.map(&:amount).sum
end
end
これも先ほどと同様の問題を抱えています。save!
を呼び出すとset_amount
メソッドが自動的に呼び出されるよう軽く自動化してあります。しかし、これでは次のようなテストを書けません。
order.add_line(product)
expect(order.amount).to eq(product.price)
この場合、次のように再計算やsave
を手動でトリガする必要があります・
order.add_line(product)
order.save!
expect(order.amount).to eq(product.price)
order.add_line(product)
order.set_amount # privateにできない
expect(order.amount).to eq(product.price)
これでは面白くも何ともありません(少なくとも私は)。
回避する方法はないものでしょうか。そのために、amount
や tax
などの派生データを再計算するadd_line
やremove_line
やupdate_line
などの「意図が明確な」メソッドを追加しましょう。このようなドメイン操作はコールバックのようなところに隠さず、明示的にしましょう。Railsではたいていの場合、super
を呼び出してゲッターやセッターを上書きしてから作業を続行できることを思い出しましょう。
class Wow < ActiveRecord::Base
def column_1=(val)
super(val)
self.sum = column_1 + column_2
end
def column_2=(val)
super(val)
self.sum = column_1 + column_2
end
end
この手法は、再評価の必要な計算が多数ある場合に特に便利です。
class Wow < ActiveRecord::Base
def column_1=(val)
super(val)
compute_derived_calculations
end
def column_2=(val)
super(val)
compute_derived_calculations
end
private
def compute_derived_calculations
self.sum = column_1 + column_2
self.discounted = sum * percentage_discount
self.tax = (sum - discounted) * 0.02
self.total = sum - discounted + tax
end
end
今日はこの手の値に6つも出くわしました😉。
これらの問題に取り組むべき理由
これらの問題の根本的な原因は、ActiveRecord
のサブクラスがデフォルトで持っているpublic
なメソッドが多すぎることだと信じています。あらゆる属性や関連付けにもれなくpublicメソッドがついてきて、誰でも好きな場所で変更できてしまいます。このような状況に置かれた開発者は、アプリで実際に使うAPIを絞り込み、縮小して予測可能にすることに責任を持つ必要があります。
他の言語(他のフレームワークとする方がRubyの責任でないという意味で正確だと思いますが)のコードでは、ルールを保護して派生データの算出をトリガするカプセル化メソッドの方がはるかに一般的に使われています。Railsの場合は、何でもカラムで設定しておいて、バリデーション中やオブジェクトの保存時に面倒を見るのが普通なので、そのためのコールバックに強く依存します。私は、オブジェクトがいついかなるときでも問題が生じないようにしておくのが好みです。
遠回しなたとえ
ここまでご理解いただけましたでしょうか。ところで皆さんはReact.jsをお使いですか?React.jsのrender
は、コンポーネントがstate
やprops
にのみ依存する純粋関数である場合に最大の効果を発揮します。React.jsでrender
を呼び出した結果は派生データになり、引数が同じであれば常に同じ結果を得られます。
以下のようなメソッドについても同じように考えられます。
def compute_derived_calculations
self.sum = column_1 + column_2
self.discounted = sum * percentage_discount
self.tax = (sum - discounted) * 0.02
self.total = sum - discounted + tax
end
column_1
や column_2
などに値を設定できます。
def column_2=(val)
super(val)
compute_derived_calculations
end
その他に、sum
やdiscounted
やtax
やtotal
などの自動で再計算される派生値があります。いずれもわかりきったことではありますが、ActiveRecordがあるとこの辺を簡単には実現できないので、もう少し頑張る必要があります。
凝集度を高めて「集約」を作り出す
本ブログをお読みの方でDomain-Driven Design(電子書籍)をお読みいただいた方であれば、本記事で説明したリファクタリングによってよりよい「集約(aggregate)」を実現できるということにお気づきかと思います。内部ルールはこの集約によって常に保護されます。
詳しく知りたい方へ
本記事をお楽しみいただけましたら、ぜひ私たちのニュースレターの購読をお願いします。私たちが日々追求している、開発者を驚かさないメンテ可能なRailsアプリの構築方法を皆さんにお届けいたします。
以下の記事も参考にどうぞ。
- Rails: beforeバリデーションをやめてセッターメソッドにしよう(翻訳)
- Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)
- The biggest Rails code smell you should avoid to keep your app healthy
- Application Services - 10 common doubts answered
私たちの最新書籍『Domain-Driven Rails』をぜひチェックしてみてください。巨大で複雑なRailsアプリを扱っている方に特におすすめします。