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 的例子:

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

Service Object 整理和小结相关推荐

  1. Service知识点整理

    Service简单概述 Service(服务)是一个一种可以在后台执行长时间运行操作而没有用户界面的应用组件.服务可由其他应用组件启动(如Activity),服务一旦被启动将在后台一直运行,即使启动服 ...

  2. #实验吧整理#拐弯抹角小结

    题目地址:实验吧:拐弯抹角 知识点 伪静态:参考资料 关于绕过构造: 构造 /indirection/a/-/ /indirection/./ 构造 \ 来代替被过滤的 / 有的系统大小写通用,可以尝 ...

  3. centos 7.x systemd service 配置方法整理

    一.存放路径 /etc/systemd/system 二.service配置整理 2.1 zookeeper.service [Unit] Description=ZooKeeper Service ...

  4. [转]Android中程序与Service交互的方式——交互方式

    本文转自:http://blog.csdn.net/yihongyuelan/article/details/7216188 上一篇文章:Android中程序与Service交互的方式--综述 简述了 ...

  5. Android面试,与Service交互方式

    五种交互方式,分别是:通过广播交互.通过共享文件交互.通过Messenger(信使)交互.通过自定义接口交互.通过AIDL交互.(可能更多) Service与Thread的区别 Thread:Thre ...

  6. Android Service(一) Service初识

    在开发应用中,或多或少都会遇到Service有关知识.今天就来分析Service的使用. 一.概述. 首先看看官网是如何描述的. A Service is an application compone ...

  7. Android横竖屏切换小结

    (老样子,图片啥的详细文档,可以下载后观看 http://files.cnblogs.com/franksunny/635350788930000000.pdf) Android手机或平板都会存在横竖 ...

  8. Android横竖屏切换小结(重建、非重建Activity)

    来自:http://www.cnblogs.com/franksunny/p/3714442.html (老样子,图片啥的详细文档,可以下载后观看 http://files.cnblogs.com/f ...

  9. Android 横竖屏切换小结

    (自己体会:每次横竖屏自动切时都会run Activity的onCreate,即相当后重新进入Activity初始化一样:) 转自:http://www.cnblogs.com/franksunny/ ...

最新文章

  1. python 核心编程 第十三章
  2. 自动机器学习新进展!性能超过人类调参师6个点,AutoGluon 低调开源
  3. WCF4.0新特性体验(3):标准终结点(Standard Endpoints)
  4. Jenkins执行脚本,提示“sudo: no tty present and no askpass program specified”解决方法
  5. 011_Vue自定义指令
  6. SQLServer数据库设置项梳理
  7. 面向.NET开发人员的Dapr——目录
  8. Jupyter Notebook的15个技巧和窍门,可简化您的编码体验
  9. 网格搜索支持向量机运行结束不了
  10. Oracle数据库脚本学习:建用户、删用户、建表、改表、删表
  11. win8.1 64位安装oracle10g客户端心得
  12. 使用数组初始化vector对象
  13. 一个查看Access数据库密码的工具
  14. 线性表的链式存储结构基本操作代码实现
  15. 先电OpenStack创建云主机报错500
  16. 几乎所有食物的英文翻译
  17. UVA437 巴比伦塔 The Tower of Babylon
  18. 公安部中标十大身份证阅读器品牌型号
  19. Redis消息订阅发布
  20. Hadoop HA (四) --------- YARN-HA 配置

热门文章

  1. Vant 库中如何给 List 列表组件的 cell 中左右两边来添加小图标?
  2. 大厂面试官:关于校招,你必须知道的那些事和建议
  3. EdrawMax亿图下载方法(方便可用免费)
  4. Wireless工具移植之openssl-0.9.8za
  5. json expected name at 1 1
  6. 4999小游戏HTML5小游戏,4999小游戏
  7. JavaScript内置对象2
  8. Google上不了?跨国邮件收不到?用企业级国际上网专线快速解决
  9. window管理工具Dism++
  10. 微信官方揭秘高收益骗局:既骗用户还骗运营者