開発チームの下田です。

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クラスにしたことは正しいのですが、送信方法によってふるまいが違います。役割が同じでふるまいが違うなら、サブクラスを作りポリモーフィズムを持たせるべきです。

sti


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