TL;DR(長いので最初にまとめ)
- scaffoldと全く同じ、もしくはscaffoldに限りなく近いコントローラが良いコントローラ
- fatコントローラは悪いコントローラ。その反対でskinnyなコントローラ(薄いコントローラとも言う)を目指す
- コントローラ内でtransactionやrescueを書かない(fatになるし、scaffoldからも乖離する)
- BlogsControllerならBlogクラスのインスタンスだけをインスタンス変数にセットする
- メールはコントローラで送信する(EメールはSMTPを通じて届けられるviewと考える)
はじめに
RailsはMVC(Model-View-Controller)アーキテクチャを採用しています。
しかし、ロジック自体はやろうと思えばモデルでもviewでもコントローラでも、どこでも書くことができます。
特に、プログラミング初心者の方はついついコントローラにロジックを追加しがちになりますが、それはあまり良いアプローチとは言えません。
そこでこの記事では、プログラミング初心者(新人プログラマ)の方に向けて、Railsにおける良いコントローラ、悪いコントローラについて説明しようと思います。
良いコントローラってどんなコントローラ?
良いコントローラとはズバリ、「scaffoldと全く同じ、もしくはscaffoldに限りなく近いコントローラ」です。
たとえば、Rails 7.0でrails g scaffold Blog title content
を実行すると、以下のようなコードが生成されます。これが良いコントローラの理想形です。
class BlogsController < ApplicationController
before_action :set_blog, only: %i[ show edit update destroy ]
# GET /blogs or /blogs.json
def index
@blogs = Blog.all
end
# GET /blogs/1 or /blogs/1.json
def show
end
# GET /blogs/new
def new
@blog = Blog.new
end
# GET /blogs/1/edit
def edit
end
# POST /blogs or /blogs.json
def create
@blog = Blog.new(blog_params)
respond_to do |format|
if @blog.save
format.html { redirect_to blog_url(@blog), notice: "Blog was successfully created." }
format.json { render :show, status: :created, location: @blog }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @blog.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /blogs/1 or /blogs/1.json
def update
respond_to do |format|
if @blog.update(blog_params)
format.html { redirect_to blog_url(@blog), notice: "Blog was successfully updated." }
format.json { render :show, status: :ok, location: @blog }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @blog.errors, status: :unprocessable_entity }
end
end
end
# DELETE /blogs/1 or /blogs/1.json
def destroy
@blog.destroy
respond_to do |format|
format.html { redirect_to blogs_url, notice: "Blog was successfully destroyed." }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_blog
@blog = Blog.find(params[:id])
end
# Only allow a list of trusted parameters through.
def blog_params
params.require(:blog).permit(:title, :content)
end
end
でもここは変えてもOK
ただし、jsonでレスポンスを返す要件がない(想定しているのはhtmlのレスポンスだけ)なら、respond_to
メソッドはなくしてもいいと思います。
def create
@blog = Blog.new(blog_params)
# respond_to メソッドをなくした書き方(レスポンスはhtmlのみを想定)
if @blog.save
redirect_to blog_url(@blog), notice: "Blog was successfully created."
else
render :new, status: :unprocessable_entity
end
end
個人的にはdestroy
アクションも成功と失敗で条件分岐するか、もしくは失敗時に例外が発生するようにdestroy!
メソッドを使うのがいいんじゃないかなーと思ったりもします。
def destroy
# 万一削除に失敗したら例外を発生させて失敗を検知しやすくする
@blog.destroy!
redirect_to blogs_url, notice: "Blog was successfully destroyed."
end
悪いコントローラってどんなコントローラ?
悪いコントローラは本来コントローラに書くべきではないロジックがたくさん書かれたコントローラです。こうしたコントローラは「fatコントローラ」という名前で呼ばれ、アンチパターンと見なされます。
たとえば、コントローラの中でtransaction
メソッドが登場してたりするケースは、だいたいアンチパターンです。
def create
@blog = Blog.new(blog_params)
# コントローラ内でblogの保存以外にもあれこれやっているのはNG
@blog.transaction do
@blog.save!
Notification.notify_subscribers(@blog)
current_user.consume_credit
end
redirect_to book_url(@book), notice: 'Book was successfully created.'
end
なぜfatコントローラはダメなの?ロジックはどこに書けばいいの?
コントローラは基本的に再利用しにくいクラスです。コントローラに複雑なロジックを書いてしまうと、たとえばrails consoleの中でそのロジックを使おうと思っても再利用するのが難しくなります。
また、テストコードを書くときもcontrollerのテストやシステムテスト(RSpecならシステムスペック)として書く必要が出てくるため、テストコードの難易度が上がります。
なので、複雑なロジックはコントローラではなく、モデルの中で書くようにしましょう。そうすればコントローラもscaffoldの形をほとんど崩さずに書くことができます。
def create
@blog = Blog.new(blog_params)
# ややこしいロジックはモデルに書く(メソッド名は適当に付けてます)
if @blog.execute_save_procedure(current_user)
redirect_to blog_url(@blog), notice: "Blog was successfully created."
else
render :new, status: :unprocessable_entity
end
end
モデルに書いていれば、rails console上でも簡単に呼び出せます。
> blog = Blog.new(title: 'foo', content: 'bar')
> user = User.first
> blog.execute_save_procedure(user)
テストコードもモデルのテストなら簡単に書けます。
class BlogTest < ActiveSupport::TestCase
test "#execute_save_procedure" do
blog = blog = Blog.new(title: 'foo', content: 'bar')
user = users(:one)
assert blog.execute_save_procedure(user)
end
end
コントローラの中に例外処理が登場するのもNG
コントローラの中に例外処理(rescue節)が登場したりするケースもだいたいアンチパターンです。
def create
@blog = Blog.new(blog_params)
@blog.transaction do
@blog.save!
Notification.notify_subscribers(@blog)
current_user.consume_credit
end
redirect_to book_url(@book), notice: 'Book was successfully created.'
rescue ActiveRecord::RecordInvalid
# 例外が発生したら保存失敗と見なす・・・のはNG
render :new, status: :unprocessable_entity
end
バリデーションエラーはシステムエラーではなく業務エラーに相当するので、例外処理ではなく条件分岐で成功と失敗を管理すべきです。システムエラーと業務エラーの違いとその適切な対処法については以下の記事を参考にしてください。
前述の「複雑なロジックはコントローラではなく、モデルの中で書くようにしましょう」と紹介したサンプルコードでは、成功と失敗を条件分岐で管理している点に注目してください。つまり、scaffoldの形を崩さないコントローラであれば、業務エラーも自ずと適切に対処できていることになります。
def create
@blog = Blog.new(blog_params)
if @blog.execute_save_procedure(current_user)
# 保存に成功した場合
redirect_to blog_url(@blog), notice: "Blog was successfully created."
else
# 保存に失敗(=業務エラーが発生)した場合
render :new, status: :unprocessable_entity
end
end
なお、この手の議論をするとよく「モデルではなくサービスクラスを用意してそこにロジックを書くべきでは?」という意見が挙がりますが、個人的にはコントローラに限らず、設計全体をscaffoldの形から崩さない(scaffoldにないクラスはなるべく作らない)方がRails wayだと考えています。なので、僕は「どのモデルがこのロジックの責務を持つべきか」を考えた上でモデルにロジックを書くようにしています。
viewで利用するデータは全部コントローラで用意すべき?
viewで必要なデータを全部コントローラで用意しようとするコードをよく見かけますが、個人的には「どうしても」というとき以外はやらない方がいいと考えています。
class BlogsController < ApplicationController
def show
# blogにひもづくcommentsをコントローラで用意する・・・のはNG
@comments = @blog.comments
end
なぜコントローラ内はダメなの?どこでデータを取得すればいいの?
「viewで使うデータを全部コントローラで用意する」という方針にしてしまうと、「あれも、これも」と、どんどんインスタンス変数が増えてしまいます。
インスタンス変数はスコープが広い変数です。むやみやたらにインスタンス変数を増やしてしまうと、見通しが悪いプログラムになったり、思わぬ不具合を生みやすくなったりします(「インスタンス変数」を「グローバル変数」に置き換えるとイメージが付きやすくなるかもしれません)。
原則として、「BlogsControllerであればBlogクラスのインスタンスだけをインスタンス変数にセットする」と考えるのがいいと思います。
class BlogsController < ApplicationController
def index
# BlogsControllerだから、Blogのインスタンスだけをインスタンス変数にセットする
@blogs = Blog.all
end
「じゃあ、@comments
みたいなデータはどこで用意すればいいの?」と思うかもしれませんが、簡単に取得できるデータであればviewの中で取得してしまえばOKです。
<% @blog.comments.each do |comment| %>
<p><%= comment.content %></p>
<% end %>
データを取得するまでに何か複雑なロジックが必要なら、モデルもしくはhelperにロジックを定義しましょう(viewの中のロジックは極力シンプルに済ませる)。
module BlogsHelper
def fetch_comments(blog)
# あんなロジック
# こんなロジック
# そんなロジック
# 最後にcommentsを返す
comments
end
end
<%# helperメソッドを経由してデータを取得する %>
<% fetch_comments(@blog).each do |comment| %>
<p><%= comment.content %></p>
<% end %>
<%# もしくはモデルにメソッドを定義する %>
<% @blog.fetch_comments.each do |comment| %>
<p><%= comment.content %></p>
<% end %>
ちなみに、commentsの取得がややこしいのはDBに発行する検索条件を構築する部分だけ、という場合はscopeを定義するのが正攻法です。
<%# データの検索条件をscopeとして定義する %>
<% @blog.comments.published.each do |comment| %>
<p><%= comment.content %></p>
<% end %>
scopeに関する詳しい説明はRailsガイドを参照してください。
良いコントローラってどんなコントローラ?(再び)
先ほど、「fatコントローラはアンチパターン」と書きましたが、反対にコントローラはskinny(やせっぽち)である方が望ましいです(skinnyコントローラを目指す)。つまり、ロジックが少ないシンプルなコントローラ(薄いコントローラとも言う)である方が望ましいということです。
コントローラは交通整理のお巡りさん
コントローラは交差点に立って交通整理するお巡りさんみたいなものです。
「そっちの車はこっちへ!」
「そこの車はあっちへ!」
「お前はそこでちょっと待て!」
と、往来する車に指示を出すお巡りさん👮👮♂️をイメージしてください。
コントローラも同じように、ブラウザからやってきたリクエストに対して、まずモデルに処理(データの取得や更新)を依頼し、その結果を受け取って「成功したらこの画面を表示しろ」「失敗したらこの画面を表示しろ」と指示を出します。
大事なポイントは、コントローラはあくまで「こういう場合はこっち」「そうじゃないときはそっち」と指示を出すだけ、という点です。
お巡りさんがわざわざ車に乗り込んでハンドルを操作しないのと同じで、コントローラもコントローラ自身が複雑なデータ取得ロジックや更新ロジックを実行することはありません。
メールは原則としてコントローラで送信する
アプリケーションの要件によってはデータの更新後にメール送信が必要になるケースもあると思います。「skinnyコントローラを目指さなきゃ」と考えると、メールの送信もモデルで行うべきなのでは?と思うかもしれませんが、メール送信は原則コントローラで行います。
def create
@blog = Blog.new(blog_params)
if @blog.save
# メールの送信はコントローラで行う
BlogMailer.with(blog: @blog).new_blog_post.deliver_later
redirect_to blog_url(@blog), notice: "Blog was successfully created."
else
render :new, status: :unprocessable_entity
end
end
DHH氏は以下のブログで「EメールはSMTPを通じて届けられるviewだと考えよう」と語っています。
実際、Mailerもメール送信時は本文の作成にはERBで書かれたviewを利用するので、「メール=view」と考えるのは理にかなっています。
Railsガイドでも以下のようにメールの送信処理はコントローラに書かれています。
class UsersController < ApplicationController
# ...
# POST /users(または/users.json)
def create
@user = User.new(user_params)
respond_to do |format|
if @user.save
# 保存後にUserMailerを使ってwelcomeメールを送信
UserMailer.with(user: @user).welcome_email.deliver_later
format.html { redirect_to(@user, notice: 'ユーザーが正常に作成されました') }
format.json { render json: @user, status: :created, location: @user }
else
format.html { render action: 'new' }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
# ...
end
とはいえ、現実問題としてはメール送信処理はコントローラに書くよりもモデルに書いた方が都合が良いこともよくあります。たとえば、devise_invitableというgemを使うとuser.invite!
のように、モデルに対してメソッドを呼んだときに招待メールが送信されたりします。
なので、個人的には「メールはコントローラで送信する」というルールはmustというよりもshouldなのかなーと思っています。
まとめ
というわけで、このエントリではRailsにおける良いコントローラと悪いコントローラについて、あれこれ書いてみました。
冒頭に書いたまとめをもう一度書いておきます。
- scaffoldと全く同じ、もしくはscaffoldに限りなく近いコントローラが良いコントローラ
- fatコントローラは悪いコントローラ。その反対でskinnyなコントローラ(薄いコントローラとも言う)を目指す
- コントローラ内でtransactionやrescueを書かない(fatになるし、scaffoldからも乖離する)
- BlogsControllerならBlogクラスのインスタンスだけをインスタンス変数にセットする
- メールはコントローラで送信する(EメールはSMTPを通じて届けられるviewと考える)
大きなアプリケーションになってくるとユースケースも様々なので、「あんなケースだとこう書く」「そんなケースではこう書く」「こういうケースは例外として考える」等々、無限に議論ができてしまいますが、とりあえずこの記事ではプログラミング初心者(新人プログラマ)が作るような比較的シンプルなRailsアプリケーションを想定しながら、基本的なコントローラの実装指針をまとめてみました。
コントローラの書き方に迷ったときは、ぜひこの記事を参考にしてみてください。