[Rails]Model/テーブル設計で必ず覚えておきたいSTI
開発チームの下田です。
Ruby on Railsについて、基本的なこと(modelやcontrollerが何か知っている程度)を理解されている方を対象としています。
BtoBクラウド受注・発注システムCORECはRailsで開発しています。
開発初期のコードを見直してみると失敗したなと思うことがあります。
中でも特に開発効率に影響しているなと思うのが、RailsのActiveRecordのSTI(Single Table Inheritance、単一テーブル継承)を活用しなかったことです。
STIを簡単に言うと、モデルを永続化するときに、どのクラスなのかというメタ情報を含めてデータベースに保存します。
これだけではわかりづらいと思うので、失敗した例と改善例からご紹介したいと思います。
悪い設計
仮に下記の仕様のコードを書くとします。
要件1:
CORECではWEBアプリから発注書を送信できます。送信方法は次の3つから選べます。
- WEBアプリ内で送信する
- メールで送信する
- FAXで送信する
この時、次のようなコードにしていました。
(項目名、メソッド名は意味が通じるように和名にしています)
# 注文書model class Order < ActiveRecord::Base include FAX送信モジュール validates :送信方法, :presence => true # WEBで送信するなら送信先IDが必須 validates :送信先ユーザ_id, :presence => true, if: ->{ 送信方法 == 'WEB' } # メールならメールアドレスが必須 validates :メールアドレス, :presence => true, if: ->{ 送信方法 == 'メール' } # FAXならFAX番号が必須 validates :FAX番号, :presence => true, if: ->{ 送信方法 == 'FAX' } has_many :FAX送信結果 def 送信する self.送信済 = true validate! case 送信方法 when 'メール' 注文Mailer.メール注文書送信.deliver when 'FAX' FAX送信する end save end private def FAX送信する FAX送信結果.create(送信結果: FAX送信モジュール.送信) end end
何が問題か
当初は送信するときだから、送信手段ごとに処理をcase-whenで振り分けることに違和感はありませんでした。しかし、開発を進めていると、同じコードがあちこちに現れて読みづらいコードになり始めました。
要件2:
- FAXは送信に失敗することがあるので、結果判定をする
- WEBとメールは必ず成功したとみなす
class Order < ActiveRecord::Base # ~~ 略 ~~ def 送信成功? # メソッドごとに送信方法による分岐がある状態 case 送信方法 when 'FAX' FAX送信結果.送信成功? default true end end end
あるべき姿
FAXもメールもすべて注文書の情報なのでOrderクラスにしたことは正しいのですが、送信方法によってふるまいが違います。役割が同じでふるまいが違うなら、サブクラスを作りポリモーフィズムを持たせるべきです。
class Order < ActiveRecord::Base def 送信する self.送信済 = true save end def 送信成功? false end end # WEBで送信する注文書クラス class Order::Web < Order validates :送信先ユーザ_id, :presence => true def 送信する validate! super end end # メールで送信する注文書クラス class Order::Mail < Order validates :メールアドレス, :presence => true def 送信する validate! 注文Mailer.メール注文書送信.deliver super end end # FAXで送信する注文書クラス class Order::Fax < Order validates :FAX番号, :presence => true has_many :FAX送信結果 def 送信する validate! FAX送信する super end def 送信成功? FAX送信結果.送信成功? end private def FAX送信する FAX送信結果.create(送信結果: FAX送信モジュール.送信) end end
メソッドの中から冗長なcase文が消えます。また、relationやvalidationのふるまいも送信方法ごとに整理され、見通しが良くなりました。
このようなクラス設計にしたい場合はSTIを使用します。STIを使用するにはtype:stringカラムを追加します。
create_table "orders", force: true do |t| t.string "type" # 追加するとSTIになる t.integer "送信先ユーザ_id" t.string "メールアドレス" t.string "FAX番号" t.datetime "created_at" t.datetime "updated_at" end
typeカラムがある場合とない場合を比較してみましょう。
# typeカラムがない場合 order = Order::Mail.new(メールアドレス: 'hoge@example.com') order.save # id: 1 # メールアドレス: "hoge@example.com" # OrderからロードするとOrderになる Order.find(order.id) => #<Order id: 1, メールアドレス: "hoge@example.com"> # Order::MailからロードすればOK Order::Mail.find(order.id) => #<Order::Mail id: 1, メールアドレス: "hoge@example.com">
# typeカラムがあり、STIになっている場合 order = Order::Mail.new(メールアドレス: 'hoge@example.com') order.save # type: 'Order::Mail' # クラス名がtypeに自動的にsaveされる # id: 1 # メールアドレス: "hoge@example.com" # created_at: 2015-01-01 12-34-56 # updated_at: 2015-01-01 12-34-56 # Orderからロードしても、きちんとOrder::Mailになる Order.find(order.id) => #<Order::Mail id: 1, メールアドレス: "hoge@example.com"> # サブクラスからロードすると、typeを検索条件に自動で加えてくれる Order::Mail.all.to_sql => "select * from orders where type = 'Order::Mail'"
データベースに保存し永続化した後でもふるまいを維持し続けてくれるので、クラス設計を適切に行えば複雑な仕様が追加されても、良い状態のコードを保てると思います。
運用上の注意
開発には非常に便利なSTIですが、運用を考えて開発しなければならないことが1点あります。typeカラムに存在しないclass名が設定されていると、ActiveRecord::SubclassNotFound例外が発生します。
ActiveRecord::SubclassNotFound例外が発生するのは、2パターンがあります。
- リリース時に並行稼動している時に、新しいAPで保存した新しいサブクラスを古いAPでロードした
- リリースしたがロールバックし、新しいAPで保存した新しいサブクラスを古いAPでロードした<
どちらの場合も、何も変更しないサブクラスを作り、先行してリリースしておくと、予期せぬ例外が起きません。フェイルソフトになります。
# 仮置きする。 class Order::NewClass < Order end
もしくはデフォルトスコープに現バージョンで存在しているclassのみを指定すると、ロードされないので例外が発生しません。サブクラスを追加した時に追加し忘れたり、where句が思わぬところで効いたりしないか確認が必要です。
class Order < ActiveRecord::Base # 条件に加えてしまう。 default_scope ->{ where(type: [Order, Order::Corec, Order::Mail, Order::Fax]) } # ~~ 略 ~~ end
おわりに
STI以外にも、ああすればよかったと思うことは多くあります。その中でもSTIについて書いたのは、知っているか知らないかでModelの設計に差が出てしまうため、気づいた時には手軽にはリファクタリングできなくなっているからです。その反面、ある程度の規模のアプリケーションでなければ使い道が無くRuby on Rails チュートリアルに載っていなかったりと軽視されがちです。
新しくmodelを設計するときには頭の片隅に置いておくといいと思います。