はじめに
この記事は CrowdWorks Advent Calendar 2016 23日目の記事です。
クラウドワークスでRuby、JavaScriptを主に書いているエンジニアの @suzan2go です。
直近ではクラウドワークスの新規事業 WoW!me(ワオミー) の立ち上げをやっていました。
WoW!me(ワオミー) では色々と技術的な挑戦をしているのですが、今回は新規事業でのデータモデリングについてお話したいと思います。
新規事業におけるアプリケーション
新規事業において特にプロジェクトの最初のうちは仕様自体がフワッとしている場合も多く、最初から完璧な設計を行うのは難しいと思います。少しずつ仕様が明確に、あるいは増えていった結果、最初は綺麗に保てていたアプリケーションコードが気づいたらとても複雑になってしまっていた・・・ということは結構あるあるなのではないでしょうか。
特に要件が徐々に変化していった結果、一つのモデルが様々な状態を持つようになり、結果としてファットモデルになることが多いのではと思います。データ移行を考えなくてよいリリース前のタイミングでモデリングを見直してファットな状態を解消することは、リリース後の開発速度を下げない・負債を残さないという意味でとても大切です。
実はWoW!me(ワオミー) も、複数の状態が入り組んで結構辛い状態になっていたのを、開発中のあるタイミングでモデリングを大きく見直して一気に作り変えるということを行いました。
以下で実際のアプリケーションに様々な要件を追加してモデルが太っていく様子を追体験して、実際にどのようにすればよいか考えてみましょう。
ラーメン屋をアプリケーションにして考える
WoW!me(ワオミー) の実際のコードやデータ構造を例に出せると一番良いのですが、流石にそうもいかないのでここは似たモデル構造になるラーメン屋をアプリケーションに置き換えて考えてみたいと思います。
食券・先払いのラーメン屋をアプリケーションに置き換えて、色々な要件をどんどん足していってみましょう。ここでは購入まわりのモデリングのみ考えて、支払とか商品マスターとかは一旦置いて考えてみます。
一回の注文で、ラーメンやトッピングなどを注文できる。
Order
を注文の単位として、その下に OrderItem
がくっつく形になりそうです。(OrderItem
にラーメンや煮玉子が入るイメージ)
class Order < ApplicationRecord
has_many :order_items
def set_amount
self.total_amount = order_items.sum(:price)
end
end
class OrderItem < ApplicationRecord
belongs_to :order
enum kind: { ramen: 0, topping: 10, chahan: 20 }
end
ラーメンの食券を買ったあと、白飯とかも追加で食券を買えるようにしたいね
なるほど。 Order
が注文の単位だとすると、その上にお客さんの来店という概念がくっつきそうです。
いい名前が思いつかないので、 RamenTransaction
という名前にしておきます。
class RamenTransaction < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
has_many :order_items
belongs_to :ramen_transaction
validates: :order_items, presence: true
def set_amount
self.total_amount = order_items.sum(:price)
end
end
class OrderItem < ApplicationRecord
belongs_to :order
enum kind: { ramen: 0, topping: 10, chahan: 20 }
end
なんかまだ大丈夫そう。しかし、実際のアプリケーション開発ではここに様々なビジネス要件が追加されていきます。
以下で実際に色々な要件を想定して実装を考えて行きましょう。
最初の注文は絶対ラーメン頼んで貰う縛りをいれたいね!
まず最初の注文かどうかを判定するカラム primary
という boolean
のカラムを Order
に足してみましょう。
class RamenTransaction < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
has_many :order_items
# 新しくこの関連を定義して・・・
has_many :ramen_items, -> { ramen }, class_name: OrderItem
belongs_to :ramen_transaction
validates :order_items, presence: true
# ramen_itemsが存在することの確認を行う
validates :ramen_items, presence: true, if: :primary?
def set_amount
self.total_amount = order_items.sum(:price)
end
end
class OrderItem < ApplicationRecord
belongs_to :order
enum kind: { ramen: 0, topping: 10, chahan: 20 }
end
あ、でも夜は居酒屋的な営業になるんでラーメンは頼まなくて良いようにしたい
RamenTransaction に kind
カラムを足して、ランチかディナーか判定するようにしてみましょう。
class RamenTransaction < ApplicationRecord
# ランチがディナーかを判定するenumを追加
enum kind: { lunch: 0, dinner: 10 }
has_many :orders
end
class Order < ApplicationRecord
has_many :order_items
has_many :ramen_items, -> { ramen }, class_name: OrderItem
has_many :tipping_items, -> { topping }, class_name: OrderItem
belongs_to :ramen_transaction
validates :order_items, presence: true
# ランチで最初のオーダーのときだけラーメンが存在することを確認する
validates :ramen_items, presence: true, if: :primary_at_lunch?
validate :topping_must_with_ramen
delegate :lunch?, to: :ramen_transaction
def set_amount
self.total_amount = order_items.sum(:price)
end
private
# ランチでかつ最初のオーダを判定するメソッドを追加
def primary_at_lunch?
primary? && lunch?
end
end
夜の営業ではお通しを必ず頼まないといけないようにしたい
OrderItem
の enum
に、 otoshi
を追加して、これも validation
に追加しましょう。
class RamenTransaction < ApplicationRecord
enum kind: { lunch: 0, dinner: 10 }
has_many :orders
validates :orders, presence: true
end
class Order < ApplicationRecord
has_many :order_items
has_many :ramen_items, -> { ramen }, class_name: OrderItem
has_many :topping_items, -> { topping }, class_name: OrderItem
has_many :otoshi_items, -> { otoshi }, class_name: OrderItem
belongs_to :ramen_transaction
validates :order_items, presence: true
validates :ramen_items, presence: true, if: :primary_at_lunch?
# ディナーでかつ最初のオーダのときだけ、お通しが存在することを確認する
validates :otoshi_items, presence: true, if: :primary_at_dinner?
validate :topping_must_with_ramen
delegate :lunch?, :dinner?, to: :ramen_transaction
def set_total_amount
self.total_amount = order_items.sum(:price)
end
private
def primary_at_lunch?
primary? && lunch?
end
# ディナーでかつ最初のオーダを判定するメソッドを追加
def primary_at_dinner?
primary? && dinner?
end
end
class OrderItem < ApplicationRecord
belongs_to :order
# enumにお通しを追加
enum kind: { ramen: 0, topping: 10, chahan: 20, otoshi: 30 }
end
お昼のラーメンは最初の注文だけラーメン100円OFFにしたい
ramen_transaction
が lunch
だったら、 100円引くようにするかーうーん…
class Order < ApplicationRecord
has_many :order_items
has_many :ramen_items, -> { ramen }, class_name: OrderItem
has_many :topping_items, -> { topping }, class_name: OrderItem
has_many :otoshi_items, -> { otoshi }, class_name: OrderItem
belongs_to :ramen_transaction
validates :order_items, presence: true
validates :ramen_items, presence: true, if: :primary_at_lunch?
validates :otoshi_items, presence: true, if: :primary_at_dinner?
validate :topping_must_with_ramen
delegate :lunch?, :dinner?, to: :ramen_transaction
def set_total_amount
self.total_amount =
# ランチで最初の注文のときだけ、ラーメンの数 * 100円を合計から引く
if primary_at_lunch?
order_items.sum(:price) - ramen_items.size * 100
else
order_items.sum(:price)
end
end
private
def primary_at_lunch?
primary? && lunch?
end
def primary_at_dinner?
primary? && dinner?
end
end
夜は追加で注文されたトッピング10円引きにしたいんやけどいける?
class Order < ApplicationRecord
has_many :order_items
has_many :ramen_items, -> { ramen }, class_name: OrderItem
has_many :topping_items, -> { topping }, class_name: OrderItem
has_many :otoshi_items, -> { otoshi }, class_name: OrderItem
belongs_to :ramen_transaction
validates :order_items, presence: true
validates :ramen_items, presence: true, if: :primary_at_lunch?
validates :otoshi_items, presence: true, if: :primary_at_dinner?
validate :topping_must_with_ramen
delegate :lunch?, :dinner?, to: :ramen_transaction
def set_total_amount
self.total_amount =
if primary_at_lunch?
order_items.sum(:price) - ramen_items.size * 100
# ディナーで追加の注文のときだけ、トッピングの数 * 10円を合計から引く
elsif additional_at_dinner?
order_items.sum(:price) - topping_items.size * 10
else
order_items.sum(:price)
end
end
private
def additional_at_dinner?
!primary && dinner
end
def primary_at_lunch?
primary? && lunch?
end
def primary_at_dinner?
primary? && dinner?
end
end
辛いです・・・
段々辛みが増してきましたね。ここで更にお昼だけ使えるクーポンとか、最初の注文は餃子タダになるとかやりだすとさらなるさらなる辛みが見えますね。さらにこの状態では単純に実装の見通しが悪いということ以外に、以下のような問題があります。
- ディナータイムの処理を加えると、ランチタイムの処理にも影響を与える可能性がある
- 朝食時に特別な処理をしたくなると複雑度が更に上る(拡張性が低い)
モデルに状態を持たせず、分割していこう
前置きが長くなりましたが、この記事の主題です。
最初はシンプルで必要十分なモデル構造に見えましたが、ビジネス要件を実装していくうちに段々と辛い感じになってしまいました。今後、ビジネス要件が追加される度にOrderのモデルがファットになっていく未来が見えます。
このような場合アプリケーション側で頑張らず、 まず モデルを整理して適切に分割していく ことを頑張ってみると、アプリケーションのコードをすっきりさせることができます。
似て非なるものを一緒にしない
今回の例だと最初に設計したモデリングでは以下のような課題があることが分かります。
-
RamenTransaction
はlunch
とdinner
によってhas_many
しているOrder
の振る舞いが大きく異る、 -
Order
は 最初のOrder
か(primary
か否か)によって振る舞いが大きく異る
ということが見えてきます。最初は一つのモデルとして表現できていたとしても、ビジネス要件が追加されていった結果、あるカラムの状態に応じて異なる振る舞いを持たせる必要が出て来る場合は多いです。これを今回はカラムで振舞いを変えるのではなく、モデル自体を愚直に分割していってみましょう。
ランチの場合
class LunchTransaction < ApplicationRecord
has_one :primary_order, class_name: LunchTransaction::PrimaryOrder
has_many :additional_orders, LunchTransaction::AdditionalOrder
validates :primary_order, presence: true
end
# 最初のオーダー
class LunchTransaction::PrimaryOrder < ApplicationRecord
has_many :ramen_items, class_name: LunchTransaction::PrimaryOrder::RamenItem
has_many :topping_items, class_name: LunchTransaction::PrimaryOrder::ToppingItem
belongs_to :lunch_transaction
validates :ramen_items, presence: true
def set_total_amount
self.total_amount = ramen_items.sum(:price) - ramen_items.size * 100 + topping_items.sum(:sum)
end
end
class LunchTransaction::PrimaryOrder::RamenItem < ApplicationRecord
belongs_to :primary_order
end
class LunchTransaction::PrimaryOrder::ToppingItem < ApplicationRecord
belongs_to :primary_order
end
# 追加のオーダー
class LunchTransaction::AdditionalOrder < ApplicationRecord
has_many :ramen_items, class_name: LunchTransaction::PrimaryOrder::RamenItem
has_many :topping_items, class_name: LunchTransaction::PrimaryOrder::ToppingItem
belongs_to :lunch_transaction
def set_total_amount
self.total_amount = ramen_items.sum(:price) + topping_items.sum(:sum)
end
end
class LunchTransaction::AdditionalOrder::RamenItem < ApplicationRecord
belongs_to :additional_order
end
class LunchTransaction::AdditionalOrder::ToppingItem < ApplicationRecord
belongs_to :additional_order
end
ディナーの場合
class DinnerTransaction < ApplicationRecord
has_one :primary_order, class_name: DinnerTransaction::PrimaryOrder
has_many :additional_orders, DinnerTransaction::AdditionalOrder
end
# 最初のOrder
class DinnerTransaction::PrimaryOrder < ApplicationRecord
belongs_to :dinner_transaction
has_many :ramen_items, class_name: LunchTransaction::PrimaryOrder::RamenItem
has_many :topping_items, class_name: LunchTransaction::PrimaryOrder::ToppingItem
has_many :otoshi_items, class_name: LunchTransaction::PrimaryOrder::OtoshiItem
validates :otoshi_items, presence: true
def set_total_amount
self.total_amount = ramen_items.sum(:price) + topping_items.sum(:price) + otoshi_items.sum(:price)
end
end
class DinnerTransaction::PrimaryOrder::RamenItems < ApplicationRecord
belongs_to :primary_order
end
class DinnerTransaction::PrimaryOrder::ToppingItem < ApplicationRecord
belongs_to :primary_order
end
class DinnerTransaction::PrimaryOrder::OtoshiItem < ApplicationRecord
belongs_to :primary_order
end
# 追加のOrder
class DinnerTransaction::AdditionalOrder < ApplicationRecord
belongs_to :dinner_transaction
has_many :ramen_items, class_name: LunchTransaction::PrimaryOrder::RamenItem
has_many :topping_items, class_name: LunchTransaction::PrimaryOrder::ToppingItem
has_many :otoshi_items, class_name: LunchTransaction::PrimaryOrder::OtoshiItem
def set_total_amount
self.total_amount = ramen_items.sum(:price) + topping_items.sum(:price) + otoshi_items.sum(:price) - topping_items.size * 10
end
end
class DinnerTransaction::AdditionalOrder::RamenItem < ApplicationRecord
belongs_to :additional_order
end
class DinnerTransaction::AdditionalOrder::ToppingItem < ApplicationRecord
belongs_to :additional_order
end
class DinnerTransaction::AdditionalOrder::ChahanItem < ApplicationRecord
belongs_to :additional_order
end
如何でしょうか、モデルの数は沢山増えましたが、個々のモデルからはif文が消えアプリケーションのコードはシンプルに保つ事ができていることが分かると思います。この状態であれば、以下のような要求への対応も既存の実装への影響を意識せずに追加していくことができそうです。
- ディナータイムの最初のオーダーだけ●●キャンペーンをやりたい
- 朝食メニューを提供することにしたい
HogeItem
は共通部分をDRYに書くために、共通部分を別モジュールにconcernで切り出したり、ポリモーフィック関連を使いたくなりますが、これも安易に使うべきでないという話をCTOの @akiray03 が明日のアドベントカレンダーで語ってくれるはずです。
DinnerTransaction
、 LunchTransaction
は振る舞い同じだし同じモデルにすればよくね?という話はあるかもしれません。でも例えば「夜の営業は後払いにしたいんだよねー」という要件が出てきたとするとどうでしょう。同じモデルだとかなりの辛みがありそうですね…
まとめと感想
複数の同じようなテーブルが作成されることに懸念を抱く方もいらっしゃると思います(実際に私も少し思っていました)。しかしRailsのようにテーブルとモデルが1対1で強力に結びつくフレームワークの場合には特に、テーブルのカラムがたとえほぼ同じになったとしても振る舞いの違うモデルは別のテーブルとして表現していったほうがメリットは大きいと感じています。
ファットモデルの解決作として、concern
に切り出したりサービスオブジェクトを作ったり…とついついアプリケーションコードで頑張ろうとしてしまいますが、根本のデータ構造を見直すことが一番のファットモデル解決策なのだなと身をもって学びました。
パフォーマンスの問題や、一覧画面が必要、など要件によっては今回のようなモデル分割が実施できない場合は普通にあると思いますが、ファットモデルに苦しんでいる場合にはまずモデリングから見直してみるというのは如何でしょうか。
リリースしてからデータの溜まったテーブルを移行していくのはとっても大変なので、モデリングの見直しはできればリリース前にやっておきたいですね!
クラウドワークスでは 一緒にラーメン事業を立ち上げたい 新規事業にも挑戦したいエンジニアを募集しています!
明日は @akiray03 さんによる「Railsで大規模アプリケーションを正しく設計するために避けるべき3つの機能」です。