はじめに
プログラミングを始めたばかりの方々、ファットコントローラーをリファクタリングするように言われたことはありませんか?この記事では、初学者の私がファットコントローラーの解消方法の1つ、「モデルに寄せる」についてまとめてみました。
モデルにロジックを寄せることは、よく勧められるアプローチですが、具体的にどのようにモデルに寄せるかがわからない!!!と初学者の私は思いました。そこで、この記事では同じくモデルに寄せる方法がわからないよ!という初学者向けにモデルへの寄せ方を紹介します。
私自身も初学者なので、間違いがあるかもしれません。また、コード例はChat GPTに考えてもらって、正しいか検証して、書いています。できるだけ正確な情報を提供することを心掛けていますが、間違いがあった場合はコメントで教えていただけると嬉しいです🙇♀️🙇♀️
また、さらに良いリファクタリング方法があるかもしれませんが、今回はモデルへの寄せ方の例としてコードをリファクタリングしました。このコードをそのまま使用すると、さらなるリファクタリングが求められる可能性があるので、ご注意ください。
ファットコントローラーとは
ファットコントローラーとは、コントローラーが肥大化している、つまり、コントローラーが多くのロジックや責任を持ちすぎている状態を指します。これは、コードの再利用性や可読性が低下し、メンテナンスが困難になるため問題とされています。
ファットモデルという問題もありますが、初学者はまずファットモデルを目指すと良いと言われています。つまり、コントローラーに書かれたロジックをモデルに寄せることが目標です!
コントローラーに書くべきコードは、scaffoldで自動生成されるコードを参考にすると良いそうです。あの状態に近づけるよう、一緒に頑張りましょう!
基本的なパターン
ファットコントローラーのロジックをモデルに寄せる方法には、以下の基本的なパターンがあります。
-
クエリメソッドをクラスメソッドに置き換える
-
クエリメソッドをスコープに置き換える
-
モデルにインスタンスメソッドを追加する
では、実際にモデルに寄せてみましょう!
1. クエリメソッドをクラスメソッドに置き換える
クラスメソッドは、モデルクラス自体に関連するロジックを扱います。例えば、ある条件に合致するレコードを取得する処理などが該当します。
例えば、以下のファットコントローラーの例を見てみましょう。
class ArticlesController < ApplicationController
def show
@articles = Article.includes(:images).where(user_id: current_user.id).order(id: desc) if user_signed_in?
end
end
このコードは、Article
モデルを使ってデータベースから情報を取得するためのActiveRecordのクエリを組み立てています。
これをモデルに寄せるにはまず、Article
モデルを使って情報を取得しているので、Article
モデルにクラスメソッドを追加します。メソッド内でArticle
を参照している部分をself
に置き換えるだけでOKです。self
は、クラスメソッド内で使用されると、そのクラス自体(=Article)を指します。
クラスメソッドは定義する時self.メソッド名
と書くので注意してください。
class Article < ApplicationRecord
def self.articles_get(user_id)
self.includes(:images).where(user_id: user_id).order(id: desc)
# self.includesのselfは省略可能ですが、わかりやすくするために、書いたままにしています。
# includes(:images).where(user_id: user_id).order(id: desc)
end
end
コントローラーでモデルのクラスメソッドを呼び出す際は、モデル名を使ってクラスメソッドを呼び出します。
class ArticlesController < ApplicationController
def show
@articles = Article.articles_get(current_user.id) if user_signed_in?
end
end
ですが、簡潔なクエリの場合は、scopeを使うことで、簡単にモデルに寄せることができます!
2. クエリメソッドをスコープに置き換える
scopeとは以下のように書きます。
class モデル名 < ApplicationRecord
scope :スコープの名前, -> { 条件式 }
end
条件式の前に引数を書くこともできます。
scope機能のメリット
1.条件式に名前を付けられるので、直感的なコードになる
scope :published, -> { where(published: true) }
Blog.published
で呼び出せる=現在より過去に公開されたブログを取得
2.修正箇所を限定することが出来る
…条件式に変更が出た場合に、修正がscopeメソッドで定義した箇所だけで済む
scope :published, -> { where(published: true).limit(2) }
1のコードをスコープを用いて書いてみましょう!
1のコード
class ArticlesController < ApplicationController
def show
@articles = Article.includes(:images).where(user_id: current_user.id).order(id: desc) if user_signed_in?
end
end
scopeを使うと…
class Article < ApplicationRecord
scope :with_images, -> { includes(:images) }
scope :by_user, ->(user_id) { where(user_id: user_id).order(id: desc) }
end
Article
の絞り込みなので、Article
モデルにwith_images
とby_user
のscopeを記載します。それにより、Article.with_images
のように使うことができるようになります。
by_user
の引数user_id
にcurrent_user.id
を代入すればOKです。
scope
を使うことで、コードがすっきりし、わかりやすくなったと思います。
class ArticlesController < ApplicationController
def show
@articles = Article.with_images.by_user(current_user.id) if user_signed_in?
end
end
enum使用時
また、enum
を使用している場合はenum
のスコープを自動的に作成され、以下のようにstatus
の特定の値を持つ(または持たない)すべてのオブジェクトを検索できるようになります。
enum
とは名前を整数の定数に割り当てるために使われるデータ型のことです。これにより、整数値を意味のある名前で表現することができます。
class Conversation < ActiveRecord::Base
enum :status, { active: 0, archived: 1 }
end
# Conversation.where(status: :active)と同じ
Conversation.active
# Conversation.where(status: :actived)と同じ
Conversation.archived
3. モデルにインスタンスメソッドを追加する
class OrdersController < ApplicationController
def create
@product = Product.find(params[:product_id])
if current_user.can_purchase?(@product)
order = current_user.purchase(@product)
redirect_to orders_path, success: '商品を購入しました'
else
redirect_to products_path, alert: '購入条件に満たしていません。'
end
end
end
if current_user.can_purchase?(@product)
のブロックをモデルに寄せてみましょう。
current_user
はUser
モデルなので、User
モデルに書きます。
current_user
をself
に置き換えます。
if文でtrue
かfalse
かを判定するので、まずreturn
を使ってfalse
を早期に返しましょう。
order = current_user.purchase(@product)
はcurrent_user
をself
に置き換えるだけです。
あとは引数をproduct
にすると、モデルに寄せることができました。
if文のメソッドを定義する場合、true
かfalse
かを判定する必要があることに気をつけましょう。
class User < ApplicationRecord
def purchase_product(product)
return false unless self.can_purchase?(product)
order = self.purchase(product)
true
end
end
コントローラーはモデルに定義したメソッドにもともと何が入っていたのか思い出したら大丈夫です。
self
はcurrent_user
、product
は@product
です。
class OrdersController < ApplicationController
def create
@product = Product.find(params[:product_id])
if current_user.purchase_product(@product)
redirect_to orders_path, success: '商品を購入しました'
else
edirect_to products_path, alert: '購入条件に満たしていません。'
end
end
end
まとめ
クラスメソッドとかインスタンスメソッドとか書きましたが、メソッドをモデルに書くことがわかったら、大丈夫です。
一応説明すると、クラスメソッドはモデルクラス全体で使われる汎用的な処理を定義するために使用され、インスタンスメソッドは特定のレコードに対して操作するメソッドを定義するために使用されます。
つまり、Article
モデルにメソッドを記載すると、クラスメソッドであればArticle
というクラス(例: Article.with_images
)で使うことができ、インスタンスメソッドであれば@articles
というArticle
モデルのインスタンス(例: @articles.with_images
)で使うことができるようになります。
メソッドを作る手順としては、
- コントローラーのコードの中から、モデルに関連するロジックを見つけて、それをモデルのメソッドにまとめる
- その際、クラスメソッドを作成する場合は、モデル名をselfに置き換えて、インスタンスメソッドを作成する場合は、モデルのインスタンスに関連するオブジェクトをselfに置き換える
※selfは省略可能。わかりにくい時は書くと良い。
です。メソッドをモデルに寄せて、脱ファットコントローラーを一緒に目指しましょう!
クラスメソッドよりインスタンスメソッドを定義しよう!
インスタンスメソッドを使ってメソッドチェーンを綺麗に繋いだ方がいいとのことです。
追記で詳細書かせていただきました!
脱ファットコントローラーができたら、次は脱ファットモデルをしていかないといけないそうです…
詳細はRailsのファットモデル 3つの対処法、Railsのファットモデル問題に対処する前に読んでほしい記事にて書かれています。
以上駄文でしたが、読んでくださり、ありがとうございました。
追記
みなさま、こちらの【ガチでコーディング添削】プロが未経験エンジニアのコードを美しくします!の動画はご覧になられましたでしょうか?
私はたった今見たのですが、クラスメソッドではなく、インスタンスメソッドを使い、綺麗に繋いで文章のように読めるメソッドチェーンにした方がいいとのこと!
1.でクエリメソッドをクラスメソッドに変えようと書いたのですが、このままでは良くないようです!!同じコードを例にインスタンスメソッドを定義してみましょう。
では、クラスメソッドをどうインスタンスメソッドに変えていったらいいのでしょうか。それは、まずコントローラー部分でメソッドチェーンにしてみることです!
class ArticlesController < ApplicationController
def show
@articles = Article.includes(:images).where(user_id: current_user.id).order(id: desc) if user_signed_in?
end
end
この@articles
部分をまず変更してみましょう。
@articles = current_user.articles.includes(:images).order(id: desc) if user_signed_in?
に変えることができます。
これでcurrent_userが関連づけられたarticles
を絞り込むことができました。
ちなみに、articles
メソッドはUserモデルにhas_many :articles
とArticleモデルとの関連付けを行うことで、使うことができるメソッドです。
じゃあ、あとは簡単!3. モデルにインスタンスメソッドを追加するで紹介したようにインスタンスメソッドを定義してみましょう。
current_userなので、Userモデルにメソッドを作成します。
class User < ApplicationRecord
def articles_with_images
self.articles.includes(:images).order(id: desc)
end
end
これでインスタンスメソッドを定義することができました!
以下のように、Userモデルのオブジェクトcurrent_user
がこのメソッドを呼び出すことができます。
class ArticlesController < ApplicationController
def show
@articles = current_user.articles_with_images if user_signed_in?
end
end
わからないメモ🗒️
.where(user_id: current_user.id)
のようにwhere
を使っているクエリメソッドは`current_user'みたいに、先にオブジェクトを作っておくと、いいのかな🤔
先にオブジェクトを作ってたら、インスタンスメソッドになるよね。
current_user
のような絞り込みをしないときとかは、クラスメソッドじゃなくて、scopeを使うようにしたらいいのかなと思いました!
もし、current_user
で絞り込んだ記事をインスタンスメソッドではなく、scopeで定義するなら、以下のようになります。
# app/controllers/article_controller.rb
class ArticlesController < ApplicationController
def show
@articles = current_user.articles.includes(:images).order(id: desc) if user_signed_in?
end
end
# scopeを定義して、モデルに寄せると…
# app/models/article.rb
class Article < ApplicationRecord
scope :with_images, -> { includes(:images) }
scope :ordered_desc, -> { order(id: desc) }
end
#app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def show
@articles = current_user.articles.with_images.ordered_desc if user_signed_in?
end
end
scopeはモデルに定義するため、Article
モデルにscopeを定義して使うことができます。
Articleの絞り込みなので、インスタンスメソッドとは違い、Userモデルに書くことはできません!
そのため、articles分長くなっちゃうので、current_user
のように記事の絞り込みをしてから、さらに絞り込むにはscopeは向いてないのかなと思いました🤔
scopeはクラスメソッドの代わりに使うイメージで、モデルクラス全体の絞り込みをしたい時に使ったら、コードが見やすくなって、綺麗なメソッドチェーンを作れるのかなと思いました!
参考サイト
Active Record クエリインターフェイス
Railsのファットコントローラー 3つの対処法
Railsのパターンとアンチパターン4: コントローラ編(翻訳)
【新人プログラマ応援】Railsにおける良いコントローラ、悪いコントローラについて
MVCモデルにおけるモデルとコントローラーの境界とその扱いを考える
【Rails】 モデルのスコープ機能(scope)の使い方を1から理解する
ActiveRecord::Enum
よく使うQueryメソッドはscopeで整頓しよう