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 里的话我觉得已经总结得很好了。
- 操作逻辑很复杂。
- 操作涉及到多个 model。
- 操作涉及到调用外部服务。
- 操作不是 model 该关注的逻辑(比如定时清理过期数据)。
- 操作涉及到一系列不同的具体实现(比如用 token 认证或者 password 认证),策略模式就是干这个的。
因为和业务逻辑比较接近,Service Object 通常用在 Controller 中,但也可以单独使用(比如在 job , console 或者其他 Service Object 中嵌套使用)。
Service Object 的一些简单的约定:
- 一个 Service Object 只做一件事。
- 每个 Service Object 一个文件,统一放在 app/services 目录下。
- 命名采用动作,比如 SignEstimate ,而不是 EstimateSigner 。
- instance 级别实现两个接口,
initialize
负责传入所有依赖,call
负责调用。 - class 级别实现一个接口
call
,用于简单的实例化 Service Object 然后调用 call 。 -
call
的返回值默认为true/false
,也可以有更复杂的形式,比如 StatusObject 。
以上这些只是约定,不是必须遵循的规范。比如你可以叫 SignEstimateService
,把 call
改成 invoke
,execute
,perform
或者其他你喜欢的。但记住 如果没有特殊的理由,请让你的所有 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
如何使用它:
ruby
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 。我比较喜欢用组合的方式,你也可以用继承来实现。
ruby
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 的功能:
-
ActiveSupport::Concern
来抽离 Service Object 的公共接口。 -
ActiveModel::Model
来做校验,你也可以只要ActiveModel::Validations
。 -
delegate
方法来代理需要验证的字段和errors
接口。这样添加的错误就自动给@estimate
了。 -
ActionLoggable
是我自己写的 Concern ,用来添加一些操作日志,生成报表用。
统一的约定可以方便抽离接口,PORO 可以方便我添加任何其他东西,不用考虑继承了什么类带来的 side effect 。而且易于理解和修改。
Status Object as Return Value
这篇文章 的作者也提到了返回值的约定。一个有意思的概念是,当需要返回的内容比较复杂时(操作失败返回错误信息),可以抽象出一个 Object 去封装返回值,这就是 Status Object 。它定义了一个 success?
接口来判断操作是否成功,其他的信息就由各人自己 DIY 了。
ruby
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
ruby
Success = Struct(:data) { def success?; true; end } Error = Struct(:error) { def success?; false; end }
怎么用呢:
ruby
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 写,可能就是这个样子:
ruby
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 的讨论