237
172

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【新人プログラマ応援】Railsにおける良いコントローラ、悪いコントローラについて

Last updated at Posted at 2023-04-02

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アプリケーションを想定しながら、基本的なコントローラの実装指針をまとめてみました。

コントローラの書き方に迷ったときは、ぜひこの記事を参考にしてみてください。

237
172
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
237
172

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?