重构 Form Object 的 Validate 在并发时候的有一坑

bydmm · 2014年07月07日 · 最后由 bydmm 回复于 2014年10月30日 · 7849 次阅读

前置知识: Form Object http://railscasts.com/episodes/416-form-objects

Form Object 在创建表单的时候特别好使,在不同的业务的时候对同一个 model 的处理和验证可能都不同,这时候逻辑无论是写在 model 层或 controller 层都不太好使。这时候 Form Object 就横空出世了。

但是我们在使用 Form Object 的时候也遇到了一个坑。

那就是 Form Object 在并发的时候验证有问题。

class SignUpForm
  validate :uniqueness_of_email

  def submit(params)
    user.attributes = params.slice(:email, :first_name, :last_name)
    if valid?
      run_callbacks :save do
        user.save!
      end
    end
  end

  private

  def uniqueness_of_email
    errors.add(:email, 'Email is already in use') unless User.find_by(email: email).nil?
  end
end

当用户在注册表单上连点两次提交的话就会挂在 user.save! 上。 原因是 User 这个 model 内为了保证数据完整性也加了一个 validates :email, uniqueness: true 当用户连点两次提交,两次提交会进入两个不同的进程,两个进程都会通过 Form Object 下的验证, 幸好 Model 层有事务的保护,最后一次提交会失败,保证了数据的完整。

现在已经在提交按钮上添加了 disable_with: 'saving' 使用户不能点两次提交,从前端解决这个问题。 但是这暴露了非事务的 Form Object 在并发下验证的脆弱。

对此,同事的建议是使用 http://www.ruby-doc.org/core-2.1.2/Mutex.html 让保存这件事只能单线程执行。

我觉得“幸好”这个词不对。正是因为 ActiveRecord 有保护,所以你不需要在这些 Service 里面增加复杂性,比如 Mutex 之类。

另外,我不知道你的run_callbacks是不是用了 transaction,如果没有,应该用。

第二次提交失败是完全合理的,服务器端的工作没有任何问题。这个问题只能从前端解决。

数据库加 email 唯一索引才是最有效的

rails 做的 uniqueness 验证并不保险,比如当两个人使用同一个邮箱注册,在验证成功时,还没有保存,另一个人并发请求,也会验证通过,之后两个人都会保存成功的

Mutex 只能保存同一进程多个线程有效,多个进程是无效的。

匿名 #3 2014年07月07日

#1 楼 @billy "第二次提交失败是完全合理的,服务器端的工作没有任何问题。这个问题只能从前端解决。"

FormObject 属于后端工作。问题的关键是:使用 form object 就是希望把特定的 validate 从 model 里剥离出来,只用于特定业务(例如这里的 sign up),避免把这些代码都放在 model 里。

run_callbacks 显然是用了 transaction 的,但是并没有把 ·if valid?· 放进去,所以 form object 里的 validation 也没有被 transaction 包围

@brookzhang Form Object 剥离出特定的 validation rules 应该是有选择的,主要是不适合在 model 里面执行,跨 model 之类的。如果它的工作只是重复 model,那真的没有必要用。

像 email uniqueness 这种,按常识这种肯定是 model 和 db 的职责,就像@cxh116说的。完全不能依赖 Form object。

匿名 #5 2014年07月07日

#4 楼 @billy 认同 +1

我不认为这是个 Bug FormObject 不应该有必要解决这个只有靠 Transaction 才能防御住的问题。另外 Mutex 防线程不防进程啊。

写 XXX 有一坑的时候真要先检查自己的问题。

我倒觉得 model 层 validation 没通过就足够了,返回的 error message 也可以直接展示在页面上。 而且 Form object 层的 validation。一部分我觉得可以用 valid?(context) 去做。

class User < ActiveRecord::Base
  validates :email, uniqueness: true
  validates :address, presence: true, on: :sign_up  # 只有 sign up 需要做的验证
end

class SignupForm
  def submit(params)
    if user.valid?(:sign_up)
      # ...
    end
  end
end

这个说不上是 form object 的坑吧?多进程多线程都会碰到这个问题..

我都是这样作,要是遇到重复按,就用那個已经存进资料库的即可:

begin
  @order.save!
rescue ActiveRecord::RecordNotUnique => e
  # Remedy user double click submit problem
  if e.message =~ /Duplicate entry '\d+' for key 'index_orders_on_email'/
    @order = Order.where(email:params[:order][:email]).first
  else
    raise
  end
end

#10 楼 @lulalala 这个方案我喜欢。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号