Rails服务对象:综合指南

本文概述

Ruby on Rails附带了快速构建应用程序原型所需的一切, 但是当你的代码库开始增长时, 你将遇到传统的Fat模型, 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对象(PO​​RO), 旨在在你的域逻辑中执行一项单独的操作, 并做得很好。考虑上面的示例:我们的方法已经具有完成一件事情的逻辑, 那就是创建一条推文。如果此逻辑封装在我们可以实例化并调用方法的单个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

想更多地了解自动加载的工作原理?阅读自动加载和重新加载常量指南。

添加语法糖以减少Rails服务对象的吮吸

看, 从理论上来说, 这感觉不错, 但是TweetCreator.new(params [:message])。send_tweet只是一口。多余的单词太冗长了, 就像HTML(ba-dum tiss!)。严肃地说, 为什么人们在HAML出现时仍会使用HTML?甚至苗条。我想这是另一篇文章。返回手头的任务:

TweetCreator是一个很好的短类名, 但是实例化对象和调用方法的额外麻烦太长了!如果在Ruby中只有优先权可以调用某些东西, 并让它立即使用给定的参数自动执行……哦, 等等!是Proc#call。

Proccall调用该块, 并使用类似于方法调用语义的方式将块的参数设置为params中的值。它返回在块中求值的最后一个表达式的值。 aproc = Proc.new {|标量, 值| 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]文档

如果这使你感到困惑, 请让我解释一下。可以调用proc以使用给定的参数执行自身。这意味着, 如果TweetCreator是一个proc, 我们可以使用TweetCreator.call(message)对其进行调用, 其结果将与TweetCreator.new(params [:message])。call等效, 该外观与笨拙的旧TweetCreator非常相似.new(params [:message])。send_tweet。

因此, 让我们的服务对象的行为更像proc!

首先, 因为我们可能想在所有服务对象中重用此行为, 所以让我们从Rails Way借用并创建一个名为ApplicationService的类:

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

你看到我在那里做什么了吗?我添加了一个名为call的类方法, 该方法使用传递给它的参数或代码块创建该类的新实例, 并在该实例上调用call。正是我们想要的!最后要做的是从TweetCreator类中重命名要调用的方法, 并使该类继承自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 Way中还有一种我们可以复制的模式-我的意思是, 灵感来自于:命名空间。

假设我们的任务是创建一个遵循其他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

我从服务对象返回什么?

我们已经讨论了如何调用服务对象, 但是该对象应该返回什么?有三种方法可以解决此问题:

  • 返回true或false
  • 返回值
  • 返回一个枚举

返回true或false

这很简单:如果一个动作按预期工作, 则返回true;否则, 返回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 /文件夹描述为放置”你的应用程序的扩展模块”的位置。

以我的拙见, “扩展模块”是指未封装核心域逻辑的模块, 通常可在整个项目中使用。用一个随机的Stack Overflow答案的明智的话来说, 在其中放置”可能成为自己的宝石”的代码。

服务对象是一个好主意吗?

这取决于你的用例。看, 你现在正在阅读本文的事实表明你正在尝试编写不完全属于模型或控制器的代码。我最近阅读了有关服务对象如何是反模式的文章。作者有他的意见, 但我谨不同意。

仅仅因为其他人过度使用了服务对象并不意味着它们本质上就很糟糕。在我的初创公司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。如果他们的工作是阅读特定的tweet, 则将它们称为TweetReader。

规则3:不要创建通用对象来执行多项操作

服务对象是单个业务操作。我将功能分为两部分:TweetReader和ProfileFollower。我没有做的是创建一个名为TwitterHandler的通用对象, 并将所有API功能都转储到那里。请不要这样做。这违背了”商业行为”的思维方式, 并使服务对象看起来像Twitter Fairy。如果要在业务对象之间共享代码, 只需创建BaseTwitterManager对象或模块并将其混合到服务对象中即可。

规则4:处理服务对象内的异常

在无数次中:服务对象是单个业务操作。我不能这么说。如果你有可以阅读推文的人, 他们要么给你发推文, 要么说:”此推文不存在。”同样, 也不要让服务对象惊慌, 跳到控制器的桌子上并告诉它停止所有工作, 因为”错误!”只需返回false, 然后让控制器从那里继续前进即可。

本文总结和后续步骤

没有srcmini令人惊叹的Ruby开发人员社区, 就不可能有这篇文章。如果我遇到问题, 社区是我见过的最有才华的工程师团队。

如果你使用的是服务对象, 则可能会想知道如何在测试时强制执行某些答案。我建议阅读这篇文章, 了解如何在Rspec中创建模拟服务对象, 该对象将始终返回所需的结果, 而无需实际点击服务对象!

如果你想了解有关Ruby技巧的更多信息, 我建议由srcminierMátéSolymosi同事创建Ruby DSL:高级元编程指南。他分析了route.rb文件看起来不像Ruby的感觉, 并帮助你构建自己的DSL。

微信公众号
手机浏览(小程序)
0
分享到:
没有账号? 忘记密码?