Rails

【Rails】ロジックを1つに集約できるデザインパターン「Form Object」を簡易実装してみた

Rails

 

今回は1つのフォームで複数のモデルを扱う際に重宝される設計手法「Form Object(フォームオブジェクト)」について簡易実装してみたいと思います。Form Objectはデータベースに保存しないフォームでもActive Recordを使用できるため、可読性の高いコードを実現することができます。

今回はnewとcreateアクションをForm Objectを使用することで実装していきます。

Form Objectとは

Form Objectはデザインパターンの1つで、1つのフォームから複数のリソースを同時保存する際などに役立つ設計手法です。

「1つのフォームから複数リソースを同時保存できる」という言葉を聞くと「accepts_nested_attributes_for」を思い浮かべる人も多いとは思いますが、この「accepts_nested_attributes_for」にはバグが多く、Rails生みの親であるDHH氏が消したいとも公言しています。

そのため、1つのフォームから複数モデルを扱う際は「accepts_nested_attributes_for」ではなく「Form Object」の使用が推奨されています。

Rails
【Rails】「accepts_nested_attributes_for」について簡潔に解説してみた!モデルの親子関係(ネスト関係)を構築するためのメソッド「accepts_nested_attributes_for」について、出来るだけ簡潔に解説しています。このメソッドは「fields_for」と併用して用いられることが多いので、「fields_for」と併せて学習を進めていきましょう。...

Form Objectのメリット

Form Objectを使用するメリットとしては以下の2点です。

  • DBに保存しないフォームでも、ActiveRecordのバリデーションが使用可能
  • 散在するロジックをForm Object1箇所に集約することができるので管理が容易

Form Objectを使用したアプリケーション開発

ではここからForm Objectを簡易実装してみます。

今回は本の情報と作者情報を同時に保存できるミニアプリを作成します。

本来であれば本と作者情報を分ける必要はありませんが、今回はForm Objectを用いるため、あえてモデルを分けています。

新規アプリケーションの立ち上げ

まずは以下のコマンドで新規アプリケーションを作成しましょう。

% rails new formObject-app -d mysql
% cd formObject-app
% rails db:create

今回開発するアプリケーションの環境は以下のようになります。

  • アプリケーション名:formObject-app
  • Rails:6.0.3.2
  • Ruby:2.6.5
  • DB:MySQL

Autherモデルの作成

続いて作者情報を管理するモデル「Autherモデル」を作成します。

カラムは「name」と「birth_place」と「birth_date」の3つとします。

% rails g model auther name:string birth_place:string birth_date:date
% rails db:migrate

Bookモデルの作成

次に本の情報を管理するモデル「Bookモデル」を作成します。

モデルの作成には便利なScaffoldの機能を使用し、カラムは「title」と「price」と「publish_date」の3つとします。

% rails g scaffold book title:string price:integer publish_date:date auther:references
% rails db:migrate

アソシエーションの定義

先ほど作成した「Autherモデル」と「Bookモデル」のアソシエーションを1対多の関係で組みましょう。

class Book < ApplicationRecord
  belongs_to :auther
end
class Auther < ApplicationRecord
  has_many :books
end

ルーティングの作成

現在ではルートパスが未定義のため、以下のようにroutes.rbを定義します。

Rails.application.routes.draw do
  root "books#index"
  resources :books
end

フォームの作成

現在は本の情報を入力するフォームしか作成されていません。

本情報のフォーム

 

ここに作者情報も入力することができるフォームを加えていきます。

「app/views/books/new.html.erb」と「app/views/books/_form.html.erb」を以下のように編集します。

<%# app/views/books/new.html.erb %>

<h1>本情報</h1>

<%= render 'form', book: @book %>

<%= link_to '戻る', books_path %>
<%# app/views/books/_form.html.erb %>

<%= form_with(model: @book, url: books_path, local: true) do |form| %>
  <% if book.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(book.errors.count, "error") %> prohibited this book from being saved:</h2>

      <ul>
        <% book.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :タイトル %>
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.label :価格 %>
    <%= form.number_field :price %>
  </div>

  <div class="field">
    <%= form.label :出版日 %>
    <%= form.date_select :publish_date %>
  </div>

  <br>

  <h1>作者情報</h1>

  <div class="field">
    <%= form.label :名前 %>
    <%= form.text_field :name %>
  </div>

  <div class="field">
    <%= form.label :出身地 %>
    <%= form.text_field :birth_place %>
  </div>

  <div class="field">
    <%= form.label :生年月日 %>
    <%= form.date_select :birth_date %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>
本情報と作者情報

Form Objectの作成

では実際にForm Objectの作成に取り掛かります。

今回は「auther_books.rb」という名前のForm Objectを作成します。

まずは「app/forms」を作成し、その配下に「auther_books.rb」を作成しましょう。

ファイル作成
app/forms以下に作成する決まりはありませんが、Form Objectはforms以下に配置することをRailsが推奨しているようです。

 

「auther_books.rb」を以下のように編集します。

class AutherBooks

  include ActiveModel::Model
  include ActiveRecord::AttributeAssignment
  attr_accessor :name, :birth_place, :birth_date, :title, :price, :publish_date

  with_options presence: true do
    validates :name
    validates :birth_place
    validates :birth_date
    validates :title
    validates :publish_date
  end

  validates :price, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 1000000, message: "is out of setting range"}

  def save
    ActiveRecord::Base.transaction do
      auther = Auther.create(name: name, birth_place: birth_place, birth_date: birth_date)
      Book.create(title: title, price: price, publish_date: publish_date, auther_id: auther.id)
    end
  end
end
include ActiveRecord::AttributeAssignment

 

上記がない場合、UnknownAttributeErrorが発生してしまいます。

フォームでdata_selectを使用した場合、「”publish_date(1i)”=>”2020″, “publish_date(2i)”=>”10”, “publish_date(3i)”=>”8″」のような形でパラメーターが送信されます。本来であれば1つのpublish_dateとしてストロングパラメーターが認識してくれますが、ActiveRecord:Baseを継承していないFormObjectのようなモデルでは認識してくれません。ActiveRecord::AttributeAssignmentという記述がそのようなエラーを回避してくれます。

もう少し言うと、ActiveRecord:Baseを継承したモデルではassign_multiparameter_attributesというメソッドがそういった処理を行なってくれています。

Rails
【Rails】Formオブジェクトでdate_selectを使おうとしたらUnknownAttributeエラーが出た話フォームでdate_selectを使用し、かつFormオブジェクトを用いた場合にUnknownAttributeエラーが発生した時の対処方法について解説しています。date_selectやdatetime_selectは便利なヘルパーメソッドですが、癖のあるメソッドなので慎重に使用していきましょう。...

コントローラーの編集

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 = AutherBooks.new
  end

  # GET /books/1/edit
  def edit
  end

  # POST /books
  # POST /books.json
  def create
    @book = AutherBooks.new(book_params)
    if @book.valid?
      @book.save  # バリデーションをクリアした時
      return redirect_to root_path
    else
      render "new"    # バリデーションに弾かれた時
    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, notice: 'Book was successfully destroyed.' }
      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

    # Only allow a list of trusted parameters through.
    def book_params
      params.require(:auther_books).permit(:name, :birth_place, :birth_date, :title, :price, :publish_date, :auther)
    end
end

 

これでForm Objectを用いてnewとcreateを実装することができました。

挙動確認

新規投稿
Image from Gyazo
バリデーション
Image from Gyazo
上記GIFをクリックすることで、拡大して確認することができます。

参考記事

 

 

今回はデザインパターンの1つであるForm Objectについて簡易実装してみたので紹介しました。ロジックが散在していたり、モデルが肥大化しそうな場合に役立つ設計のため、ぜひ取り入れてみてください。