Form Object

什么是 Form Object

封装了一个表单对象的 Ruby 类。

何时使用 Form Object

  • 需要在一个表单中更新多个 ActiveRecord 模型,这比使用 accepts_nested_attributes_for 要好的多。
  • 提交的数据不需要持久化。
  • 创建一个没有数据库模型支持的表单。

比如修改密码,找回密码,登录,注册之类的表单。而根据复杂度不同,可能这些 From Object 又会去调用 Service Object。

Form Object 和 Service Object 的区别

代码和功能上没啥区别,只有在抽象哲学层面有点不同。Form Object 是抽象出来提供给 HTML 表单用的。

使用 accepts_nested_attributes_for 怎么了?

你可能看过这篇文章 accepts_nested_attributes_for with Has-Many-Through Relations, 并且觉得 Rails Way 很棒啊,但是这会带来如下问题:

  • 你的 model 需要关心 view 是如何收集数据的
  • 强迫 view 的结构必须和 model 的结构和 relationships 对应起来
  • ActiveRecord 对象增加更多复杂的 API
  • 当同一个模型中存在两个 accepts_nested_attribtutes_for 或者一个 accepts_nested_attribtutes_for 关联的 model 存在其它 relationships 的时候,很容易使人迷惑

例子

下面是一个正常的例子。

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :company

  attribute :name, String
  attribute :company_name, String
  attribute :email, String

  validates :email, presence: true
  # … more validations …

  # Forms are never themselves persisted
  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end
class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])

    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end

如果表单中的 persist! 逻辑太复杂,您可以将此方法与 Service Object 组合。由于 validation 的逻辑通常是关联上下文的,因此可以在 Form ObjectService Object 中进行定义,而不需要在 ActiveRecord model 中定义 validate。

但是这并不意味着,model 里面一个验证都没有。应该把核心的验证逻辑放在 model 中。比如 User 模型中应该保留 email 唯一性的验证。

Form Object with active_type

  • Let your form inherit from ApplicationForm
  • Put your forms in app/forms/
  • 文件以 form 结尾并能描述出功能。例如:app/forms/admins/change_password_form.rb
  • initialize always requires a model that the form represents.
  • #sync writes form data back to the model and nested models. This will only use setter methods on the model(s).

例子

# app/forms/application_form.rb
class ApplicationForm < ActiveType::Object
  self.abstract_class = true

  attribute :object

  validates :object, presence: true
  validate :verify_object_class_correct
  validate :verify_object_valid

  before_validation :setup_object_attributes
  before_validation :setup_associations
  after_save :sync

  def initialize(params)
    self.object = object_class.new
    super
  end

  # NOTE: can implemented by subtypes.
  def object_class
    self.class.to_s[/\A([^:]+)/, 1].singularize.constantize
  rescue
    raise "Could not determine form class from #{self.class}."
  end

  private

  # NOTE: can implemented by subtypes.
  def sync
    object.save
  end

  def verify_object_class_correct
    errors.add :base, :object_class_incorrect unless object.is_a? object_class
  end

  def verify_object_valid
    promote_errors(object.errors) unless object.valid?
  end

  # NOTE: can implemented by subtypes.
  def setup_object_attributes; end

  # NOTE: can implemented by subtypes.
  def setup_associations; end

  def promote_errors(child_errors)
    child_errors.each { |attribute, message| errors.add(attribute, message) }
  end
end
# app/forms/admins/password_form.rb
class Admins::PasswordForm < ApplicationForm
  attribute :password
  attribute :password_confirmation

  validates :password, confirmation: true

  private

  def sync
    form_object.update(password: password)
  end
end
class Admins::PasswordsController < ApplicationController
  before_action :load_admin, :build_password_form

  def update
    if @password_form.save
      redirect_to admins_path, notice: 'Admin password was successfully updated.'
    else
      render :edit
    end
  end

  private

  def load_admin
    @admin = Admin.find(params[:admin_id])
  end

  def build_password_form
    @password_form = Admins::PasswordForm.new(form_object: @admin)
    @password_form.attributes = form_params
  end

  def form_params
    form_params = params[:admins_password_form]
    form_params ? form_params.permit(:password, :password_confirmation) : {}
  end
end

如果你的 From Object 要同时保存两个对象,可以参考下面的代码。

class Registration
  # Make this model play nice with `form_for`. Gives us validations, initialize, etc
  include ActiveModel::Model

  # Accessors for the fields we are exposing in the form
  attr_accessor :email, :password, :zip

  # This is an implementation detail of our authentication (Clearance). It's ultimately going to
  # sign this object in and that process expects a user object that has these fields available
  delegate :remember_token, :id, to: user

  # For this object to be valid, we want the child objects (user and profile) to be valid. This is
  # the validation method that does this.
  validate :validate_children

  # We tell registration it's a user. This will keep it pointing to Users controller and allow it to use
  # user translations
  def self.model_name
    User.model_name
  end

  # This is what our controller calls to save the user.
  def save
    if valid?
      # Create a transaction. If any of the database stuff in here fails, they will raise an exception
      # (because they are bang methods) and rollback the transaction.
      ActiveRecord::Base.transaction do
        user.save!
        profile.save!
      end
    end
  end

  private

  # Initialize the user object with the arguments that apply to user
  def user
    @user ||= User.new(email: email, password: password)
  end

  # initialize the profile object with the arguments that apply to profile
  def profile
    @profile ||= user.build_profile(zip: zip)
  end

  # the implementation of our validation method. We just delegate to our
  # two child objects which define their validations (presence on all fields in this case)
  def validate_children
    if user.invalid?
      promote_errors(user.errors)
    end

    if profile.invalid?
      promote_errors(profile.errors)
    end
  end

  # Errors on `user` or `profile` aren't helpful to us because `user.email` isn't on our form. `email` is however.
  # Take all errors from our child objects and promote them to the same field on the base object. This will make
  # them render properly on the form.
  def promote_errors(child_errors)
    child_errors.each do |attribute, message|
      errors.add(attribute, message)
    end
  end
end

参考

如果觉得我的文章对您有用,请在支付宝公益平台找个项目捐点钱。 @Victor Jun 11, 2017

奉献爱心