Model と画面上の form が1対1で一致しない場合、どのように実装するのが綺麗なのか?


#1

Railsのきれいなコードのお題案 · Issue #1 · clean-rails-ja/conversation からの転載。

ブログにカテゴリーを複数つけて投稿する機能。
はてなブログの投稿画面がイメージに近いです。

blog_img

おそらく、モデルは下記のように複数のモデルになると思います。

# app/models/article.rb
class Article < ApplicationRecord
  has_many :categories
end

# app/models/category.rb
class Category < ApplicationRecord
  belongs_to :article
end

実装方法の例

下記のような方法があると思いますが、それぞれメリット・デメリットがあると思う。

私が知らないだけで、他にも良い実装方法はあるかも。


#2

[WIP]Model と画面上の form が1対1で一致しない場合、どのように実装するのが綺麗なのか? · Issue #2 · clean-rails-ja/conversationからの転載

この例、厳密に考えるとArticleとCategoryは多対多な気がするけど、面倒なのでいったん1対多で考えますね。

accepts_nested_attributes_for の例

class Article < ApplicationRecord
  has_many :categories
  accepts_nested_attributes_for :categories, allow_destroy: true, reject_if: :all_blank
end

class Category < ApplicationRecord
  belongs_to :article
end

class ArticlesController < ApplicationController
  # ...
  def create
    # ...
  end

  def update
    @article = Article.find(params[:id])
    if @article.update(article_params)
      redirect_to article_path(@article), notice: '更新しました'
    else
      render :edit      
    end
  end

  def article_params
    params.require(:article).permit(:title, :body, categories_attributes: [:id, :name, :_destroy])
  end
end
<%= form_with @article do |f| %>
    <%= f.text_field :title %>
    <%= f.text_area :body %>
    <%= f.field_for :categories |g| %>
      <%= g.text_field :name %>
      削除する <%= g.check_box :_destroy %>
    <% end %>
  <% f.submit %>
<% end %>

メリット

  • 全部自分で書くのと比べてコード量が格段に減る

デメリット

  • 覚えづらい、ハマりやすいインターフェース
    • reject_if_destroy
    • accepts_nested_attributes_forを定義するとfields_forの挙動が変わる
      • name属性がnested attributes仕様に変わる
  • モデルの定義とビューの定義が一体化される
  • レールから外れたいときに大変
    • 保存時にbelongs_toのバリデーションが走ってN+1になってしまう、みたいなケースがあった気がする

#3
  • ライフサイクル的に親に従属して単独更新もほぼない、というケースであればaccepts_nested_attributes_for一択ですね。お題のはてなブログのカテゴリのケースだと、右側のカテゴリはAjaxか何かで単独更新にした上で、articles_categoriesまでをaccepts_nested_attributes_forにすると思います。
  • 単に複数モデルの同時更新のようなものであれば、Formオブジェクトを作った上で、accepts_nested_attributes_for互換のアクセサなど作る方向にすると思います。これをFormと呼ぶかServiceと呼ぶかは、一括更新するのが「ビジネスロジックとして整合性を担保する必然性がある」かどうかによるかなと。

#4

nested attributes嫌いな人の意見も聞いてみたいですね。僕はわかって使う分にはいいんじゃないのか派。

↓こういうバッドノウハウができてしまうのはちょっと微妙だなという気はするけども。

nested attributes なレコードを、特定の属性が空の時に削除する - おもしろwebサービス開発日記


#5

ちょっとトピックから外れますが、上記のような場合は_destroy: 1を設定するのではなく、mark_for_destructionを使うのが好みであったりはします(起きる現象は同じですが_desrtroy: 1は隠し属性的でよろしくないなーと)


#6

基本方針は@elcondorさんと同じかなあ。
ただ、フォームオブジェクトを使う場合、私はサービスと併用したりしています。
フォームオブジェクトにはあくまでユーザ入力の取り扱いのみ(具体的にはヴァリデーションとかその辺)をお願いし、その後のビジネスロジックはサービスで実装する、という形を取っていたり。

いつもそうするというわけではありませんし、フォームオブジェクトで通常のモデルでやるように振る舞いまで実装してしまっても構わないかなとは思います。
同様の処理をフォームオブジェクトを経由せずに行う処理(例えばバッチ処理やAPI経由の処理)にしたい場合、フォームオブジェクトを共有するのは違和感があるので、ビジネスロジック(サービス)とビューに依存する処理(フォームオブジェクト)に分けてる感じです。
この辺みんなどうしてるのかな?


#7

単純な複数選択の場合には、こういうのも好きです。

# class Article
def category_names
  categories.map(&:name)
end
def category_names=(names)
  self.categories = names.select(&:present?).map { |name| Category.find_or_initialize_by(name: name) }
end

ビューはselect2みたいな感じで。category_names[] を複数送ればOK。


#8

accepts_nested_attributesは滅びるべきAPIだと思ってるので、自分なら絶対使いません。
大きな理由は2点かな。

  • あれはActiveRecordのAPIでPOROに使えないので、ARのカラム構造と現実が合わなくなった時や付随する処理でPOROが必要になった時に調整で苦労する可能性が高い
  • ReactやVueの様なJSON構造と表示のbindingと相性が悪い(名前付けのルールとかdestroyフラグとか)
  • この手の複数選択系のフォームはまともなUIで作るならJSでの項目の増減や個別の編集フォーム等はほぼ必須で、今時そういうUIをRailsのform helperをベースに作ることはほぼ無いので、記述削減のメリットが無い。

基本はFormオブジェクトで、シンプルに一要素だけでマッピング可能であるならtkawaさんのスタイルも使いそうです。


#9

本当にこういうAPIが出てきたら面白そうだなぁと思っています


#10

accepts_nested_attributes_forこの辺のコメントを読む限り新規に開発する部分ではあまり使いたくないですね(バージョン上げる際の負債になりそうなので)

dhh on 15 Nov 2016 Owner
I’d actually like to kill accepts_nested_attributes_for in due time. Don’t think we should promote it for this new API. Rather, let’s just show how to do it by hand in the controller.
https://github.com/rails/rails/pull/26976#discussion_r87855694


#11

Formオブジェクトにするのは僕も賛成なのですが、お仕事だとFormオブジェクトの作り方を伝授する暇がなかったり、POROのFormオブジェクトだと記述量がそれなりに増えてしまうという理由でaccepts_nested_attributes_forを許容する機会がわりとあります。

Formオブジェクトで実装する場合、POROで実装しますか?それともなんらかのライブラリを使います?


#12

Formオブジェクトで実装する場合、POROで実装しますか?それともなんらかのライブラリを使います?

activemodelですかね。。


#13

同じく ActiveModel ですね…最近はこんなクラスを用意して使ってます。

class ApplicationModel
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveModel::Validations
end

#14

同じく ActiveModel で、

class ApplicationModel

これを個人的に「ApplicationModel パターン」と呼んでいますw

$ bin/rails g model HogeForm --parent ApplicationModel

ってするとモデルが生成できるので便利(Qiita か何かに書こうかな…)

あと、ActiveModel にすることで AR::Base なクラスとインターフェイスが揃うのが良いですね