LoginSignup
19
14

More than 5 years have passed since last update.

やっぱりファット・モデルは分割したい

Posted at

いまさらながらファット・モデルの分割について。

過去にいろんな失敗をしたけどやっと落ち着いてきた感があるのでファット・モデルの分割について書きたくなりました。
サンプルはRailsで書いていますが、言語・フレームワークに関わらずオブジェクト指向言語では同じように実装できます。いつもはJavaとPHPの人なのでコードの文法が間違っていたらスルーしてください。

クラスを分割する目的は「オブジェクトを疎結合にしてテスタビリティを確保して品質を向上し変更に強いソフトウェアを構築するため」です。

クラス設計のルール

ファットモデルは目指すべきものなので、ファットモデル自体が問題ではない」という意見もあるようですが、個人的な意見としてはやはり大きなクラスは分割したいです。

クラス設計をするときに重要視しているのは以下のルールです。

  • オープン・クローズドの原則
  • 単一責任の原則
  • デメテルの法則
  • データと処理を近くに置く

これらに従うとどうしてもファット・モデルは許容できません。

プロジェクトの中で肥大化するモデルはいつもだいたい決まっています。ウェブサービスのUser(ユーザ)モデルだったり、ECサイトのOrder(注文)モデルだったり、業務管理システムのProject(案件)モデルだったりします。名前からわかるようにシステムの利用者が最も関心を持っている上位のモデルです。いつもこれら上位モデルに処理が集まり肥大化していくことが多いのではないでしょうか。

ファット・モデルをPOROに分割する

では実際にモデルを分割するときにどのような手順でやっているのか。上位モデルのクラスが大きくなったと感じたら以下のルールで分割しています。

  1. PORO(Plain Old Ruby Object)で新しいクラスを定義する
  2. 上位モデルからPOROオブジェクトを取得する
    • 実装者が PORO.new でオブジェクトを生成しない
  3. コンストラクタに上位モデルを渡す
    • POROのインスタンス変数として保持する

「クラス分割の基準は実装者の考え方に個人差があり無秩序になりやすい」という意見には100%同意できます。そのため分割の基準としてこのルールを適用しています。

「通知付きポスト」の再実装

それではルールに従って「ブログを投稿して友達に通知をする」機能を実装してみます。元のコードは「Railsの太ったモデルをダイエットさせる方法について - メドピア開発者ブログ」です。

メドピアさんの例では PostWithNotifications クラスに分割してクラスメソッド create! を呼び出していました。実装箇所はUserモデルです。

# メドピアさんのコード抜粋
class User < ApplicationRecord
  def create_post_with_notifications!(body)
    PostWithNotifications.create!(creator: self, body: body)
  end
end

まずは基本となるPostUserモデルの作成です。元サイトでは Post クラスが出てきませんでしたが、あとで必要になるので定義しておきます。

class Post < ApplicationRecord
  belongs_to :user
end
class User < ApplicationRecord
  has_many :posts
  has_many :friends # posts作成時に友達に通知する
  has_many :notifications # 通知
end

コントローラの中で posts.create! を使って Post を作成します。

def create
  ActiveRecord::Base.transaction do
    user.posts.create!(body: params[:body])
  end
end

次に通知機能を実装します。ここでは先に挙げた分割ルールを満たすように PostNotification クラスを定義して通知処理を分割してみます。オブジェクトは Post.notification メソッドから取得します。

class PostNotification # 1. ActiveRecord::Baseを継承しないPORO
  def initialize(post) # 2. 上位モデル(Post)を引数に取る
    @post = post
  end
  def created! # posts作成を通知する
    @post.user.friends.each do |friend|
      friend.notifications.create!("#{creator.name}さんが投稿しました")
    end
  end
end

class Post
  def notification # 3. 上位モデルからオブジェクトを取得
    PostNotification.new(self)
  end
end

Postを作成して通知を送信するときはコントローラから呼び出します。

def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    post.notification.created!
  end
end

コントローラで posts.create!notification.created!を呼び出しているのには理由があります。仕様が変更され一括でPostを作成する機能が追加されました。一括作成なので通知は不要です。

def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    post.notification.created!
  end
end
# 一括作成フォーム
def batch_create
  ActiveRecord::Base.transaction do
    params[:bodies].each do |body|
      user.posts.create!(body: params[:body]) # 一括作成のときは通知しない
    end
  end
end

このように「通知する・しない」の仕様は境界にあることが多いのでコントローラで呼び出しています。「境界」というのはユーザとシステムの接点です。この例のように「1件作成」「一括作成」の他に「定期バッチで作成するときは通知しない」といったような処理にも対応できます。

これをUserモデルに追加すると、通知を送るcreate_post_with_notificationや、通知を送信しない *_without_notificationや更新時に通知を送るupdate_with_notificationのようなメソッドが増えて肥大化していきます。

更新や削除時にも通知を送りたいときはPostNotificationクラスにupdated!deleted!メソッドを追加します。さらに通知を受信しない設定にしているユーザは除外してみましょう。

# 他の通知を追加して対象ユーザも絞り込む
class PostNotification
  def initialize(post)
    @post = post
  end
  def created!
    notify "#{@post.user.name}さんが投稿しました"
  end
  def updated!
    notify "#{@post.user.name}さんが更新しました"
  end
  def deleted!
    notify "#{@post.user.name}さんが削除しました"
  end

  def friends
    @post.user.friends.select do |friend|
      friend.enable_notification # 通知を受け取る設定にしているfriendだけ
    end
  end
  def notify(message)
    friends.each do |friend|
      friend.notifications.create!(message) # 友達に通知を送信
    end
  end
end

このようにPostNotificationクラスを定義することで通知作成の処理をUserPostモデルから分割することに成功し、かつトランザクションスクリプト的なモデルにもなりませんでした。

今後、通知に関する仕様変更は PostNotification だけに閉じた変更になり、新しい仕様、例えば ActiveJob を使った非同期通知やメール通知などもこの中だけで完結できるでしょう。

PostNotificationクラスの見つけ方

この手の議論でいつも課題になるのはモデル分割の指針です。「オーケー、この例はわかったけどPushNotificationクラスは空から降ってきたの?」というやつです。

基本的には上記の3ルールを満たせばうまく分割できています。また、3つのルールの他に隠れた4つ目のルールがあります。

  1. PORO(Plain Old Ruby Object)で新しいクラスを定義する
  2. 上位モデルからPOROオブジェクトを取得する
  3. コンストラクタに上位モデルを渡す
  4. POROのメソッドに引数を渡さない

3つ目の「コンストラクタに上位モデルを渡す」の裏返しなのですが、POROのメソッドに引数を渡さないような設計にしています。

当初、「通知付きポストの再実装」では User モデルをコンストラクタに渡すPOROを考えました。Userモデルにcreate_post_with_notifications!メソッドがあったので自然ですね。
ただ、このように分割すると通知対象の Post がわからないため created! の引数に Post が必要になってしまい4つ目のルールに違反します。

# Userモデルから委譲されるパターン
class UserNotification
  def initialize(user)
    @user = user
  end
  def created!(post)
    # @userを持っていてもどのpostに対する通知なのかわからないのでしょうがなくpostを受け取る...
  end
end

# コントローラ
def create
  ActiveRecord::Base.transaction do
    post = user.posts.create!(body: params[:body])
    user.notification.created!(post) # 引数が必要になる
  end
end

PostNotification は通知対象の Post モデルをインスタンス変数として保持しているためデータと処理が近くにあります。一方、UserNotification は操作対象のデータが外部から渡されるため、通知を作成するだけのトランザクションスクリプト、サービスクラス的な実装になっています。操作対象のモデルを引数として渡すとトランザクションスクリプト的なモデルになりやすいので避けています。

ここではじめて UserNotification クラスが間違っていると気が付き、引数として受け取ってた Post モデルをコンストラクタに移動させた PostNotification クラスを見つけました。

クラス数が多くならない?

はい、なります。models/ 以下に全て置いています。

実際のプロジェクトで使用している Order(注文)のイメージを共有します。Order には以下のようなデータ、機能があります。

  • 注文内容
  • 売上
  • 仕入
  • 配送
  • 売上サマリ表を管理者が見る
  • 売上推移グラフを管理者が見る

「売上」や「配送」「仕入」の起点は「注文」なので Order モデルが肥大化してきます。これを分割の4ルールに従って、人が認識しているモデルに分割していくと Order モデルには委譲用のメソッドがどんどん増えていきます。

class Order < ActiveRecord::Base
  # (snip: 注文者情報や注文内容,ステータスなど一般的な情報)
  def sales
    OrderSales.new(self) # 売上に関する情報, 税額計算や割引, 請求書など
  end
  def payments
    OrderPayments.new(self) # 仕入れに関する情報
  end
  def delivery
    OrderDelivery.new(self) # 配送に関する情報
  end
  # コレクションメソッド
  # 売上げ集計表のために月別売上げを計算する
  def self.sales_table
    OrderSalesTable.new(all)
  end
  # 売上のグラフを表示するためのデータセットを作成する
  def self.sales_chart
    OrderSalesChart.new(all)
  end
end

コントローラではモデルを以下のように使います。

# 売上画面コントローラ
class SalesController < ApplicationController
  # 売上げ情報を表示する
  def show
    @sales = Order.find(params[:id]).sales
  end

  # 売上げ表を表示するためのAjax API
  def table_dataset
    orders = Order.where(ordered_on: last_month) # 先月の注文データを取得
    table = orders.sales_table
    render json: {
      headers: table.headers,
      rows: table.rows
    }
  end

  # Chart.jsでグラフを表示するためのAjax API
  def chart_dataset
    orders = Order.where(ordered_on: last_month)
    chart = orders.sales_chart
    render json: {
      labels: chart.labels,
      datasets: chart.total_amount_dataset, # 税込み合計金額のデータセット
      # datasets: chart.total_amount_without_tax_dataset, # 税抜き金額のグラフはこっち
    }
  end
end

このような分割方法、委譲メソッドが正しいのかどうかわかりませんが、いまのところこの構成でテスタビリティのある小さなクラスに分割できています。

サービスクラスについて

TODO: トランザクションスクリプト的なサービスクラスを使うケースもあるのでそれについても書きたい。
OO

19
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
14