Rails Service Object 整理和小结

darkbaby123 · January 22, 2015 · Last by StephenZzz replied at October 19, 2020 · 10453 hits
Topic has been selected as the excellent topic by the admin.

TL;DR

这篇文章整理了 Service Object 的一套 Convention,用 PORO 结合 Rails 的功能完成了一个例子,并介绍了一些其他思路。

Why Service Object (Again)?

Service Object 已经不是一个新鲜话题了。从 7 Patterns to Refactor Fat ActiveRecord Models 开始就有不少人尝试照着这些 pattern 从 Rails 项目抽象出各种 object 进行解耦。这些 pattern 也催生了不少 gem,比如关注 policy 的 Pundit ,关注 form 的 Reform,关注 presenter 的……太多不举例了……

但 Service Object 却很少看到有相关的 gem,DHH 还跟别人讨论了大半天 service 的话题,看起来每个人对于 Service Object 的理解都有些差别。这是为什么?

我个人的理解是,Service Object 没有一个固定的形态,因为它完全就是业务逻辑的封装。

那讨论还有意义吗?有。因为我们需要它,需要更有效率地使用和讨论它。

Convention over Configuration

说到效率,就不得不提关于 Rails 的核心哲学 Convention over Configuration。如果你的理解仅仅是用 Convention 省去了配置,那并不是它的全部含义。

Convention 的另一层意义在于,它就是一个最佳实践的表现形式,Rails 本质上是一系列 web 开发中最佳实践的集合体。通过 Convention,Rails 开发者不仅可以避免为一些琐碎的事情费神,从而去处理真正需要关心的事情。更重要的是,遵循 Convention 的 Rails 项目都长得差不多,这使得 Rails 开发者的经验能够跨项目地重用。而且开发者互相交流起来天生就在一个频道上。We are on the same page !

但真正的项目千差万别,Rails 为我们做的毕竟有限,在没有 Convention 覆盖到的地方,开发者的理解就各有千秋了。Service Object 就是其中最典型的例子。有自己想法的人自然可以不拘泥于形式,但也有不少人在疑惑“怎么才算 Service Object”和“如何更好地实现 Service Object” ?

这篇文章推荐了一些 Service Object 的 Convention,来自 这篇文章这篇文章

Service Object & Convention

简单的说,Service Object 是用对象来封装一段操作。通常情况下我们用它封装业务逻辑。关于什么情况下该使用 Service Object,7 patterns 里的话我觉得已经总结得很好了。

  1. 操作逻辑很复杂。
  2. 操作涉及到多个 model。
  3. 操作涉及到调用外部服务。
  4. 操作不是 model 该关注的逻辑(比如定时清理过期数据)。
  5. 操作涉及到一系列不同的具体实现(比如用 token 认证或者 password 认证),策略模式就是干这个的。

因为和业务逻辑比较接近,Service Object 通常用在 Controller 中,但也可以单独使用(比如在 job,console 或者其他 Service Object 中嵌套使用)。

Service Object 的一些简单的约定:

  1. 一个 Service Object 只做一件事。
  2. 每个 Service Object 一个文件,统一放在 app/services 目录下。
  3. 命名采用动作,比如 SignEstimate,而不是 EstimateSigner。
  4. instance 级别实现两个接口,initialize 负责传入所有依赖,call 负责调用。
  5. class 级别实现一个接口 call ,用于简单的实例化 Service Object 然后调用 call。
  6. call 的返回值默认为 true/false ,也可以有更复杂的形式,比如 StatusObject。

以上这些只是约定,不是必须遵循的规范。比如你可以叫 SignEstimateService,把 call 改成 invokeexecuteperform 或者其他你喜欢的。但记住 如果没有特殊的理由,请让你的所有 Service Object 保持一致的约定

一个 Service Object 的例子:

# app/services/sign_estimate.rb
class SignEstimate
  def self.call(*args)
    new(*args).call
  end

  def initialize(estimate, params)
    @estimate = estimate,
    @params = params
  end

  def call
    # Do whatever you want
    # Return true/false
  end
end

如何使用它:

class EstimatesController
  # POST /estimates/:id/sign
  def sign
    @estimate = Estimate.find(params[:id])

    if SignEstimate.call(@estimate, estimate_params)
      # Do something like redirect
    else
      # Display errors
    end
  end
end

With Rails's help

Service Object 就是一个纯粹的 Ruby Object(PORO),但这不代表我们不能复用 Rails 已有的功能。我一直觉得为了开发便利,可以视情况增加 MVC 之外的层,但如果抛弃 Rails 已有的东西就本末倒置了,比如没必要为了建一个 Form Object 而把 Model 层的 validation 全部扔到 Form Object 里面去。

上个例子里的 SignEstimate 是我自己项目中的例子,实际使用时我会需要对 Estimate 这个 Model 做额外的 validation,但我不希望把这些逻辑放到 Model 层去,因为它们只有在 Sign 这个过程中有用。所以我会用到 ActiveModel。

另外,因为约定中每个 Service Object 中都有类方法 call 。我们可以把它单独抽出来变成一个 Concern。我比较喜欢用组合的方式,你也可以用继承来实现。

module Serviceable
  extend ActiveSupport::Concern

  class_methods do
    def call(*args)
      new(*args).call
    end
  end
end

class SignEstimate
  include Serviceable
  include ActiveModel::Model
  include ActionLoggable

  attr_reader :estimate

  delegate :signer_name,
           :sign_via,
           :signer_driver_lic,
           :signer_ssn,
           :errors,
           to: :estimate

  validates :signer_name, presence: true
  validates :sign_via, inclusion: { in: %w[driver_lic ssn] }
  validates :signer_driver_lic, presence: true, if: :sign_via_driver_lic?
  validates :signer_ssn, presence: true, if: :sign_via_ssn?

  def initialize(estimate, params)
    @estimate = estimate,
    @params = params
  end

  def call
    valid? && persist
  end

  private

  def persist
    @estimate.transaction do
      sign_estimate!
      close_sales_lead!
      transform_prospect_to_customer!
      copy_forms!
    end
    create_activity
    write_log('sign_est', resource: @estimate, operator: @estimate.assigned_to)
    true
  rescue ActiveRecord::RecordInvalid
    false
  end

  def sign_via_driver_lic?
    sign_via == 'driver_lic'
  end

  def sign_via_ssn?
    sign_via == 'ssn'
  end
end

有些方法是纯粹的业务逻辑,具体实现就不写了。这里我用了以下 Rails 的功能:

  1. ActiveSupport::Concern 来抽离 Service Object 的公共接口。
  2. ActiveModel::Model 来做校验,你也可以只要 ActiveModel::Validations
  3. delegate 方法来代理需要验证的字段和 errors 接口。这样添加的错误就自动给 @estimate 了。
  4. ActionLoggable 是我自己写的 Concern,用来添加一些操作日志,生成报表用。

统一的约定可以方便抽离接口,PORO 可以方便我添加任何其他东西,不用考虑继承了什么类带来的 side effect。而且易于理解和修改。

Status Object as Return Value

这篇文章 的作者也提到了返回值的约定。一个有意思的概念是,当需要返回的内容比较复杂时(操作失败返回错误信息),可以抽象出一个 Object 去封装返回值,这就是 Status Object。它定义了一个 success? 接口来判断操作是否成功,其他的信息就由各人自己 DIY 了。

class Success
  attr_reader :data

  def initialize(data)
    @data = data
  end
end

class Error
  attr_reader :error

  def initialize(error)
    @error = error
  end
end

你也可以用自己的方法来 one liner

Success = Struct(:data) { def success?; true; end }
Error = Struct(:error) { def success?; false; end }

怎么用呢:

def call
  if valid?
    # Dirty business logic...
    Success.new(@estimate)
  else
    Error.new("customized error message")
  end
end

我目前没有用到 Status Object 的必要,所以没有深入的例子。感兴趣的可以参考作者原文的例子,他在 AuthorizationError 里带了 code 和 message,方便 Controller 做针对性的操作。

Service Object 的构建很灵活,你可以想出最符合自己习惯的用法,形成约定。但记住 不要为了 pattern 而 pattern ,在满足要求的同时,尽量保持简单,重用 Rails 已有的功能,提高效率。

Testing

Service Object 的所有依赖都是在初始化的时候注入的,所以也可以很方便地使用 double 或者 Fake Object 来伪造对象,隔离依赖。

但根据我的实际经验,大部分 Service Object 都要跟 Model 层打交道, 建议这种情况下全部用真实的 Model 对象,不要 Mock/Stub

因为 Service Object 的存在必然会抽走一部分的 Model 逻辑。Model 中也许就只剩下比较简单的 validation, callback 和自定义方法了(比如关联保存 relationship,我不大喜欢 autosave)。这时候 Model 的 Unit Test 实际上是不足以保证数据库层面的功能正确的。如果 Service Object 都 Mock 了,那么保证功能的正确性就要靠 Integration Test 了。测试是为了保证系统稳定性的,为了一些速度降低稳定性不值得

Another Way

刚才的 Service Object 是一种思路,但并不是没有其他的方法去抽离业务逻辑。这里是我在学习过程中看到的一些其他 gem。都可以达到相同的目的。我最终没用只是因为觉得这些 gem 的理念不太符合。不代表它们不好。

ActiveType

ActiveType 的理念是尽量利用 ActiveRecord 的 lifecycle,你可以写一个自己的 Object,但是像 Model 一样把逻辑封装进 validation 和 callback,从而让自定义的 Object 有和 ActiveRecord 一样的接口和使用方式。

这是我在 Growing Rails Applications in Practice 一书里看到的。里面提倡的一点就是把所有接口 CRUD 化,接口统一了之后就容易做更高层次的抽象。这个理念还是值得学习的。如果你没看过这本书,强烈建议看一看。

有人会疑惑为什么不用 ActiveModel 自己造?因为有太多的东西仍然在 ActiveRecord 里面。有些看似简单的需求很难实现,比如 save 之前调用你的 Object 的 validation 和内部的 Model 的 validation。如果你想自己写一个 Object 并沿袭 ActiveRecord 的接口,你需要做不少事情,但最终会发现自己仿造 ActiveRecord 写了一个 Object。可能还有各种问题……

上面的 Service Object 用 ActiveType 写,可能就是这个样子:

class SignEstimate < ActiveType::Record[Estimate]
  validates :signer_name, presence: true
  validates :sign_via, inclusion: { in: %w[driver_lic ssn] }
  validates :signer_driver_lic, :signer_state, presence: true, if: :sign_via_driver_lic?
  validates :signer_ssn, presence: true, if: :sign_via_ssn?

  before_save :set_sign_date
  after_save :close_sales_lead
  after_save :transform_prospect_to_customer
  after_save :copy_forms

  after_commit :create_activity, on: :update
  after_commit :write_log, on: :update

  after_rollback :clear_sign_info
end

这种 Service Object 在 Controller 中就跟 Model 一样用。喜不喜欢这种思路就见仁见智了。

Wisper

Wisper 是一个以 pub/sub 为理念的 gem,主张用 event + callback 的方式解耦。我是在搜索“为什么 Rails observer 被废掉了”的过程中偶然找到这个 gem 的。它同样可以用来解耦业务逻辑。

我个人不喜欢这种方式。因为有 callback 的代码很难被外层 Object 封装,比如官方的 Controller 例子很难抽象成统一的接口,进而使用 respond_with

不管怎么样,我想作为一个 900+ stars 的 gem 它还是很成功的。也许它是 observer 的一个很好的替代品。

Conclusions

Service Object 是 Rails 开发者回归 OO 方式思考的结果之一。它并不违反 Rails way,我们也没必要把任何操作都封装成 Service Object。解决方案通常是跟适用场景息息相关的,No silver bullet。作为 Rails 开发者,充分利用它的优势加上适当地拥抱变化,可以让人走的更远。

References

7 Patterns to Refactor Fat ActiveRecord Models

Gourmet Service Objects

Service objects in Rails will help you design clean and maintainable code. Here's how.

Object Oriented Rails – Writing better controllers

Twitter 上 DHH 关于 Service Object 的讨论

楼主对这个六边形架构有何看法么?https://ruby-china.org/topics/23855

service 对复杂的涉及多个 model 的情况是必须的。

@lgn21st 谢谢!

@chenge 这是个有意思的东西,不过我不了解,待我看过后再做评价。我引用的文章中,有一篇就提到如果 Service Object 和数据库耦合比较紧,可以采用 repository pattern。那是我下一步准备看的东西。估计那又是一个很长的话题了。

好文,👍

赞一个,最近也在研究 DDD,1 楼说的六边形架构是《IDDD》中提出的。

需要使用 service object 时,model 其实主要就是领域模型的角色了。

在 Fowler 的《EAA》也写明了,ActiveRecord 这种模式,适应于简单的业务模型,对于复杂的业务模型,就需要建立领域模型来解决了。

此外,Wisper 主张是“最终一致性”而不是 CallBack 场景下的“实时一致性”,所以在分布式或者需要把业务模型严格切块(DDD 中处理多个聚合)时,就需要用到 Domain Event,也就是 Wisper 来进行聚合间的交流,而不是一概用 Callback 来联动。

写得较乱。

我喜欢 pundit!

Growing Rails Applications in Practice 这本书不错,对更好的组织项目代码提了很多有用的建议

@pishilong 能说下 Wisper 这种思路的应用场景吗? BTW 看了下你的博客,不过貌似有点问题,每次切换页面都提醒我“请设置正确的 data-thread-key 属性” 。

:plus1: :plus1: :plus1:

@darkbaby123 啊哈,服务层设计,欢迎楼主来参与吐槽盖楼: https://ruby-china.org/topics/24742

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