読み込み中...

【Rails】Formオブジェクトを用い、モデルとフォームの密結合を防ぐ

プログラミング #Formオブジェクト #rails #クリーンコード #デザインパターン
2024年4月12日
2024年4月23日
【Rails】Formオブジェクトを用い、モデルとフォームの密結合を防ぐ

はじめに

Fat Controllerを避けるために、ビジネスロジックをモデルに定義するというアプローチをよく見かけます。しかし、この方法を盲目的に適用すると、今度はFat Modelという別の問題が発生する可能性があります。

Fat Modelになると、モデルのコードが肥大化し、可読性や保守性が低下します。また、特定のビジネスロジックに関連する処理を、他のビジネスロジックの実装時に考慮しなければならなくなるなど、モデルの関心事が広がりすぎてしまう問題があります。

これらの問題を回避するために、Formオブジェクトというデザインパターンが存在します。Formオブジェクトは、フォームに関連するビジネスロジックを独立したクラスとして定義することで、モデルやコントローラーからロジックを分離します。

Formオブジェクトはどういう場面で有用か?

Formオブジェクトは以下の場合に有用です。

  1. フォーム固有のコールバック/バリデーションがある場合
  2. モデルの各カラムとフォームのinputが1:1とならない場合

それぞれ解説致します。

フォーム固有のコールバック/バリデーションがある場合

フォームに固有のコールバックやバリデーションがある場合、それらをモデルに直接記述してしまうと、他のビジネスロジックでそのモデルを使用する際に、意図しないコールバックが実行されてしまう可能性があります。

もちろん、以下のようなコードを使って、条件に応じてコールバックの実行を制御することができます:

after_update :send_email, if: -> { is_context_form? }

しかし、このようなアプローチを取ると、同様のビジネスロジック固有の条件分岐がコールバックやバリデーションに大量に存在する場合、コードの可読性が低下し、バグを生み出す原因となってしまいます。

この問題を解決するために、Formオブジェクトを使用することが効果的です。

Formオブジェクトを使えば、フォーム固有の処理をFormオブジェクト内で完結させることができます。その結果、モデルにはモデル全体で共通的に必要な定義のみが残り、コードがすっきりとします。

また、フォームに関連する処理はFormオブジェクトに集約されるため、コードの見通しが良くなり、開発効率が向上します。

モデルの各カラムとフォームのinputが1:1とならない場合

例えば、住所を入力するフォームで、都道府県と市区町村を別々の入力項目で入力してもらいたいが、データを登録する際には、それらを連結して一つのカラムに保存したいというケースを考えてみましょう。(ここでは、そもそも別のカラムにすべきや文字列ではなく外部キーにすべきという議論は置いておきます。)

この場合、Formオブジェクトを使用しない方法として、以下の2つのアプローチが考えられます。

  1. Cookieにデータを保持し、コントローラーで送信された値を連結したり、バリデーションエラーメッセージの表示ロジックを記述する。
  2. モデルに都道府県と市区町村の attribute を定義し、before_validationで連結してカラムに値をセットする。

しかし、1つ目の方法では、本来コントローラーで実装すべきではない処理が含まれてしまいます。これを回避するためには、サービスオブジェクトなどを別途実装する必要がありますが、form_withとの親和性を考慮すると、Formオブジェクトの方がより適しています。

2つ目の方法では、フォーム固有の属性がモデルに次々と定義されていくと、モデルが肥大化してしまう問題があります。また、これらの属性はデータベースには保存されないため、モデルの責務から逸脱してしまいます。

Formオブジェクトを使用すると、Formオブジェクト内に都道府県と市区町村のattributeの定義や、文字列連結などのビジネスロジックをまとめることが出来ます。

Formオブジェクトの定義方法

これまでの内容からFormオブジェクト導入によるメリットを理解頂けたかと思います。

では、Formオブジェクトはどのように定義すべきですが、以下のコードを確認頂ければと思います。

app/models/post/post_form.rb

class Post::PostForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :prefecture, :string
  attribute :municipality, :string

  validates :address, presence: true # formでのみバリデーションする前提

  delegate_missing_to :post

  def initialize(attributes = {}, post: Post.new)
    @post = post
    super(attributes)
  end

  def save
    set_address
    return if invalid?

    ActiveRecord::Base.transaction do
      post.save!
    end
  rescue ActiveRecord::RecordInvalid
    false
  end

  # ビューの表示(form_with)に必要なメソッド
  # アクションのURLを適切なもの(posts_pathやpost_path(id))に切り替えてくれる
  def to_model
    post
  end

  private

  attr_reader :post

  def set_address
    post.address = "#{prefecture} #{municipality}"
  end
end

Formオブジェクトの使用方法

使用する際には以下のように使用します。

app/controllers/posts_controller.rb

class PostsController < ApplicationController
  before_action :set_post, only: %i[edit update]

  def new
    @post = Post::PostForm.new
  end

  def create
    @post = Post::PostForm.new(post_params)

    if @post.save
      redirect_to posts_path, notice: 'Successfully created!'
    else
      render :new
    end
  end

  def edit
    @post = Post::PostForm.new(post: @post)
  end

  def update
    @post = Post::PostForm.new(post_params, post: @post)

    if @post.save
      redirect_to @post, notice: 'Successfully updated!'
    else
      render :edit
    end
  end

  private

  def post_params
    params.require(:post).permit(:title, :content, :prefecture, :municipality)
  end

  def set_post
    @post = Post.find(params[:id])
  end
end

viewのform部分

<%= form_with model: @post do |f| %>
  <%= f.text_field :title %>
  <%= f.text_area :content %>
  <%= f.text_field :prefecture %>
  <%= f.text_field :municipality %>
  <%= f.submit %>
<% end %>

このようにしてコントローラでnewしてviewではFormオブジェクトを使用しないパターンと同様に扱う事が可能となり、ビューとFormオブジェクトの結合度を下げ、より柔軟で保守性の高いコードを書くことが可能になります。

小ネタですが、個人的にFormオブジェクトを使用する時はdelegate_missing_toを使用することが多いです。

Formオブジェクトに存在しないメソッドが呼び出された場合に、指定したオブジェクトにその処理を委譲するための機能です。

例えば、@post.persisted?のように、Post::PostFormに定義されていないメソッドがcallされた場合、postオブジェクトのpersisted?メソッドが代わりに呼び出されます。

delegate_missing_toを活用することで、Formオブジェクトとモデルオブジェクトの間の処理の委譲を簡潔に表現でき、コードの可読性と保守性が向上します。

最後に

Formオブジェクトは、Rails開発において非常に有用なパターンであり、適切に使用することでコードの品質と開発効率を向上させることができます。プロジェクトの要件や規模に応じて、Formオブジェクトの導入を検討してみても良いかもしれません。



システム開発やDX戦略などでお困りの際はお気軽にご相談ください。

※通常1営業日以内に回答致します。

お問い合わせはこちら
Top