Rails 服務對象:綜合指南

已發表: 2022-03-11

Ruby on Rails 提供了快速構建應用程序原型所需的一切,但是當您的代碼庫開始增長時,您將遇到傳統的 Fat Model、Skinny Controller 口頭禪被打破的場景。 當您的業務邏輯既不能適應模型也不能適應控制器時,服務對象就會出現,讓我們將每個業務操作分離到它自己的 Ruby 對像中。

Rails 服務對象的示例請求週期

在本文中,我將解釋何時需要服務對象; 如何編寫乾淨的服務對象並將它們組合在一起以保持貢獻者的理智; 我對我的服務對象施加的嚴格規則,以將它們直接綁定到我的業務邏輯; 以及如何不將您的服務對像變成您不知道如何處理的所有代碼的垃圾場。

為什麼需要服務對象?

試試這個:當你的應用程序需要發送來自params[:message]的文本時,你會怎麼做?

如果到目前為止您一直在使用 vanilla Rails,那麼您可能已經做過這樣的事情:

 class TweetController < ApplicationController def create send_tweet(params[:message]) end private def send_tweet(tweet) client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(tweet) end end

這裡的問題是您已經向控制器添加了至少十行,但它們並不真正屬於那裡。 另外,如果您想在另一個控制器中使用相同的功能怎麼辦? 您是否將此轉移到關注點? 等等,但這段代碼根本不屬於控制器。 為什麼 Twi​​tter API 不能只提供一個準備好的對象供我調用?

我第一次這樣做時,我覺得我做了一些骯髒的事情。 我以前非常精巧的 Rails 控制器開始變胖,我不知道該怎麼辦。 最終,我用服務對象修復了我的控制器。

在開始閱讀本文之前,讓我們假設:

  • 此應用程序處理 Twitter 帳戶。
  • Rails Way 的意思是“傳統的 Ruby on Rails 做事方式”,而這本書並不存在。
  • 我是 Rails 專家……我每天都被告知我是專家,但我很難相信,所以讓我們假裝我真的是專家。

什麼是服務對象?

服務對像是普通的舊 Ruby 對象 (PORO),旨在在您的域邏輯中執行一個單獨的操作並且做得很好。 考慮上面的例子:我們的方法已經有了做一件事的邏輯,那就是創建一條推文。 如果這個邏輯被封裝在一個我們可以實例化和調用方法的 Ruby 類中呢? 就像是:

 tweet_creator = TweetCreator.new(params[:message]) tweet_creator.send_tweet # Later on in the article, we'll add syntactic sugar and shorten the above to: TweetCreator.call(params[:message])

差不多就是這樣; 我們的TweetCreator服務對象,一旦創建,就可以從任何地方調用,而且它會很好地完成這件事。

創建服務對象

首先讓我們在名為app/services的新文件夾中創建一個新的TweetCreator

 $ mkdir app/services && touch app/services/tweet_creator.rb

讓我們將所有邏輯轉儲到一個新的 Ruby 類中:

 # app/services/tweet_creator.rb class TweetCreator def initialize(message) @message = message end def send_tweet client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

然後你可以在你的應用程序的任何地方調用TweetCreator.new(params[:message]).send_tweet ,它就會工作。 Rails 會神奇地加載這個對象,因為它會自動加載app/下的所有內容。 通過運行驗證這一點:

 $ rails c Running via Spring preloader in process 12417 Loading development environment (Rails 5.1.5) > puts ActiveSupport::Dependencies.autoload_paths ... /Users/gilani/Sandbox/nazdeeq/app/services

想了解更多關於autoload的工作原理嗎? 閱讀自動加載和重新加載常量指南。

添加語法糖以減少 Rails 服務對象

看,這在理論上感覺很棒,但TweetCreator.new(params[:message]).send_tweet只是拗口。 冗長的單詞太冗長了……很像 HTML(ba-dum tiss! )。 不過,說真的,當 HAML 出現時,為什麼人們還要使用 HTML? 甚至是苗條。 我想那是另一篇文章了。 回到手頭的任務:

TweetCreator是一個不錯的短類名,但是實例化對象和調用方法的額外麻煩實在是太長了! 如果在 Ruby 中有優先調用某些東西並讓它使用給定參數立即執行的優先級……哦等等,有! 這是Proc#call

Proccall調用塊,使用接近方法調用語義的東西將塊的參數設置為 params 中的值。 它返回塊中評估的最後一個表達式的值。

 aproc = Proc.new {|scalar, values| values.map {|value| valuescalar } } aproc.call(9, 1, 2, 3) #=> [9, 18, 27] aproc[9, 1, 2, 3] #=> [9, 18, 27] aproc.(9, 1, 2, 3) #=> [9, 18, 27] aproc.yield(9, 1, 2, 3) #=> [9, 18, 27]

文檔

如果這讓你感到困惑,讓我解釋一下。 可以call proc以使用給定的參數執行自身。 這意味著,如果TweetCreator是一個proc ,我們可以使用TweetCreator.call(message)調用它,結果將等同於TweetCreator.new(params[:message]).call ,這看起來與我們笨拙的舊TweetCreator.new(params[:message]).send_tweet非常相似TweetCreator.new(params[:message]).send_tweet

所以讓我們的服務對象表現得更像一個proc

首先,因為我們可能希望在所有服務對像中重用這種行為,所以讓​​我們藉鑑 Rails 方式並創建一個名為ApplicationService的類:

 # app/services/application_service.rb class ApplicationService def self.call(*args, &block) new(*args, &block).call end end

你看到我在那裡做了什麼嗎? 我添加了一個名為call的類方法,它使用傳遞給它的參數或塊創建類的新實例,並在實例上調用call 。 正是我們想要的! 最後要做的是將TweetCreator類中的方法重命名為call ,並讓該類繼承自ApplicationService

 # app/services/tweet_creator.rb class TweetCreator < ApplicationService attr_reader :message def initialize(message) @message = message end def call client = Twitter::REST::Client.new do |config| config.consumer_key = ENV['TWITTER_CONSUMER_KEY'] config.consumer_secret = ENV['TWITTER_CONSUMER_SECRET'] config.access_token = ENV['TWITTER_ACCESS_TOKEN'] config.access_token_secret = ENV['TWITTER_ACCESS_SECRET'] end client.update(@message) end end

最後,讓我們通過在控制器中調用我們的服務對象來結束它:

 class TweetController < ApplicationController def create TweetCreator.call(params[:message]) end end

對相似的服務對象進行分組以保持理智

上面的示例只有一個服務對象,但在現實世界中,事情會變得更加複雜。 例如,如果您有數百個服務,其中一半是相關的業務操作,例如,擁有一個關注另一個 Twitter 帳戶的Follower服務怎麼辦? 老實說,如果一個文件夾包含 200 個外觀獨特的文件,我會發瘋的,幸好我們可以復制 Rails 方式的另一種模式——我的意思是,用作靈感:命名空間。

假設我們的任務是創建一個遵循其他 Twitter 配置文件的服務對象。

讓我們看看我們之前的服務對象的名稱: TweetCreator 。 這聽起來像是一個人,或者至少是一個組織中的角色。 創建推文的人。 我喜歡將我的服務對象命名為:組織中的角色。 按照這個約定,我將調用我的新對象: ProfileFollower

現在,由於我是這個應用程序的至高無上的霸主,我將在我的服務層次結構中創建一個管理職位,並將這兩個服務的責任委託給該職位。 我將把這個新的管理職位稱為TwitterManager

由於這個管理器除了管理什麼都不做,讓我們把它做成一個模塊並將我們的服務對象嵌套在這個模塊下。 我們的文件夾結構現在看起來像:

 services ├── application_service.rb └── twitter_manager ├── profile_follower.rb └── tweet_creator.rb

而我們的服務對象:

 # services/twitter_manager/tweet_creator.rb module TwitterManager class TweetCreator < ApplicationService ... end end
 # services/twitter_manager/profile_follower.rb module TwitterManager class ProfileFollower < ApplicationService ... end end

我們的調用現在將變為TwitterManager::TweetCreator.call(arg)TwitterManager::ProfileManager.call(arg)

處理數據庫操作的服務對象

上面的示例進行了 API 調用,但是當所有調用都針對您的數據庫而不是 API 時,也可以使用服務對象。 如果某些業務操作需要包含在事務中的多個數據庫更新,這將特別有用。 例如,此示例代碼將使用服務來記錄發生的貨幣兌換。

 module MoneyManager # exchange currency from one amount to another class CurrencyExchanger < ApplicationService ... def call ActiveRecord::Base.transaction do # transfer the original currency to the exchange's account outgoing_tx = CurrencyTransferrer.call( from: the_user_account, to: the_exchange_account, amount: the_amount, currency: original_currency ) # get the exchange rate rate = ExchangeRateGetter.call( from: original_currency, to: new_currency ) # transfer the new currency back to the user's account incoming_tx = CurrencyTransferrer.call( from: the_exchange_account, to: the_user_account, amount: the_amount * rate, currency: new_currency ) # record the exchange happening ExchangeRecorder.call( outgoing_tx: outgoing_tx, incoming_tx: incoming_tx ) end end end # record the transfer of money from one account to another in money_accounts class CurrencyTransferrer < ApplicationService ... end # record an exchange event in the money_exchanges table class ExchangeRecorder < ApplicationService ... end # get the exchange rate from an API class ExchangeRateGetter < ApplicationService ... end end

我從我的服務對象返回什麼?

我們已經討論瞭如何call我們的服務對象,但是對象應該返回什麼? 有三種方法可以解決這個問題:

  • 返回truefalse
  • 返回一個值
  • 返回一個枚舉

返回truefalse

這很簡單:如果一個動作按預期工作,則返回true ; 否則,返回false

 def call ... return true if client.update(@message) false end

返回一個值

如果您的服務對像從某處獲取數據,您可能希望返回該值:

 def call ... return false unless exchange_rate exchange_rate end

用枚舉響應

如果您的服務對像有點複雜,並且您想要處理不同的場景,您可以添加枚舉來控制服務的流程:

 class ExchangeRecorder < ApplicationService RETURNS = [ SUCCESS = :success, FAILURE = :failure, PARTIAL_SUCCESS = :partial_success ] def call foo = do_something return SUCCESS if foo.success? return FAILURE if foo.failure? PARTIAL_SUCCESS end private def do_something end end

然後在您的應用程序中,您可以使用:

 case ExchangeRecorder.call when ExchangeRecorder::SUCCESS foo when ExchangeRecorder::FAILURE bar when ExchangeRecorder::PARTIAL_SUCCESS baz end

我不應該將服務對象放在lib/services而不是app/services中嗎?

這是主觀的。 人們對將服務對象放置在何處有不同的看法。 有些人將它們放在lib/services中,而有些人則創建app/services 。 我屬於後者。 Rails 的入門指南將lib/文件夾描述為放置“應用程序的擴展模塊”的地方。

在我的拙見中,“擴展模塊”是指不封裝核心域邏輯並且通常可以跨項目使用的模塊。 用隨機堆棧溢出答案的明智的話來說,將“可能成為自己的寶石”的代碼放在那裡。

服務對像是個好主意嗎?

這取決於您的用例。 看——你現在正在閱讀這篇文章的事實表明你正在嘗試編寫不完全屬於模型或控制器的代碼。 我最近閱讀了這篇關於服務對像如何成為反模式的文章。 作者有他的觀點,但我很不同意。

僅僅因為其他人過度使用服務對象並不意味著它們天生就是壞的。 在我的創業公司 Nazdeeq 中,我們使用服務對像以及非 ActiveRecord 模型。 但對我來說,去向之間的區別一直很明顯:我將所有業務操作保留在服務對像中,同時將不需要持久性的資源保留在非 ActiveRecord 模型中。 歸根結底,由您決定哪種模式對您有利。

但是,我認為服務對像一般是個好主意嗎? 絕對地! 它們使我的代碼井井有條,而讓我對 PORO 的使用充滿信心的是 Ruby 喜歡對象。 不,說真的,Ruby喜歡對象。 這太瘋狂了,完全瘋了,但我喜歡它! 一個例子:

 > 5.is_a? Object # => true > 5.class # => Integer > class Integer ?> def woot ?> 'woot woot' ?> end ?> end # => :woot > 5.woot # => "woot woot"

看? 5字面上是一個對象。

在許多語言中,數字和其他原始類型不是對象。 Ruby 通過為所有類型提供方法和實例變量來遵循 Smalltalk 語言的影響。 這簡化了 Ruby 的使用,因為應用於對象的規則適用於所有 Ruby。 Ruby-lang.org

什麼時候不應該使用服務對象?

這個很容易。 我有這些規則:

  1. 您的代碼是否處理路由、參數或執行其他控制器相關的事情?
    如果是這樣,請不要使用服務對象——您的代碼屬於控制器。
  2. 您是否嘗試在不同的控制器中共享您的代碼?
    在這種情況下,不要使用服務對象——使用關注點。
  3. 您的代碼是否像不需要持久性的模型?
    如果是這樣,請不要使用服務對象。 請改用非 ActiveRecord 模型。
  4. 您的代碼是特定的業務操作嗎? (例如,“倒垃圾”、“使用此文本生成 PDF”或“使用這些複雜的規則計算關稅”)
    在這種情況下,請使用服務對象。 該代碼可能在邏輯上不適合您的控制器或模型。

當然,這些是我的規則,因此歡迎您根據自己的用例調整它們。 這些對我來說效果很好,但你的里程可能會有所不同。

編寫好的服務對象的規則

我有四個創建服務對象的規則。 這些不是一成不變的,如果你真的想打破它們,你可以,但我可能會要求你在代碼審查中改變它,除非你的推理是合理的。

規則 1:每個服務對像只有一個公共方法

服務對像是單個業務操作。 如果您願意,可以更改公共方法的名稱。 我更喜歡使用call ,但 Gitlab CE 的代碼庫將其稱為execute ,其他人可能會使用perform 。 使用任何你想要的東西——你可以稱它為nermin ,我只關心它。 只是不要為單個服務對象創建兩個公共方法。 如果需要,將其分成兩個對象。

規則 2:將服務對象命名為公司中的啞角色

服務對像是單個業務操作。 想像一下,如果你在公司雇了一個人來做那一項工作,你會怎麼稱呼他們? 如果他們的工作是創建推文,請稱他們為TweetCreator 。 如果他們的工作是閱讀特定的推文,則稱他們為TweetReader

規則 3:不要創建通用對象來執行多個操作

服務對像是單個業務操作。 我將功能分為兩部分: TweetReaderProfileFollower 。 我沒有做的是創建一個名為TwitterHandler的通用對象並將所有 API 功能轉儲到其中。 請不要這樣做。 這違背了“業務行動”的心態,並使服務對像看起來像 Twitter 仙女。 如果您想在業務對象之間共享代碼,只需創建一個BaseTwitterManager像或模塊並將其混合到您的服務對像中。

規則 4:處理服務對象內部的異常

無數次:服務對像是單個業務操作。 我不能這麼說。 如果你有一個閱讀推文的人,他們要么給你推文,要么說,“這條推文不存在。” 同樣,不要讓您的服務對象恐慌,跳到控制器的桌子上,告訴它停止所有工作,因為“錯誤!” 只需返回false並讓控制器從那裡繼續前進。

學分和後續步驟

如果沒有 Toptal 令人驚嘆的 Ruby 開發人員社區,就不可能寫出這篇文章。 如果我遇到問題,社區是我見過的最有才華的工程師群體。

如果您正在使用服務對象,您可能會發現自己想知道如何在測試時強制某些答案。 我建議閱讀這篇文章,了解如何在 Rspec 中創建模擬服務對象,它總是會返回你想要的結果,而不會實際命中服務對象!

如果您想了解更多關於 Ruby 技巧的信息,我推薦 Toptaler Mate Solymosi 的《創建 Ruby DSL:高級元編程指南》。 他分解了routes.rb文件與 Ruby 的不同之處,並幫助您構建自己的 DSL。