Service Object 整理和小结

TL;DR

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

Why Service Object (Again)?

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

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

我我的的理解是,Service Object 没有一个固定的形态,由于它彻底就是业务逻辑的封装。web

那讨论还有意义吗?有。由于咱们须要它,须要更有效率地使用和讨论它。数据库

Convention over Configuration

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

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

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

这篇文章推荐了一些 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 的例子:

ruby# 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

如何使用它:

rubyclass 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 。我比较喜欢用组合的方式,你也能够用继承来实现。

rubymodule 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 了。

rubyclass 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

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

怎么用呢:

rubydef 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 写,可能就是这个样子:

rubyclass 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 的讨论

相关文章
相关标签/搜索