Rails: ActiveRecordのコールバック/セッター/派生データについて再び(翻訳)

概要

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

Rails: ActiveRecordのコールバック/セッター/派生データについて再び(翻訳)

Arkencyではこれまで数度に渡って、Railsで使えるコールバック以外の手法についての記事をお送りし、どんな問題が発生する可能性があるかを考察してきました。

しかし、まだまだ多くのシナリオでコールバックが使われているのを今も目にします。そこで今回はもう少し別の例を使って、今一度コールバックについて書いてみたいと思います。

コントローラでメソッドが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と組み合わせて使われることもしばしばです。

そして、後になってtaxsumcounterdiscountなどの派生データを再計算しなければならなくなった場合の対応方法は、アプリによってまちまちです。たとえば、米国の売上税(sales tax)は出荷元の住所によって変動することがあります。そのため、出荷元住所を設定したら、アプリで使われているOrderSaleCartなどの税金も再計算したいことがあります。

普通これらの計算結果をデータベースに保持しておく理由は、価格や税金やディスカウント額などが将来変更された場合に金額が変わっては困るからです。そのため、現在の値から派生するデータを算出して保存します。他に、データベース上の計算を簡単かつ高速に出力したいという理由付けもあります。

そのような場合には、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_lineremove_lineupdate_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は、コンポーネントがstatepropsにのみ依存する純粋関数である場合に最大の効果を発揮します。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

その他に、sumdiscountedtaxtotalなどの自動で再計算される派生値があります。いずれもわかりきったことではありますが、ActiveRecordがあるとこの辺を簡単には実現できないので、もう少し頑張る必要があります。

凝集度を高めて「集約」を作り出す

本ブログをお読みの方でDomain-Driven Design(電子書籍)をお読みいただいた方であれば、本記事で説明したリファクタリングによってよりよい「集約(aggregate)」を実現できるということにお気づきかと思います。内部ルールはこの集約によって常に保護されます。

詳しく知りたい方へ

本記事をお楽しみいただけましたら、ぜひ私たちのニュースレターの購読をお願いします。私たちが日々追求している、開発者を驚かさないメンテ可能なRailsアプリの構築方法を皆さんにお届けいたします。

以下の記事も参考にどうぞ。

私たちの最新書籍『Domain-Driven Rails』をぜひチェックしてみてください。巨大で複雑なRailsアプリを扱っている方に特におすすめします。

関連記事

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

Ruby: 年に1度だけ発生する夏時間バグ(翻訳)

デザインも頼めるシステム開発会社をお探しなら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探訪シリーズ