cloudpack の ヤマグチです。

Railsネタです。

RailsにはConcernという仕組みがあります。ActiveSupportが提供する機能のひとつなのですが、一言でいうと複数モデル間で共通のロジックを1つのモジュールにまとめてDRYにできる機能で、Railsの開発者であるDHHもこれを推しているようです。

Concernよりも有名と思われるRailsの機能にScaffoldがあり、これはrails generate scaffoldコマンドを打つだけで関連するmodel、controller、viewを基本的な機能をあらかじめ実装した形で一気に自動生成してくれるというウルトラ便利機能です。

しかしこのScaffold、複数回使用するとその分だけほとんど内容の変わらないmodelやcontrollerが量産されてしまい、そんなのDRYじゃねえ!状態になりがちです。

上の記事ではmodelに対してconcernを使っていますが、同じ考え方はcontrollerにも使えます。

Scaffoldが生成するcontrollerはリソースの作成・更新・読み取り・削除(いわゆるCRUD)を提供します。そこでこのCRUDな機能をconcernとしてまとめてみようと思います。

まず適当なRailsプロジェクトを作り、2つのモデルをscaffoldします。

rails new test_proj --skip-bundle
cd test_proj
bundle install --path=vendor/bundle

bundle exec rails generate scaffold author name:string birth_date:date description:text
bundle exec rails generate scaffold book title:string release_date:date description:text

念のため動作を確認しましょう。

bundle exec rake db:migrate
bundle exec rails server

authorsbooksがブラウザでCRUDできると思います。さて、これらのcontrollerを見てみましょう。

app/controllers/authors_controller.rb

class AuthorsController < ApplicationController
  before_action :set_author, only: [:show, :edit, :update, :destroy]

  # GET /authors
  # GET /authors.json
  def index
    @authors = Author.all
  end

  # GET /authors/1
  # GET /authors/1.json
  def show
  end

  # GET /authors/new
  def new
    @author = Author.new
  end

  # GET /authors/1/edit
  def edit
  end

  # POST /authors
  # POST /authors.json
  def create
    @author = Author.new(author_params)

    respond_to do |format|
      if @author.save
        format.html { redirect_to @author, notice: 'Author was successfully created.' }
        format.json { render :show, status: :created, location: @author }
      else
        format.html { render :new }
        format.json { render json: @author.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /authors/1
  # PATCH/PUT /authors/1.json
  def update
    respond_to do |format|
      if @author.update(author_params)
        format.html { redirect_to @author, notice: 'Author was successfully updated.' }
        format.json { render :show, status: :ok, location: @author }
      else
        format.html { render :edit }
        format.json { render json: @author.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /authors/1
  # DELETE /authors/1.json
  def destroy
    @author.destroy
    respond_to do |format|
      format.html { redirect_to authors_url }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_author
      @author = Author.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def author_params
      params.require(:author).permit(:name, :birth_date, :description)
    end
end

app/controllers/books_controller.rb

class BooksController < ApplicationController
  before_action :set_book, only: [:show, :edit, :update, :destroy]

  # GET /books
  # GET /books.json
  def index
    @books = Book.all
  end

  # GET /books/1
  # GET /books/1.json
  def show
  end

  # GET /books/new
  def new
    @book = Book.new
  end

  # GET /books/1/edit
  def edit
  end

  # POST /books
  # POST /books.json
  def create
    @book = Book.new(book_params)

    respond_to do |format|
      if @book.save
        format.html { redirect_to @book, notice: 'Book was successfully created.' }
        format.json { render :show, status: :created, location: @book }
      else
        format.html { render :new }
        format.json { render json: @book.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /books/1
  # PATCH/PUT /books/1.json
  def update
    respond_to do |format|
      if @book.update(book_params)
        format.html { redirect_to @book, notice: 'Book was successfully updated.' }
        format.json { render :show, status: :ok, location: @book }
      else
        format.html { render :edit }
        format.json { render json: @book.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /books/1
  # DELETE /books/1.json
  def destroy
    @book.destroy
    respond_to do |format|
      format.html { redirect_to books_url }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_book
      @book = Book.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def book_params
      params.require(:book).permit(:title, :release_date, :description)
    end
end

クラス名以外ほとんど同じコードですね?concernでまとめてしまいましょう!!

app/controllers/concerns/crudable.rb

module CRUDable
  extend ActiveSupport::Concern

  included do
    before_action :set_model
    before_action :set_resource, only: [:edit, :update, :destroy]

    def index
      instance_variable_set :"@#{model_name.tableize}", @model.all
    end

    def show
    end

    def new
      instance_variable_set :"@#{model_name.underscore}", @model.new
    end

    def edit
    end

    def create
      instance_variable_set :"@#{model_name.underscore}", @model.new(resource_params)

      respond_to do |format|
        if instance_variable_get("@#{model_name.underscore}").save
          format.html { redirect_to action: :index, notice: 'Resource was successfully created.' }
          format.json { render action: 'show', status: :created, location: instance_variable_get("@#{model_name.underscore}") }
        else
          format.html { render "concerns/admin/new" }
          format.json { render json: instance_variable_get("@#{model_name.underscore}").errors, status: :unprocessable_entity }
        end
      end
    end

    def update
      respond_to do |format|
        if instance_variable_get("@#{model_name.underscore}").update(resource_params)
          format.html { redirect_to action: :index, notice: 'Resource was successfully updated.' }
          format.json { head :no_content }
        else
          format.html { render "concerns/admin/edit" }
          format.json { render json: instance_variable_get("@#{model_name.underscore}").errors, status: :unprocessable_entity }
        end
      end
    end

    def destroy
      instance_variable_get("@#{model_name.underscore}").destroy

      respond_to do |format|
        format.html { redirect_to action: :index, notice: 'Resource was successfully destroyed.' }
        format.json { head :no_content }
      end
    end

    private

    def set_model
      @model = controller_name.classify.constantize
    end

    def model_name
      @model.to_s
    end

    def set_resource
      instance_variable_set :"@#{model_name.underscore}", @model.find(params[:id])
    end

    def resource_params
      params.require(model_name.underscore.intern).permit(*@model.column_names.map(&:intern))
    end
  end
end

app/controllers/authors_controller.rb

class AuthorsController < ApplicationController
  require "crudable"
  include CRUDable
end

app/controllers/books_controller.rb

class BooksController < ApplicationController
  require "crudable"
  include CRUDable
end

Concern moduleは〜ableという名前を付ける慣例っぽいので、ここではCRUDableとしてみました。元々のauthors_controller.rb、books_controller.rbはCRUDableをincludeするだけにまで単純化できました。いかがでしょうか?

ポイントとしてはset_modelコールバックで現在のコントローラ名を取得し、そこからモデルクラスへ@modelで参照できるようにしています。

ちなみにここではviewをそのまま使えるように@bookのようなインスタンス変数にセットするようにしていますが、ここを@itemや@resourceのように汎用的な名前にするとさらにコードがシンプルになり、viewも共通化することができます。お試しあれ。

余談ですが、bundle execはシェルの設定でbeというエイリアスで呼べるようにすると少しだけ幸せになれます。

元記事はこちら

Ruby on Rails: Scaffoldで量産されたControllerの処理をConcernで共通化してみる