【Rails】Formオブジェクトを用い、モデルとフォームの密結合を防ぐ
プログラミング #Formオブジェクト #rails #クリーンコード #デザインパターン目次 / Index
はじめに
Fat Controllerを避けるために、ビジネスロジックをモデルに定義するというアプローチをよく見かけます。しかし、この方法を盲目的に適用すると、今度はFat Modelという別の問題が発生する可能性があります。
Fat Modelになると、モデルのコードが肥大化し、可読性や保守性が低下します。また、特定のビジネスロジックに関連する処理を、他のビジネスロジックの実装時に考慮しなければならなくなるなど、モデルの関心事が広がりすぎてしまう問題があります。
これらの問題を回避するために、Formオブジェクトというデザインパターンが存在します。Formオブジェクトは、フォームに関連するビジネスロジックを独立したクラスとして定義することで、モデルやコントローラーからロジックを分離します。
Formオブジェクトはどういう場面で有用か?
Formオブジェクトは以下の場合に有用です。
- フォーム固有のコールバック/バリデーションがある場合
- モデルの各カラムとフォームのinputが1:1とならない場合
それぞれ解説致します。
フォーム固有のコールバック/バリデーションがある場合
フォームに固有のコールバックやバリデーションがある場合、それらをモデルに直接記述してしまうと、他のビジネスロジックでそのモデルを使用する際に、意図しないコールバックが実行されてしまう可能性があります。
もちろん、以下のようなコードを使って、条件に応じてコールバックの実行を制御することができます:
after_update :send_email, if: -> { is_context_form? }
しかし、このようなアプローチを取ると、同様のビジネスロジック固有の条件分岐がコールバックやバリデーションに大量に存在する場合、コードの可読性が低下し、バグを生み出す原因となってしまいます。
この問題を解決するために、Formオブジェクトを使用することが効果的です。
Formオブジェクトを使えば、フォーム固有の処理をFormオブジェクト内で完結させることができます。その結果、モデルにはモデル全体で共通的に必要な定義のみが残り、コードがすっきりとします。
また、フォームに関連する処理はFormオブジェクトに集約されるため、コードの見通しが良くなり、開発効率が向上します。
モデルの各カラムとフォームのinputが1:1とならない場合
例えば、住所を入力するフォームで、都道府県と市区町村を別々の入力項目で入力してもらいたいが、データを登録する際には、それらを連結して一つのカラムに保存したいというケースを考えてみましょう。(ここでは、そもそも別のカラムにすべきや文字列ではなく外部キーにすべきという議論は置いておきます。)
この場合、Formオブジェクトを使用しない方法として、以下の2つのアプローチが考えられます。
- Cookieにデータを保持し、コントローラーで送信された値を連結したり、バリデーションエラーメッセージの表示ロジックを記述する。
- モデルに都道府県と市区町村の
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オブジェクトの導入を検討してみても良いかもしれません。