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 的討論

相關文章
相關標籤/搜索