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

这里的问题是您已经向控制器添加了至少十行,但它们并不真正属于那里。 另外,如果您想在另一个控制器中使用相同的功能怎么办? 您是否将此转移到关注点? 等等,但这段代码根本不属于控制器。 为什么 Twitter 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。