いまさらながらファット・モデルの分割について。
過去にいろんな失敗をしたけどやっと落ち着いてきた感があるのでファット・モデルの分割について書きたくなりました。
サンプルはRailsで書いていますが、言語・フレームワークに関わらずオブジェクト指向言語では同じように実装できます。いつもはJavaとPHPの人なのでコードの文法が間違っていたらスルーしてください。
クラスを分割する目的は「オブジェクトを疎結合にしてテスタビリティを確保して品質を向上し変更に強いソフトウェアを構築するため」です。
クラス設計のルール
「ファットモデルは目指すべきものなので、ファットモデル自体が問題ではない」という意見もあるようですが、個人的な意見としてはやはり大きなクラスは分割したいです。
クラス設計をするときに重要視しているのは以下のルールです。
- オープン・クローズドの原則
- 単一責任の原則
- デメテルの法則
- データと処理を近くに置く
これらに従うとどうしてもファット・モデルは許容できません。
プロジェクトの中で肥大化するモデルはいつもだいたい決まっています。ウェブサービスのUser
(ユーザ)モデルだったり、ECサイトのOrder
(注文)モデルだったり、業務管理システムのProject
(案件)モデルだったりします。名前からわかるようにシステムの利用者が最も関心を持っている上位のモデルです。いつもこれら上位モデルに処理が集まり肥大化していくことが多いのではないでしょうか。
ファット・モデルをPOROに分割する
では実際にモデルを分割するときにどのような手順でやっているのか。上位モデルのクラスが大きくなったと感じたら以下のルールで分割しています。
- PORO(Plain Old Ruby Object)で新しいクラスを定義する
- 上位モデルからPOROオブジェクトを取得する
- 実装者が PORO.new でオブジェクトを生成しない
- コンストラクタに上位モデルを渡す
- POROのインスタンス変数として保持する
「クラス分割の基準は実装者の考え方に個人差があり無秩序になりやすい」という意見には100%同意できます。そのため分割の基準としてこのルールを適用しています。
「通知付きポスト」の再実装
それではルールに従って「ブログを投稿して友達に通知をする」機能を実装してみます。元のコードは「Railsの太ったモデルをダイエットさせる方法について - メドピア開発者ブログ」です。
メドピアさんの例では PostWithNotifications
クラスに分割してクラスメソッド create!
を呼び出していました。実装箇所はUser
モデルです。
# メドピアさんのコード抜粋
class User < ApplicationRecord
def create_post_with_notifications!(body)
PostWithNotifications.create!(creator: self, body: body)
end
end
まずは基本となるPost
とUser
モデルの作成です。元サイトでは 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
クラスを定義することで通知作成の処理をUser
やPost
モデルから分割することに成功し、かつトランザクションスクリプト的なモデルにもなりませんでした。
今後、通知に関する仕様変更は PostNotification
だけに閉じた変更になり、新しい仕様、例えば ActiveJob
を使った非同期通知やメール通知などもこの中だけで完結できるでしょう。
PostNotificationクラスの見つけ方
この手の議論でいつも課題になるのはモデル分割の指針です。「オーケー、この例はわかったけどPushNotification
クラスは空から降ってきたの?」というやつです。
基本的には上記の3ルールを満たせばうまく分割できています。また、3つのルールの他に隠れた4つ目のルールがあります。
- PORO(Plain Old Ruby Object)で新しいクラスを定義する
- 上位モデルからPOROオブジェクトを取得する
- コンストラクタに上位モデルを渡す
- 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