Rails 使用 Subscriber 来管理 Model Callbacks [北京 Rubyists 活动分享]

zfben · March 28, 2017 · Last by IChou replied at April 09, 2017 · 4342 hits
Topic has been selected as the excellent topic by the admin.

这次 Ruby 线下聚会,我只做了个简单的 5 分钟分享,但收获了不少知识和技术观点,下面是加上后来反思的内容后,补充完善的分享内容。欢迎大家继续讨论,也希望大家踊跃报名下次线下聚会的分享~

场景及问题

  • 随着业务的复杂化,Model 间互相调用的情况越来越多
  • 但大部分情况下如果封装 service 层又过于复杂
  • 于是 callback 里堆积了大量操作,导致 model 文件越来越长

解决方法

  • 用观察者模式,监听 model callback 触发
  • 用约定来清晰划分代码位置

简单栗子

当更新商品价格时,需要更新相关订单的价格。

class Product < ApplicationRecord
  has_many :orders

  after_update do
    if price_changed?
      orders.each do |order|
        order.update! price: price
      end
    end
  end
end

使用 Subscriber 后

# ./app/subscribers/order_subscriber.rb
subscribe_model :product, :after_update do
  if price_changed?
    orders.each do |order|
      order.update! price: price
    end
  end
end

使用约定

  • 只有与别的 model 交互的业务,才能写到 subscriber 中
  • subscriber 中不能修改触发 model
  • 业务代码放置在消费该触发的 subscriber 里
  • 如果单个 subscriber 太长,也可以按业务切分成多个 subscriber,如 order_product_subscriber.rb

好处

  • model 文件中不再包含大量 callback
  • 每个 model 需要消费哪些其它 model 一目了然
  • after_save 类的 callback,依然是被包裹在一个 transaction 中
  • 支持二级 model 的回调(会先触发二级 model 的 callbacks,再触发 base_class 的 callbacks)
  • 可以单独写测试(基于 ActiveSupport::Notifications)

核心代码

# ./config/initializers/subscribe_model.rb
def subscribe_model(model_name, event_name, &block)
  ActiveSupport::Notifications.subscribe("active_record.#{model_name}.#{event_name}") do |_name, _started, _finished, _unique_id, data|
    data[:model].instance_eval(&block)
  end
end

class ActiveRecord::Base
  class_attribute :skip_model_subscribers
  self.skip_model_subscribers = false
end

%i(after_create after_update after_destroy after_save after_commit after_create_commit after_update_commit).each do |name|
  ActiveRecord::Base.public_send(name) do
    unless skip_model_subscribers
      readonly! unless readonly?
      ActiveSupport::Notifications.instrument "active_record.#{self.class.model_name.singular}.#{name}", model: self
      ActiveSupport::Notifications.instrument "active_record.#{self.class.base_class.model_name.singular}.#{name}", model: self if self.class.base_class != self.class
      public_send(:instance_variable_set, :@readonly, false)
    end
  end
end

Rails.application.config.after_initialize do
  Dir[Rails.root.join('app/subscribers/*.rb')].each { |f| require f }
end

FAQ

Q: 为什么不用 concern?

A: 团队内部约定,只有两个及以上的 model 有共用代码,才能移到 concern。

Q: 为什么不创建 service 层?

A: service 层不够直观,而且对于小型团队来说,维护成本高于 subscriber。

转自 https://jiandanxinli.github.io/2017-03-27.html

个人会觉得 service 更适用于 api、admin 等不同使用场景的模块共存在一个项目里,但是业务逻辑相同的情况~ 订阅模式还是值得试试的

huacnlee mark as excellent topic. 28 Mar 17:36

👍 学到了

wisper 类似

被移掉的 Observer 又要加回来的感觉。

这是我上次说的 wisper 的方式

class Job
###...

  after_save :send_finished_info

  def send_finished_info
    if status_changed? && self.class.finished_statuses.include?(status)
      WisperBroadcast.new.broad_cast(:job_finished, self)
    end
  end



# init 里 
module RegisterObserver
  class << self
    def init
      Wisper.subscribe(QueueObserver.new)
      Wisper.subscribe(AgentObserver.new)
      Wisper.subscribe(MonitorQueueObserver.new)
# ...
      Wisper.subscribe(MixpanelObserver.new)
      Wisper.subscribe(StarBranchesObserver.new)
      Wisper.subscribe(DingTalkMessageObserver.new)
    end
  end
end

RegisterObserver.init

之后就可以全局看到多少人受到 job_finished 的影响了

当然这种写法你最好不要在一个 observer 里再次 broadcast,如果你那样做,就跟 after_create 这种回调链没有区别了

Reply to jicheng1014

如果要采用完整的 ob 模式,wisper 是很好的选择,不过我只是想用最简单的方式处理下 model 文件里过多的 callback 代码。

这种涉及金额的情况,对精确度要求高的。不太敢使用回调,因为用不了事务。我为了确保两个 model 的 price 会同时修改,而不是出现一个修改的情况。我会比较信任事务。 不知道实际使用过程中,如果price更新了,而orderpriceafter_update出现了异常导致了price更新了,而orderprice没更新,你会怎么处理?

Reply to jesktop

ActiveRecord 的 after_update 本身就包裹在一个事务中,示例代码中的update!如果抛出异常,会导致整个事务回滚

我想到一个 gem active_type

You need to Sign in before reply, if you don't have an account, please Sign up first.