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オブジェクトがもつべき責務を明確にし、肥大化しないようにする。たとえばレコード作成時にメールで通知するのはモデル層でなくコントローラ層で行う