20
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

フォームクラスを使う

Last updated at Posted at 2020-01-04

※この記事は、まとめ記事「多人数による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の基本的な型を守りつつ、モデルクラスをフォームクラスに置き換えた形にします。

app/controllers/entries_controller.rb
  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を使うを参照してください。

app/forms/entries/form.rb
class Entries::Form
  include ActiveModel::Model
  include ActiveModel::Attributes

attributeメソッドでparamsで渡されるパラメータ名を並べます。それ以外の属性はattr_accessorで作ります。

app/forms/entries/form.rb
  attribute :title, :string
  attribute :body, :string
  attribute :draft, :boolean, default: false
  attribute :published_at, :datetime
  attribute :tags
  attr_accessor :user, :entry

バリデーションはモデルの中ではなく、フォームクラスの中で指定します。

app/forms/entries/form.rb
  validates :title, presence: true, length: { maximum: 255 }
  validates :body, presence: true, length: { maximum: 40000 }
  validate :check_tags

initializeでは、super(params)でパラメータを格納します。

app/forms/entries/form.rb
  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を返します。複数のモデルがからむときは、トランザクションの中でモデルを使ってレコードを保存します。

モデルにはコールバックを書かずに、保存前後の処理はこの中に入れます。

app/forms/entries/form.rb
  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です。コントローラはこんな感じです。

app/controllers/entries_controller.rb
  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でパラメータを読み込みます。

app/forms/entries/search_form.rb
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

記事の配列を取り出すメソッドと記事数を取り出すメソッドです。

app/forms/entries/search_form.rb
  def entries
    @entries ||= relation.preload(:user, :tags).order(sort_key => :desc)
      .limit(20).offset(offset)
  end

  def entries_count
    @entries_count ||= relation.count
  end

クエリーを組み立てるメソッドです。モデルの中にスコープやクエリーを返すメソッドを書かずに、フォームクラスに記述します。

app/forms/entries/search_form.rb
  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

関連記事

20
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?