[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を設計するときには頭の片隅に置いておくといいと思います。