Ruby on Railsでフォームを扱うとき、ロジックをコントローラやビューに書くとコードの肥大化につながります。またテストが書きづらく、モデルの変更による影響を受けやすいという問題もあります。
Formオブジェクトを採用することで、コントローラやビューにちらばるロジックをひとつのクラスにカプセル化できます。ユーザーのフォームからの入力を扱うときに採用する価値のあるデザインパターンです。この記事では、Formオブジェクトについて解説します。
Ruby on Railsアプリケーションの開発や学習に役立つ本を、「Ruby on Railsのおすすめな本」で紹介しています。あわせてご覧ください。
テクニカルライター。元エンジニア。共著で「現場で使えるRuby on Rails 5」を書きました。プログラミング教室を作るのが目標です。
Formオブジェクトとは
まず、この記事におけるFormオブジェクトについて定義します。Formオブジェクトはモデル層に属するクラス群で、コントローラ層からユーザーの入力を受けとり整形・検証し永続化する責務をもちます。またビュー層に表示するためのデータを提供する、という役割もあります。
FormオブジェクトはActiveRecordモデルと1対1の場合もありますが、そうでなくてもかまいません。複数のActiveRecordモデルの場合もあれば、対応するActiveRecordモデルがない場合にも採用できます。
Formオブジェクトの必要性
Formオブジェクトはフォームの責務をカプセル化し、コントローラやビューを疎結合に保つために必要なデザインパターンです。
ユーザーの入力の整形や永続化をコントローラだけで行うと、コントローラが肥大化してしまいます。この原因はコントローラがモデル層の知識をもちすぎるためにあります。このときビューもフォームを表示するための知識をもつことになるため、コントローラと同じような問題が起こってしまいます。このことは単一責任の原則に反し、モデル層の変更がコントローラやビューに影響を及ぼすことになります。
逆にActiveRecordモデルにこういった責務をもたせると、今度はActiveRecordモデルがフォームの知識を持ちすぎてしまいます。フォームという独立した責務があるのであれば、これをひとつのクラスにカプセル化する、というのがFormオブジェクトの役割です。
Formオブジェクトにより、コントローラはFormオブジェクトの#save
のようなたったひとつのインタフェースのみを使うため、クラス間を疎結合に保てます。ビューもRailsのフレームワークに則った方法でフォームを表示できます。
FormオブジェクトはActiveRecordモデルの#save
や#update
のような単純な命令以外のことをするフォームに有用です。たとえば複数のActiveRecordモデルを操作したり、複数の子レコードをつくるといったものがあたります。また、フォームのふるまいに関するユニットテストが書きやすいというメリットもあります。
Formオブジェクトのユースケース
Formオブジェクトは、たとえば次のようなケースに適用できます。ユーザーの入力があり、ActiveRecordモデルの単純な#save
や#update
の命令で完結しない処理を行う場合に検討します。
- サインアップ処理: ユーザーの作成と他ユーザーのフォローを同時に行うなど、複数のActiveRecordモデルを作成する
- 複数のタグを作成: 複数の子レコードを作成するとき。
accepts_nested_attributes_for
を使うような場面だけど使いたくないとき - ブログの検索フォーム: Elasticsearchへのリクエストなど、ActiveRecordモデルを使用しない場合
Formオブジェクトの例
Formオブジェクトの例を、ブログの記事作成フォームをとおして示します。全体的な使い方を示せるよう、「作成・更新どちらにも対応」「検証」「複数の子要素を作成」をふまえています。
要件として、記事はタイトルと本文をもち、また複数のタグをもちます。制約としてタイトルが必須、またタグは1つ以上の入力を必要とします。これを1つのトランザクション下で行います。説明しやすいよう、タグはカンマ区切りでひとつの入力欄に記述する形式をとっています。
動作環境
この記事で示すコードは、次の各バージョンにおいて動作を確認しています。
名前 | バージョン |
---|---|
Ruby | 2.7.1 |
Ruby on Rails | 6.0.3.2 |
テーブル設計
ブログの記事作成フォームを設計する上で、次のようなテーブルを想定したコード例を示します。
Table | Column | Type | Not Null |
---|---|---|---|
posts | id | integer | ◯ |
name | string | ◯ | |
content | text | - | |
tags | id | integer | ◯ |
name | string | ◯ | |
taggings | post_id | integer | ◯ |
tag_id | integer | ◯ |
コントローラ
まずコントローラを示し、Formオブジェクトのインタフェースを確認します。その上でFormオブジェクト、ビューを示していきます。なお、ActiveRecordモデルやルーティング、例外処理など、本質でないコードは省略しています。
次のコードは記事の作成・編集を行うコントローラです。フォームを扱うとき、一般的にはActiveRecordモデルのインスタンスをビューに渡しますが、代わりにFormオブジェクトのインスタンスを渡しています。Formオブジェクトの初期化時に、#create
と#update
のときはユーザーの入力を渡しています。また編集画面の#edit
と#update
には編集対象となる@post
オブジェクトを渡しています。
class PostsController < ApplicationController def new @form = PostForm.new end def create @form = PostForm.new(post_params) if @form.save redirect_to posts_path, notice: 'The post has been created.' else render :new end end def edit load_post @form = PostForm.new(post: @post) end def update load_post @form = PostForm.new(post_params, post: @post) if @form.save redirect_to @post, notice: 'The post has been updated.' else render :edit end end private def post_params params.require(:post).permit(:title, :content, :tag_names) end def load_post @post = current_user.posts.find(params[:id]) end end
Formオブジェクト
次に本題のFormオブジェクトです。Formオブジェクトは、ユーザーの入力とpost
オブジェクトを受け取り、作成・更新処理を行います。
class PostForm include ActiveModel::Model attr_accessor :title, :content, :tag_names validates :title, presence: true validates :split_tag_names, presence: true delegate :persisted?, to: :post def initialize(attributes = nil, post: Post.new) @post = post attributes ||= default_attributes super(attributes) end def save return if invalid? ActiveRecord::Base.transaction do tags = split_tag_names.map { |name| Tag.find_or_create_by!(name: name) } post.update!(title: title, content: content, tags: tags) end rescue ActiveRecord::RecordInvalid false end def to_model post end private attr_reader :post def default_attributes { title: post.title, content: post.content, tag_names: post.tags.pluck(:name).join(',') } end def split_tag_names tag_names.split(',') end end
Formオブジェクトのコードについて解説します。まずActiveModel::Modelをincludeしています。これは値の代入やバリデーション、コールバックなど、モデルのふるまいをするための、Formオブジェクトに必要となるモジュールです。
#initialize
ではFormオブジェクトの値を初期化しています。#super
はActiveModel::Modelの#initialize
を呼び出しており、書き込みメソッド(#title=
など)を用いて値を代入しています。つまり、Formオブジェクトで用いる値は書き込みメソッドを定義する必要があります。
また、更新にも対応する場合は#default_attributes
のように保存済みのレコードをもとに値を設定する必要があります。更新に対応しない場合は#initialize
を定義する必要はありません。この場合はActiveModel::Modelの#initialize
により自動で値の初期化を行ってくれます。
値に書き込みメソッドだけでなく読み取りメソッドも定義しているのは、ビューのフォームに必要なためです。たとえばform.text_field :title
はPostForm#title
から値を取得します。フォームの内容に応じてメソッドを定義する必要があります。
#persisted?
と#to_model
はビューの表示(#form_with
)に必要なメソッドです。#persisted?
は作成・更新に応じてフォームのアクションをPOST
・PATCH
に切り替えてくれます。また#to_model
はアクションのURLを適切な場所(ここではposts_path
やpost_path(id)
)に切り替えてくれます。
バリデーションについては、ここではpresence
のみを検証しています。ActiveRecordモデルと同じく、#validate
を用い独自のバリデーションを設定できます。errors
オブジェクトにエラーを追加すれば、#valid?
での検証失敗時にユーザーにフィードバックを表示することもできます。
ビュー
最後にビューについて示します。次の内容はposts/_form.html.erb
です。posts/new.html.erb
とposts/edit.html.erb
から@form
をform
として渡す想定です。
Formオブジェクトに#persisted?
と#to_model
を定義したことで、作成・更新画面に応じてフォームの内容を切り替えてくれます。
<%= form_with model: form, local: true do |form| %> <%= form.text_field :title %> <%= form.text_area :content %> <%= form.text_field :tag_names %> <%= form.submit %> <% end %>
以上です。これまでの内容で記事を作成・更新でき、あわせて複数のタグを登録できるようになりました。Formオブジェクトを使わないと、上記のロジックがコントローラやモデル、ビューに散らばることになります。Formオブジェクトにより、フォームのロジックをひとつのクラスにカプセル化できました。
Formオブジェクトのルール
これまでの内容をもとに、Formオブジェクトを設計するときのルールについてまとめます。
- ActiveModel::Modelをincludeする
- クラス名は接尾辞を
Form
にする(例:PostForm
) #save
や#search
など、クラス名から推測可能な単一の処理用メソッドを定義する。失敗時にfalse
を返す#persisted?
と#to_model
に反応するようにする- バリデーションを実装する。ただしActiveRecordモデルとの整合性に気をつける
- Formオブジェクトがもつべき責務を明確にし、肥大化しないようにする。たとえばレコード作成時にメールで通知するのはモデル層でなくコントローラ層で行う