Help us understand the problem. What is going on with this article?

ラーメン屋で考えるRailsのデータモデリング

More than 3 years have passed since last update.

cb2088a9-31c9-c7d1-4ce5-9d6ba92a0495.png

はじめに

この記事は 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

夜の営業ではお通しを必ず頼まないといけないようにしたい

OrderItemenum に、 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_transactionlunch だったら、 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のモデルがファットになっていく未来が見えます。

このような場合アプリケーション側で頑張らず、 まず モデルを整理して適切に分割していく ことを頑張ってみると、アプリケーションのコードをすっきりさせることができます。


似て非なるものを一緒にしない

今回の例だと最初に設計したモデリングでは以下のような課題があることが分かります。

  • RamenTransactionlunchdinnerによって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 が明日のアドベントカレンダーで語ってくれるはずです。

DinnerTransactionLunchTransaction は振る舞い同じだし同じモデルにすればよくね?という話はあるかもしれません。でも例えば「夜の営業は後払いにしたいんだよねー」という要件が出てきたとするとどうでしょう。同じモデルだとかなりの辛みがありそうですね…

まとめと感想

複数の同じようなテーブルが作成されることに懸念を抱く方もいらっしゃると思います(実際に私も少し思っていました)。しかしRailsのようにテーブルとモデルが1対1で強力に結びつくフレームワークの場合には特に、テーブルのカラムがたとえほぼ同じになったとしても振る舞いの違うモデルは別のテーブルとして表現していったほうがメリットは大きいと感じています。

ファットモデルの解決作として、concern に切り出したりサービスオブジェクトを作ったり…とついついアプリケーションコードで頑張ろうとしてしまいますが、根本のデータ構造を見直すことが一番のファットモデル解決策なのだなと身をもって学びました。

パフォーマンスの問題や、一覧画面が必要、など要件によっては今回のようなモデル分割が実施できない場合は普通にあると思いますが、ファットモデルに苦しんでいる場合にはまずモデリングから見直してみるというのは如何でしょうか。

リリースしてからデータの溜まったテーブルを移行していくのはとっても大変なので、モデリングの見直しはできればリリース前にやっておきたいですね!

クラウドワークスでは 一緒にラーメン事業を立ち上げたい 新規事業にも挑戦したいエンジニアを募集しています!

明日は @akiray03 さんによる「Railsで大規模アプリケーションを正しく設計するために避けるべき3つの機能」です。

suusan2go
フリーランスのWEBエンジニアやっています。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした