form objectを使ってみよう

こんにちは。メドピアのRuby(Rails)化をお手伝いしている@willnetです。

Ruby化のプロジェクトが始まって1年が過ぎました。新しいメンバーも入り、Railsのコード量は日に日に多くなっています。可読性を保ちつつアプリケーションを大きくしていくために、使える知見をチームメンバーに効率よく伝えていくのが大事だと感じる今日このごろです。

普段メドピア内ではコードレビューや社内勉強会などで知識のシェアを行っています。そんなとき、ブログ記事や書籍などのまとまった文章があると「これ読んでおいて」と言うだけで良くなるので楽です。先日form objectを使ったほうがいいですよーという内容でレビューコメントをつけようとしたところ、日本語で詳しくまとまった文章が見当たりませんでした><なければ自分で書くしかありません。そこで今回はRailsにおいて可読性を保つための知見である、form objectについてまとめたいと思います。

form object って何?

form_withのmodelオプション*1にActive Record以外のオブジェクトを渡すデザインパターンです。form_withのmodelオプションに渡すオブジェクト自体もform objectと呼びます。

利点は大きく次の2点です。

  • DBを使わないフォームでも、Active Recordを利用した場合と同じお作法を利用できるので可読性が増す
  • 他の箇所に分散されがちなロジックをform object内に集めることができ、凝集度を高められる

具体例を見ていきましょう。

具体例

Rails 5.1.0でサービス管理者にフィードバックを返すフォームを作ってみます。フィードバック内容をデータベースに保存したい場合、素直に書くと次のようになるでしょう*2

class Feedback < ApplicationRecord
  validates :title, :body, presence: true
end

class FeedbacksController < ApplicationController
  def new
    @feedback = Feedback.new
  end

  def create
    @feedback = Feedback.new(feedback_params)
    if @feedback.save
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      render :new
    end
  end

  private

  def feedback_params
    params.require(:feedback).permit(:title, :body)
  end
end
<%= form_with model: @feedback, local: true do |f| %>
  <% if @feedback.errors.any? %>
    <% @feedback.errors.full_messages.each do |message| %>
      <%= message %>
    <% end %>
  <% end %>
  <%= f.label :title %>
  <%= f.text_field :title %>
  <%= f.label :body %>
  <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

ここでフィードバックをデータベースに保存せずに、サービス管理者へメールを送信するようにコードを変更してみましょう。データベースに保存しないので、ApplicationRecordを継承したモデルは使用しません。

そうしたときにこんな感じのコードを書いてしまう人が多いのではないでしょうか。

class FeedbacksController < ApplicationController
  def new
  end

  def create
    if params[:title].present? && params[:body].present?
      AdminMailer.feedback(params[:title], params[:body]).deliver_later
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      @error_messages = []
      @error_messages << 'タイトルを入力してください' if params[:title].blank?
      @error_messages << '本文を入力してください' if params[:body].blank?
      render :new
    end
  end
end
<%= form_with url: feedbacks_path, local: true do %>
  <% @error_messages && @error_messages.each do |message| %>
    <%= message %>
  <% end %>
  <%= label_tag :title %>
  <%= text_field_tag :title, params[:title] %>
  <%= label_tag :body %>
  <%= text_area_tag :body, params[:body] %>
  <%= submit_tag %>
<% end %>

少し乱雑なコードになりました。まず、コントローラにロジックを記述してしまっています。また、独自のお作法でフォームを作成したため、ビューとコントローラを両方読まないとどのように動くのか、すぐに理解することができなくなりました。例えばビューとコントローラで@error_messagesという変数を利用していますが、どのような形式で情報が格納されているのか、どのように使用したらいいのかはビューとコントローラ両方を読まないと判断できません。

今回の例は項目数が少なく簡単なため、この程度であれば問題ないと判断する方もいるかもしれません。しかし、フォームの入力項目数が増えたり種類(セレクトボックス、チェックボックスラジオボタン)が増えたときのことを想像してみてください。コントローラが肥大化して読みづらくなり、ビューにどんなときに何が描画されるのかもわかりづらく、修正するのが大変になりそうですね。

これを解決するためにform objectを利用してみます。

form object を利用した例

form objectとして、ActiveModel::Modelをincludeしたクラスを用意します。 Active Modelは、Active RecordからDBに依存する部分を除いた振る舞いを提供しているライブラリです。これを利用することにより、DBを利用しないフォームでもActive Recordを利用したときと同じような記述をすることができます。

コードを見てみましょう。

class Feedback
  include ActiveModel::Model
  attr_accessor :title, :body

  validates :title, :body, presence: true

  def save
    return false if invalid?
    AdminMailer.feedback(params[:title], params[:body]).deliver_later
    true
  end
end

class FeedbacksController < ApplicationController
  def new
    @feedback = Feedback.new
  end

  def create
    @feedback = Feedback.new(feedback_params)
    if @feedback.save
      redirect_to home_path, notice: 'フィードバックを送信しました'
    else
      render :new
    end
  end

  private

  def feedback_params
    params.require(:feedback).permit(:title, :body)
  end
end
<%= form_with model: @feedback, local: true do |f| %>
  <% if @feedback.errors.any? %>
    <% @feedback.errors.full_messages.each do |message| %>
      <%= message %>
    <% end %>
  <% end %>
  <%= f.label :title %>
  <%= f.text_field :title %>
  <%= f.label :body %>
  <%= f.text_area :body %>
  <%= f.submit %>
<% end %>

Feedbackクラスを除けば、Active Recordを利用した例と全く同じコードになりました。

このようにform objectを利用すると、Active Recordを利用した場合と同じお作法でビューとコントローラを記述することができて大変便利です。

個人的には、form_withでは必ずmodelオプションを利用する(form_tagを使わずにform_forを使う)という規則をチーム内に作ってしまってもよいのではないかと考えています。

その他の利用例

ここまでの文章を読むと「form objectはActive Recordを使わないフォームでだけ利用する」という理解をしてしまう方が多いのではないでしょうか。しかしform objectはActive Recordを利用する場合にも使えます。

例えば次のようなUserモデルがあるとします。

class User < ApplicationRecord
  has_secure_password

  validates :email,
            presence: true,
            format: { with: /\A.+@.+\z/ }
  validates :password, length: { minimum: 6 }, on: :create

  after_create_commit :send_welcome_mail

  def send_welcome_mail
    UserMailer.welcome(self).deliver_later
  end
end

よくある形だと思いますが、passwordのバリデーションやコールバックは、ユーザ作成時以外は必要のないものです。emailのバリデーションもユーザ作成時とメールアドレス変更時だけ必要なものなので、常に必要というわけではありません。

こんな時にform objectを利用してUserモデルに書かれたロジックを外出しすることができます。

class Signup
  include ActiveModel::Model

  attr_accessor :email, :password, :password_confirmation

  validates :email,
            presence: true,
            format: { with: /\A.+@.+\z/ }
  validates :password, presence: true, length: { minimum: 6 }, confirmation: { allow_blank: true }

  def save
    return false if invalid?

    user = User.new(email: email, password: password, password_confirmation: password_confirmation)
    user.save!
    UserMailer.welcome(user).deliver_later
    true
  end
end
class User < ApplicationRecord
  has_secure_password
end

Userモデルがスッキリしましたね。

他にも、複数のActive Recordを一度に保存するようなフォームなどでもform objectを活用することができます。

form objectをもっと活用する

これまでの例では、すべてのform objectにActiveModel::Modelをincludeしてきました。簡単なコード例なのでこれで十分だと思いますが、実際の現場でのコードはもっと複雑になります。そんなときActive Modelだけだと少し面倒に思う部分も出てくるはずです。

例えば先程のユーザ登録用のform objectに、サービス運営側からのダイレクトメールを受け取るか否かのチェックボックスを追加したとしましょう。Railsのビューヘルパーであるcheck_boxメソッドはチェックした場合は文字列の"1"、チェックしない場合は"0"がパラメータとして送信されてきます。Active ModelはActive Recordとは異なり、属性の自動的なキャストは行いません*3。booleanとして扱いたい場合、キャストの処理は自分で書く必要があります。

class Signup
  attr_accessor :email, :password, :password_confirmation, :accept_dm

  # 略

  def accept_dm
    ActiveRecord::Type::Boolean.new.cast(@accept_dm)
  end
end

signup = Signup.new(accept_dm: '1')
signup.accept_dm #=> true
signup = Signup.new(accept_dm: '0')
signup.accept_dm #=> false

独自にキャストしないといけない属性の数が多かったり、種類が豊富だったりしたら面倒ですね。そんなときに使える、属性の型を定義できるgemやform objectのためのgemがいくつか存在するので、form objectをバリバリ使ってみたくなった方は一度目を通しておくと良いと思います。

まとめ

form objectの利点や使いどころについて一通り解説しました。

form objectという概念自体は4~5年ほど前から広まっているはずなのですが、日本語でのまとまった文章がないことから、日本ではあまり普及していないような気がします。このエントリがform object普及の一助になって、読みやすいRailsのコードが少しでも増えることを願っています。


メドピアでは一緒に働く仲間を募集しています。 ご応募をお待ちしております!

■募集ポジションはこちら

https://medpeer.co.jp/recruit/entry/

■開発環境はこちら

https://medpeer.co.jp/recruit/workplace/development.html

*1:もしくはform_for

*2:ビューテンプレートのデザインは省略しています

*3:カラムの型情報がないので当たり前ですが