Rails におけるネストしたリソースを扱うフォームの実装方法
はじめに
本記事の動機
最近、既存のフォームオブジェクトのリファクタリングを考えることがありました。 そのときにふと「ネストしたリソースを扱うフォームの実装方法にはどのような選択肢があるのか」という疑問が浮かびました。
この記事では、上記の疑問について調べた内容、それぞれの選択肢について実際に手を動かしてみた所感をまとめています。 同じようなケースに遭遇した方の参考になれば幸いです。
対象読者
この記事は以下の方々を対象としています。
- ネストしたリソースを扱うフォームの実装方法の選択肢を知りたい
- フォームオブジェクトの概要は知っているが、フォームオブジェクトの実装パターンを知りたい
環境
以下のサンプルコードは Ruby3.1.0・Rails7.0.2 で動作することを確認しています。
サンプルコードの前提
サンプルコードを交えた具体的な実装方法の説明をする前に、データ構造と今回のサンプルコードで実装したフォームの要件をまとめます。
サンプルコードで扱うデータの構造は以下です。Shop と Book は1対多の関係です。
shops
Column | Type |
---|---|
name | string |
address | string |
books
Column | Type |
---|---|
title | string |
description | string |
shops_id | integer(FK) |
# app/models/shop.rb class Shop < ApplicationRecord has_many :books, dependent: :destroy validates :name, presence: true end
# app/models/book.rb class Book < ApplicationRecord belongs_to :shop validates :title, presence: true end
フォームの要件は以下としました。
- Shop と Book を一括で新規作成・編集できる
- Book は必ず2冊登録する(サンプルコードをできるだけシンプルにするための要件です)
- Shop と Book のバリデーションエラーが表示される
次にサンプルコードを交えた実装方法の説明しますが、元となるコードに対して、各種選択肢による実装を PR で見れるサンプルのリポジトリを作成しましたので必要に応じてご参照ください。
ちなみに元となるコード(main ブランチ)は scaffold し、Shop・Book に必要な関連、バリデーションを追加した状態です。
https://github.com/koheitakahashi/form-sample-app/tree/main
ネストしたリソースを扱うフォームの選択肢
ネストしたリソースを扱うフォームの選択肢には以下のようなものがあります。
- 1.
accept_nested_attributes_for
を使う - 2.フォームオブジェクトを使う
- 2-1. gem を使わない
- 2-2. gem を使う
- 2-2-1. yaaf を使う
- 2-2-2. reform を使う
これらについて、サンプルコードとともに所感を述べます。
1. accept_nested_attributes_for
を使う
accept_nested_attributes_for
は ActiveRecord の機能になります(参考)。accept_nested_attributes_for
を使用すると、関連付けられたmodelに値を入れるためのメソッドが生えます。
サンプルコード
この実装による、サンプルコードの全体は以下です。 https://github.com/koheitakahashi/form-sample-app/pull/1/files
重要な部分は以下です。Shop モデルに記述することで books_attribute=
メソッドが定義されます。そのメソッドが生えることで、Shop モデルが Books の作成・更新をできます。
# app/models/shop.rb class Shop < ApplicationRecord has_many :books + accepts_nested_attributes_for :books end
所感
コードの記述量が少なくて済むことがメリットだと感じます。Model は上記の変更だけです。Controller ではフォームの初期表示のために必要な Book モデルのインスタンスを作っておくだけですみます。View では ActionView::Helpers::FormBuilder#fields_for を使えば、フィールドとパラメータの送り方に悩む必要はありません。
子モデル(Book)のエラーは親モデル(Shop)に ActiveModel::NestedError
オブジェクトとして格納されるので、後述するフォームオブジェクトと比べてエラー格納について考えることが少なくなります。
一方で、特定のフォームでのみバリデーションを有効にしたいケースには向かないと考えられます。例えば、A と B のフォームがあって、A のフォームでは Book モデルの title
は空を許容しないが、B のフォームでは許容したいという場合などです。
一応、ActiveRecord::Validations では特定のコンテキストでバリデーションを実行できます。これを使えば、上記のケースには対応できます。
しかし、関連するフォームや、そこで扱うモデルが多くなってきた場合に、必要なコンテキストが多くなることが予想されます。
2-1. フォームオブジェクトを使う(gemを使わない場合)
フォームオブジェクトとは Rails のデザインパターンの一つです。View(ユーザー入力を受け取るフォーム)と Model の間にフォームに関する責務を負った間接層を導入するというパターンです。 このパターンの導入により、「ユーザーの入力値の加工・検証」などのフォーム独自の責務をカプセル化できます。そのため、特定のフォームに関する処理はそのフォームに対応するフォームオブジェクトにまとめることができます。
サンプルコード
この実装による、サンプルコードの全体は以下です。 https://github.com/koheitakahashi/form-sample-app/pull/2/files
重要な箇所は app/forms/shop_form.rbです。これが今回導入したフォームオブジェクトです。
このフォームオブジェクトでは、以下をやっています。
- ユーザー入力の検証
- Shop・Book モデルの save・update
- 入力フォームの初期表示に必要なモデルのインスタンスを作成する
所感
フォームに関する処理をまとめる置き場ができたことにより、accept_nested_attributes_for
で挙げた問題点を解消できました。
一方で、accept_nested_attributes_for
に比べてコードの記述量が多いことがデメリットとして挙げられます。関連するモデルの更新だけではなく、フォームオブジェクトに関連するモデルのエラーオブジェクトを格納することも自前で実装する必要があります。
また、フォームオブジェクトの実装は自由度が高いため、複数人で開発する際には実装に差異が出やすいように思いました。例えば、以下のようなところが実装が分かれそうです。
- 新規作成フォームと編集フォームで処理が異なる場合に、どのフォームかを判断する方法
- 関連するモデルのインスタンスを生成するタイミング
a について、サンプルコードでは ShopForm の shop
というキーワード引数を渡したとき、そして、Book のパラメータに id が含まれていた場合は編集画面用の処理が走るようになっています。
しかし、ShopForm の引数に edit
のようなキーワード引数を用意して、編集画面の処理を走らせたい場合は ShopForm.new(edit: true)
などのように呼び出すように実装することも可能です。
b に関しては、サンプルコードでは、books_attributes=
が呼ばれたタイミングで、必要な Book モデルのインスタンスを生成しています。しかし、ShopForm が initialize されたときに、インスタンスを生成することも可能です。
このように、実装が別れやすいポイントがあるので、複数人で開発をしていた場合にフォームオブジェクト間でインターフェースや、内部処理が分かれることになるということが起こりそうです。内部処理の差異の問題は深刻ではないかも知れません。しかし、インターフェースが異なるなどはフォームオブジェクトを使う際に混乱してしまうため、開発者間で実装のルールを作っておくなどの対応は必要だと思いました。
2-2-1. yaaf を利用したフォームオブジェクト
フォームオブジェクトを自前で書くこともできますが、実装しやすくするための gem があります。その中の1つが yaafです。yaaf 自体は100行に満たない薄い gem で、関連するオブジェクトを、トランザクションをかけて save する、エラーオブジェクトをフォームオブジェクトに格納するなどをしてくれます。
サンプルコード
この実装による、サンプルコードの全体は以下です。 https://github.com/koheitakahashi/form-sample-app/pull/3/files
ポイントとしては以下です。
YAAF::Form
を継承して、フォームオブジェクトを作成する- フォームオブジェクトの
initialize
の中で、そのフォームオブジェクトが扱うモデルのインスタンスを全て@models
に入れる@models
に格納されたインスタンスをループで回して save するという内部処理となっている
所感
YAAF の導入によりメリットに感じた点は以下です。
- YAAF がモデルを検証・作成・更新をするため、自前でそのようなメソッドを用意しなくて良い
- バリデーションエラーもフォームオブジェクトに格納される
- 薄い gem のため、全体像の把握が容易
逆にデメリットに感じた点は特にありませんでした。
2-2-2. reform を利用したフォームオブジェクト
yaaf の他には reform があります。trailblazerというフレームワークの一部になります。
サンプルコード
この実装による、サンプルコードの全体は以下です。 https://github.com/koheitakahashi/form-sample-app/pull/4/files
ポイントとしては以下です。
property
・collection
などの DSL が生えて、それを使ってフォームオブジェクトを実装する- 作成されたフォームオブジェクトには
validate
などのメソッドが生える #save
を実行することで、ネストしたモデルを一括で作成・更新することができる
所感
reform で用意されている DSL を使うことで、少ない記述量でフォームオブジェクトを実装することができます。そのため、少ないコードでフォームオブジェクトを実装できることがメリットでしょう。
一方で、DSL の使い方が ActiveModel と違うため戸惑いがありました。そういう意味では、DSL に慣れる必要があるデことがメリットとして挙げられます。
まとめ
ここまで、ネストしたリソースを扱うフォームの実装方法の選択肢を挙げて、それらの実装例と所感を見てきました。これらの選択肢は、まとめると以下のような対比構造になります。
accept_nested_attributes_for
かフォームオブジェクトか- フォームオブジェクトを実装する場合、gem を使うか、使わないか
- gem を使ったフォームオブジェクトの実装の場合、yaaf か reform か
1. accept_nested_attributes_for
かフォームオブジェクトか
accept_nested_attributes_for
- メリット
- ActiveModel の機能を使うだけなのでコードの記述量が少ない
- デメリット
- 特定のフォームだけに〇〇のバリデーションを有効するなどが難しい
- Model・Controller が肥大化する
- メリット
- フォームオブジェクト
- メリット
- 特定のフォームだけに〇〇のバリデーションを有効するなどができる
- Controller・Model の肥大化を防げる
- デメリット
accept_nested_attributes_for
に比べてコードの記述量が多い
- メリット
2. フォームオブジェクトの実装について、gem を使うか、使わないか
- gem を使わない場合
- メリット
- 実装の自由度がある
- デメリット
- 自由度があるため、複数人で開発する場合はフォームオブジェクト間の実装が異なりやすい
- メリット
- gem を使う場合
- メリット
- gem の使い方に従うようになるためフォームオブジェクトの実装にルールを持ち込める
- 自前で実装することが少なくなる
- デメリット
- gem によっては DSL が慣れないものがある
- フォームオブジェクト実装の自由度が失われる
- メリット
3. gem を使ったフォームオブジェクトの実装の場合、yaaf か reform か
- yaaf
- メリット
- やっていることは ActiveModel のレールに乗っていることなので、ActiveModel に慣れていれば実装はしやすい
- gem が薄いので、gem の全容が追いやすい
- デメリット
- 特になし
- メリット
- reform
- メリット
- DSL が提供され yaaf よりもさらにコードの記述量が少なく実装できる
- デメリット
- DSL に慣れる必要がある
- メリット
最後に
ここまで、ネストしたリソースを扱うフォームの実装方法をまとめました。個人的には以下の順序で実装を考えると思います。
accept_nested_attributes_for
で実装できないかを考える- フォームオブジェクトを導入する場合は yaaf の導入を検討する
Rails で実装されている機能を使えば事足りるのであれば、それを使いたいと考えているからです。
また、フォームオブジェクトを使った場合でも、ある程度のルールがあった方がフォームオブジェクトの読みやすさや改修しやすさがあると思うため、そのルールを持ち込む意味で yaaf を導入を検討したいです。また、reform は色々やってくれる印象があるのですが全容を把握しきれていないです。そのため、全容を把握しやすい yaaf の方が好みでした。
今回の記事作成にあたって参考にした資料
フォームオブジェクトとは
- Railsのデザインパターン: Formオブジェクト - applis
- form objectを使ってみよう - メドピア開発者ブログ
- Fat Modelの倒し方 / how to deal with fat model - Speaker Deck
- Disciplined Rails: Form Object Techniques & Patterns — Part 3 | by Jaryl Sim | Medium
accept_nested_attributes_for について
- accepts_nested_attributes_forを使わず、複数の子レコードを保存する | Money Forward Engineers' Blog
- Railsアプリケーションの実装で気をつけている8つのこと – PSYENCE:MEDIA
- ActiveRecord::NestedAttributes::ClassMethods
- Action View フォームヘルパー - Railsガイド