記事を読むのにかかる時間
約10分
結論
コントローラの記述が膨れ上がってしまう、いわゆる**「ファットコントローラ」**について、
最もオーソドックスな解消法として挙げられる「ロジックをモデルに移す」よりシンプルなのは
「テーブル設計を工夫し、そもそもコントローラに何も書かなくて良い状態を作る」
ことだと思います。
今回、個人開発でその体験ができたので共有します。
この記事から得られること
実装の前段階にあたるテーブル設計を工夫することで
コードをシンプルにする方法を一つ知ることができる。
必要なRailsの予備知識
・一般的なCRUDのアクション
・多対多の関連付け(has_many :through
とかbelongs_to
)の概要
・enum
目次
結論
開発したサービス
テーブル設計で工夫したこと
詳しく
まとめ
最後に
開発したサービス
11/25からAmazonPrimeVideoで配信される「バチェラー・ジャパン シーズン4」の優勝予想ゲームです。
ポイント制のゲームで、「途中で予想を変えると減点だが、予想した人が脱落するともっと減点される」
という減点法を採用しています。
バチェラー・ジャパンとは
アメリカの人気恋愛リアリティ番組「The Bachelor(原題)」の日本版。バチェラーとは独身男性の意で、番組では1人の幸運なバチェラーが、一般応募で集まった15人の女性とのグループ・パーティーや2人きりのロマンチックなデートを経て、最終的に自分に最もふさわしい女性1人を選び出す「婚活サバイバルゲーム」。
今回は全4話で、各放送回で徐々に候補者(=番組の女性メンバー)が減っていくイメージです。
ポイント
ここでポイントなのは、
以下のユーザーに対して減点が行われる必要があるということです。
①BETする候補者を変更したユーザー
②管理者が候補者を脱落させたとき、その候補者にBETしていたユーザー
テーブル設計で工夫したこと
今回のテーブル設計で唯一意識したのは、ユーザーの得点が
「操作によって変動する」のではなく、「履歴によって算出される」設計にする
ということだけです。
以降で詳細の説明をしていきます。
詳しく
「操作によって変動する」とは
今回の処理を普通にやろうとすると、テーブル設計は以下のような感じになるかと思います。
# 本サービスのユーザー
class User < ApplicationRecord
belongs_to :candidate
end
# バチェラーに参加する女性候補者
class Candidate < ApplicationRecord
has_many :users
# 候補者の生き残り情報
enum :status { active: 0, dropout: 1 }
end
一人の候補者が多数のユーザーにBETされるのでこのような関連付けになると思います。
そしてコントローラのロジックは一般的に以下のような感じになると思います。
①BETする候補者を変更したユーザーへの減点(-10点)
class UsersController < ApplicationController
# (中略)
def update
@user = User.find(params[:id])
@user.assign_attributes(user_params)
if @user.save
@user.points -= 10 # 減点処理
redirect_to ...
else
render ...
end
end
private
def user_params
require(:user).permit(..., :candidate_id)
end
end
②管理者が候補者を脱落させたとき、その候補者にBETしていたユーザーへの減点(-20点)
class Admin::CandidatesController < ApplicationController
# (中略)
def update
@candidate = Candidate.find(params[:id])
@candidate.dropout!
@candidate.users.each {|user| user.points -= 20 } # 減点処理
redirect_to ...
end
# (中略)
end
上記の①はユーザーの**「操作」、②は管理者の「操作」によって
Userのpointsカラムの値が「変動」**する設計であることが分かると思います。
これが「操作によって変動する」設計です。
減点処理は一行で記述できており、シンプルなCRUDの記述に近いといえば近いのですが、
以下の点で可読性に改善の余地があると考えられます。
・一つのアクションに二つの関心事がある
(①はuser.candidate
とuser.points
、②はcandidate.status
とuser.points
)
・②はCandidateのコントローラなのにUserに関する処理が記述されている
どんなにロジックをモデルに移したとしても、最低一行はコントローラを肥やすことになるのです。
「履歴によって算出される」とは
一方、今回採用したテーブル設計はこんな感じです。
class User < ApplicationRecord
has_many :bettings
has_many :candidates, through: :bettings
end
# UserとCandidateの中間モデル
class Betting < ApplicationRecord
belongs_to :user
belongs_to :candidate
end
class Candidate < ApplicationRecord
has_many :bettings
has_many :users, through: :bettings
has_and_belongs_to_many :episodes
end
# 番組の放送回(今回は全4回なので、全部で4つのインスタンスが存在)
class Episodes < ApplicationRecord
has_and_belongs_to_many :candidates
end
※has_and_belongs_to_many
を使ったことがない人は、
ここではhas_many :through
と同じ多対多の簡易版と理解していればOKです。
さっきと違う大きなポイントが三つあります。
一つ目はCandidateのstatusカラムを廃止し、Episodeテーブルを採用している点です。
これは、active
or dropout
だけでなく、いつまでactive
だったのか?の
「履歴」まで残すためです。
候補者は自身が出演する放送回を所有し、
逆に放送回は各回に出演する候補者を所有するイメージです。
(例えばある候補者が2つのEpisodeインスタンスを所有している場合、その候補者は
2話までは出演していたがそこで脱落し、3話には出られなかったということを表します)
二つ目はUserとCandidateを一対多から多対多に変更している点です。
これは単純にどのユーザーがどの候補者にBETしているかだけでなく、その履歴まで明確に残すためです。
詳しくは後述します。
三つ目はUserモデルのpointsカラムを廃止したことです。
ここも詳しくは後述しますが、結論からいうとpoints
はカラムではなく
モデルのインスタンスメソッドとしました。
こうすると、コントローラは以下のようになります。
class BettingsController < ApplicationController
# (中略)
def create
@betting = Betting.new(betting_params)
if @betting.save
redirect_to ...
else
render ...
end
end
# (中略)
end
class Admin::EpisodesController < ApplicationController
# (中略)
def update
@episode = Episode.find(params[:id])
@episode.assign_attributes(episode_params)
if @episode.save
redirect to ...
else
render ...
end
end
private
def episode_params
params.require(:episode).permit(..., candidate_ids: [])
end
end
完全にCRUDに関する記述だけになったと思います。
BETについては、先ほどはupdateだったのが今回はcreateに変わっている点に注目してください。
BETは変更するのではなく履歴を累積するという考え方にシフトしています。
Admin::EpisodesControllerにおいても、「各放送回にどの候補者が出演したか」を履歴として残しているだけです。
管理画面はこんな感じで、次の出演が決まった候補者にチェックを入れてUpdateするだけでOKです。
つまりコントローラで行われているのはあくまで履歴の累積だけ、ということになります。
では、肝心の減点処理はどこで行うのか?
これは先ほど少し触れたUserモデルのインスタンスメソッドで行っています。
class User < ApplicationRecord
# (中略)
def points
points = 100
# BETの変更(2回目以降のBET)に対する減点
points -= (bettings.count - 1) * 10
#脱落した候補者にBETしていることに対する減点(1話時点)
points -= 20 if current_candidate.episodes.count < 2
#脱落した候補者にBETしていることに対する減点(2話時点)
points -= 20 if current_candidate.episodes.count < 3
#脱落した候補者にBETしていることに対する減点(3話時点)
points -= 20 if current_candidate.episodes.count < 4
return points
end
private
def current_candidate # 最後にBETした候補者
bettings.order(created_at: :desc).first.candidate
end
end
こうすることで、まるでpointsカラムを呼び出すかのようにuser.points
でユーザーの得点が取得できます。
また、ここでは得点が変動している訳ではなく、
あくまで履歴に応じて算出されているだけ、ということが分かると思います。
これが「履歴によって算出される」設計です。
まとめると、
・コントローラはあくまで「起こった事実の履歴(ユーザーのBETと候補者の脱落)」を残すだけ。
・あとはUserモデルのpointsメソッドが、その履歴を参照して得点を算出する。
という設計になっています。
「履歴によって算出される」設計のメリット
これは単純にコントローラがすっきりするだけではないと思っています。
例えば、BETした候補者が脱落したときに発生する減点を
-20点から-30点に変更したくなったとき、
この設計であればpointsメソッドを数行書き換えるだけで済みます。
(逆に「操作によって変動する」設計だとかなり面倒になることは容易に想像できます)
管理者が候補者の出演/脱落を間違えてインプットしてしまったときも同様です。
先ほどお見せしたように、Episodeインスタンスは管理画面で何度でも修正ができます。
ユーザーのBET履歴が残っているので、各ユーザーが
「いつ何が原因で減点されたか」を把握するのも簡単です。
「履歴によって算出される」設計は、可読性だけでなく
保守性やトレーサビリティにも寄与しているといえるでしょう。
まとめ
・ファットコントローラを解消する手段として、一般的にはコントローラのロジックをモデルに
移行することが最適とされているが、必要なエンティティとその履歴があれば、
そもそもコントローラには何も書かなくていい場合もある。
・何らかの変動するデータを取り扱いたいとき、
ユーザーや管理者の**「操作」によってそのデータを「変動」させるのではなく、
累積された「履歴」を参照しながら都度データを「算出」**する設計にすることで、
可読性や保守性、トレーサビリティが向上する。
最後に
- 記事の分かりにくい箇所や過不足、誤りなどあればコメントいただけると幸いです。
- 記事のコードは分かりやすさ重視のため、実際のコードとは異なる部分があります。
- 本サービスのコードを詳しく知りたい場合はGitHubをご覧ください。