Railsのデザインパターン: Formオブジェクト

Ruby on Railsでフォームを扱うとき、ロジックをコントローラやビューに書くとコードの肥大化につながります。 またテストが書きづらく、モデルの変更による影響を受けやすいという問題もあります。

Formオブジェクトを採用することで、コントローラやビューにちらばるロジックをひとつのクラスにカプセル化できます。 ユーザのフォームからの入力を扱うときに採用する価値のあるデザインパターンです。 これについて示します。

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つのトランザクション下で行います。

説明しやすいよう、タグはカンマ区切りでひとつの入力欄に記述する形式をとっています。

動作環境

この記事で示すコードは、次の各バージョンにおいて動作を確認しています。

名前バージョン
Ruby2.7.1
Ruby on Rails6.0.3.2

テーブル設計

ブログの記事作成フォームを設計する上で、次のようなテーブルを想定したコード例を示します。

TableColumnTypeNot Null
postsidinteger
namestring
contenttext-
tagsidinteger
namestring
taggingspost_idinteger
tag_idinteger

コントローラ

まずコントローラを示し、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 :titlePostForm#titleから値を取得します。 フォームの内容に応じてメソッドを定義する必要があります。

#persisted?#to_modelはビューの表示(#form_with)に必要なメソッドです。 #persisted?は作成・更新に応じてフォームのアクションをPOSTPATCHに切り替えてくれます。 また#to_modelはアクションのURLを適切な場所(ここではposts_pathpost_path(id))に切り替えてくれます。

バリデーションについては、ここではpresenceのみを検証しています。 ActiveRecordモデルと同じく、#validateを用い独自のバリデーションを設定できます。 errorsオブジェクトにエラーを追加すれば、#valid?での検証失敗時にユーザにフィードバックを表示することもできます。

ビュー

最後にビューについて示します。 次の内容はposts/_form.html.erbです。 posts/new.html.erbposts/edit.html.erbから@formformとして渡す想定です。

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

Author
著者Hiroki Zenigami

プロダクト開発者。妻と娘、猫とのんびり暮らしています。


Publishing
現場で使えるRuby on Rails 5

共著で「現場で使えるRuby on Rails 5(マイナビ出版)」を出版しました