※この記事は、まとめ記事「多人数によるRailsアプリケーション開発」の1項目にする予定です。
ここでは、フォームクラスの使い方について考えます。フォームから送信されたデータを保存するときに、ActiveRecordのモデルではなく、ActiveModel::Modelをインクルードしたクラスを使い、バリデーションやコールバックをそちらに移してしまう、というものです。
私はフォームクラスと呼んでいますが、ネット上ではフォームオブジェクト(Form Object)と呼んでいる人が多いので、検索するときはそちらで。
サンプル: https://github.com/kazubon/blog-rails6-vuejs
環境: Rails 5.2以上
メリット
- 機能1つ(画面1つやフォーム1つ)をクラス1つに対応させることで、ソースコードのどこに実装があるか分かりやすくなる。別のプログラマが改修する際に、あちこちのファイルを探し回らなくてよい。
- Fat Controllerを防ぐとともに、モデルクラスをすっきりしたものにできる。たとえば、モデルクラスの中に特定の画面でしか使わない機能が長々と書かれている、という状態を解消できる。
- has_manyの関係にあるデータの配列など、複数のモデルがからむデータを保存するときは、フォームクラスの中でパラメータの処理と保存をベタに書けば、Railsに慣れていない人にもわかりやすい。
- テストコードが書きやすい。パラメータをいろいろ変えながらフォームクラスのテストを念入りにすれば、E2Eテストを最小限にしてテストの実行時間を減らせる。
- モデルからバリデーションやコールバックを外すと、開発用のシードデータやテスト用のFactoryBotが書きやすくなる。
新規作成・更新用のフォーム
サンプルを元にフォームクラスの流れをざっと紹介します。上記のサンプルの中でブログ記事を保存するのに使っているEntries::Formです。
コントローラはこんな感じです。なるべくRailsの基本的な型を守りつつ、モデルクラスをフォームクラスに置き換えた形にします。
def create
@entry = Entry.new
@form = Entries::Form.new(current_user, @entry, entry_params)
if @form.save
render json: { location: entry_path(@entry), notice: '記事を作成しました。' }
else
render json: { alert: '記事を作成できませんでした。' },
status: :unprocessable_entity
end
end
フォームクラスには、2つのモジュールActiveModel::ModelとActiveModel::Attributesをインクルードします。ActiveModel::Modelで使えるようになるものは、おもに次の機能です。
- assign_attributesメソッド
- バリデーション
- errorsオブジェクト
ActiveModel::Attributesでは、attributeメソッドが使えるようになります。ActiveModel::Attributesを使うを参照してください。
class Entries::Form
include ActiveModel::Model
include ActiveModel::Attributes
attributeメソッドでparamsで渡されるパラメータ名を並べます。それ以外の属性はattr_accessorで作ります。
attribute :title, :string
attribute :body, :string
attribute :draft, :boolean, default: false
attribute :published_at, :datetime
attribute :tags
attr_accessor :user, :entry
バリデーションはモデルの中ではなく、フォームクラスの中で指定します。
validates :title, presence: true, length: { maximum: 255 }
validates :body, presence: true, length: { maximum: 40000 }
validate :check_tags
initializeでは、super(params)
でパラメータを格納します。
def initialize(user, entry, params = {})
@user = user
@entry = entry
super(params)
end
このsuper(params)
は次のように置き換えても同じです。
@attributes = self.class._default_attributes.deep_dup
assign_attributes(params)
フォームクラスにsaveメソッドを用意します。ActiveRecordのモデルと同様にバリデーションに失敗したらfalseを返します。複数のモデルがからむときは、トランザクションの中でモデルを使ってレコードを保存します。
モデルにはコールバックを書かずに、保存前後の処理はこの中に入れます。
def save
return false unless valid? # バリデーション実行
set_published_at # before_saveに当たるメソッド
entry.user = user
ActiveRecord::Base.transaction do
entry.update!(attributes.except('tags')) # モデルを使って保存
save_tags! # after_saveに当たるメソッド
end
true
end
必要に応じて、assign_attributesやvalid?を上書きしてもよいです。
検索フォーム
上記のサンプルでは、検索用のフォームでもフォームクラスを使っています。ブログ記事を検索するEntries::SearchFormです。コントローラはこんな感じです。
def index
@user = User.active.find(params[:user_id]) if params[:user_id].present?
@form = Entries::SearchForm.new(current_user, @user, search_params)
respond_to :html, :json
end
Entries::Formと同様に、ActiveModel::ModelとActiveModel::Attributesをインクルードして属性を設定し、initializeでパラメータを読み込みます。
class Entries::SearchForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :title, :string
attribute :tag, :string
attribute :offset, :integer
attribute :sort, :string
attr_accessor :current_user, :user, :entries, :entries_count
def initialize(current_user, user, params = {})
@current_user = current_user
@user = user
super(params)
end
記事の配列を取り出すメソッドと記事数を取り出すメソッドです。
def entries
@entries ||= relation.preload(:user, :tags).order(sort_key => :desc)
.limit(20).offset(offset)
end
def entries_count
@entries_count ||= relation.count
end
クエリーを組み立てるメソッドです。モデルの中にスコープやクエリーを返すメソッドを書かずに、フォームクラスに記述します。
def relation
return @relation if @relation
rel = if user
user == current_user ? user.entries : user.entries.published
else
Entry.joins(:user).merge(User.active).published
end
if title.present?
rel = rel.where('title LIKE ?', '%' + title + '%')
end
if tag.present?
rel = rel.joins(:tags).where('LOWER(tags.name) = LOWER(?)', tag)
end
@relation = rel
end
モデルから移すもの
バリデーション
私の観察するところでは、モデルでのバリデーションはたいてい次のどちらかになります。
- バリデーションと保存は特定のフォームでしか行われない。
- 複数のフォームで保存が行われるが、バリデーションの仕方がそれぞれ違う。
どちらの場合でも、フォームクラスの中にバリデーションを移すほうが「どこで何やってるのか」が明確になります。特にバリデーションの仕方が違う場合は、if
オプションをやたらと生やすよりも、別々のフォームクラスで別々のバリデーションを書くほうがよいです。
必要があればモデルのほうにバリデーションを残してもかまいません。フォームとモデルで同じバリデーションを二重にやってもたいてい害はありません。
コールバック
before_validationやafter_saveなどのモデルのコールバックはなるべく避けます。コールバックではレコード保存時の処理の流れがわかりにくくなりますし、バリデーションと同様に画面によってやることが変わったりします。上記のフォームクラスのsaveメソッドのようにベタに書くほうが楽です。
スコープ
モデルクラスの中に大量のスコープを書いたり、->{ }
の中に長々としたクエリーメソッドを書かなくても、上記のSearchFormのように検索用のクラスの中にクエリーを書けば、たいていはそれで済みます。
ただし、scope :active, ->{ where(deleted_at: nil) }
のような、ソースコードの各所から頻繁に使われる簡潔なスコープは、モデルクラスの中に書くほうがよいでしょう。
Nested attributesは使わない
あるモデルにぶら下がっている複数のモデル(has_manyの関係にあるモデル)を保存するときは、Nested attributes(accepts_nested_attributes_forやfields_for)のことは忘れたほうがよいです。
Nested attributesはプログラマの頭を痛め、Railsのコードを取っ付きにくいものにします。上記のサンプルでは、記事のタグを保存するときは、save_tags!メソッドで単純にTagモデルの配列を保存しています。
そのほか補足
i18nでパラメータ名を追加する
errorsに加えたエラーのメッセージを表示したいときは、config/ja.ymlで activemodel -> attributes -> フォームクラス名の下にパラメータ名を記述します。
activemodel:
attributes:
entries/form:
title: タイトル
フォームクラスが長くなったときは
フォームクラスが長大化してきたら、適宜Serviceクラスを作って処理を任せます。たとえば、このサンプルのsave_tags!メソッドが長いものになったら、EntryTagsSaver(名前はてきとうです)を作ってレコード保存用のコードを移します。
def save_tags!
service = EntryTagsSaver.new(entry, tags)
service.perform
end
テンプレートで使うメソッド
Fat Controllerを避けるには、コントローラ内でインスタンス変数をいくつも作ったりせずに、フォームクラスにヘルパーメソッドを書きます。たとえば、このサンプルのフォームで、ユーザーがよく使うタグを選ばせる機能を追加するときは、Entris::Formクラスにメソッドを足します。
def my_tags
# よく使うタグ名の配列を作成
end
テンプレート(この場合はjson)でそのメソッドを呼び出します。
json.entry do
# そのほかのデータ...
json.my_tags @form.my_tags
end