65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Rails 服务对象保持代码整洁

starIconstarIconstarIconstarIconstarIcon

5.00/5 (6投票s)

2022 年 8 月 29 日

CPOL

8分钟阅读

viewsIcon

15746

什么是服务对象, 以及如何使用它们来使您的应用程序更整洁并保持可维护性。

引言

假设您有一个处理出租车行程的项目,我们将查看一个更新行程记录的特定控制器操作。它不仅应根据用户输入的参数(例如,出发地址、目的地地址、乘客人数等)更新行程,还应基于这些参数计算某些字段并将其保存到数据库。因此,我们有一个像这样的控制器操作:

# app/controllers/trips_controller.rb
class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])

    if update_trip(trip_params)
      redirect_to @trip
    else
      render :edit
    end
  end

  private

  def update_trip(trip_params)
    distance_and_duration = calculate_trip_distance_and_duration
                                                   (trip_params[:start_address],
                                                    trip_params[:destination_address])
    @trip.update(trip_params.merge(distance_and_duration))
  end

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end

这里的问题是,您的控制器中添加了至少十行代码,但这些代码实际上不属于控制器。此外,如果您想在另一个控制器中更新行程,例如,通过从 CSV 文件导入行程,您将不得不重复自己并重写这段代码。或者,您可以创建一个服务对象,即 TripUpdateService,并在您需要更新行程的任何地方使用它。

什么是 Service Objects?

基本上,服务对象是一个纯粹的 Ruby 对象(“PORO”),一个 Ruby 类,它返回可预测的响应,并且被设计用于执行单个操作。因此,它封装了一段业务逻辑。

服务对象的职责是封装功能,执行一项服务,并提供单一故障点。使用服务对象还可以防止开发人员在应用程序的不同部分需要使用相同的代码时一遍又一遍地编写相同的代码。

所有服务对象都应具备三点:

  • 一个初始化方法
  • 一个单一的 public 方法
  • 执行后返回一个可预测的响应

让我们通过调用服务对象来进行行程更新来替换我们的控制器逻辑

# app/controllers/trips_controller.rb
class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])

    if TripUpdateService.new(@trip, trip_params).update_trip
      redirect_to @trip
    else
      render :edit
    end
  end
end

看起来干净多了,对吧?现在让我们看看如何实现一个服务对象。

实现 Service Object

在 Rails 应用程序中,有两个常用文件夹用于存储服务对象:lib/servicesapp/services。基本上,您可以选择任何一个,但我们将在本文中使用 app/services

所以我们将在 app/services/trip_update_service.rb 中添加一个新的 Ruby 类(我们的服务对象)。

# app/services/trip_update_service.rb
class TripUpdateService
  def initialize(trip, params)
    @trip = trip
    @params = params
  end

  def update_trip
    distance_and_duration = calculate_trip_distance_and_duration
                                                   (@params[:start_address],
                                                    @params[:destination_address])
    @trip.update(@params.merge(distance_and_duration))
  end

  private

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end

好了,服务对象已添加,现在您可以在应用程序的任何地方调用 TripUpdateService.new(trip, params).update_trip,它就可以工作了。Rails 会自动加载此对象,因为它会自动加载 app/ 文件夹下的所有内容。

这已经看起来很干净了,但实际上我们可以做得更好。我们可以让服务对象在被调用时执行自身,这样我们可以使调用更短。如果我们想为其他服务对象重用此行为,我们可以添加一个名为 BaseServiceApplicationService 的新类,并让我们的 TripUpdateService 继承它。

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

因此,这个名为 call 的类方法会创建一个服务对象的新实例,并带有传递给它的参数或块,然后在该实例上调用 call 方法。然后,我们需要让我们的服务继承自 BaseService 并实现 call 方法。

# app/services/trip_update_service.rb
class TripUpdateService < BaseService
  def initialize(trip, params)
    @trip = trip
    @params = params
  end

  def call
    distance_and_duration = calculate_trip_distance_and_duration
                                                   (@params[:start_address],
                                                    @params[:destination_address])
    @trip.update(@params.merge(distance_and_duration))
  end

  private

  def calculate_trip_distance_and_duration(start_address, destination_address)
    distance = Google::Maps.distance(start_address, destination_address)
    duration = Google::Maps.duration(start_address, destination_address)
    { distance: distance, duration: duration }
  end
end

然后,让我们更新我们的控制器操作以正确调用服务对象。

# app/controllers/trips_controller.rb
class TripsController < ApplicationController
  def update
    @trip = Trip.find(params[:id])
    if TripUpdateService.call(@trip, trip_params)
      redirect_to @trip
    else
      render :edit
    end
  end
end

服务对象应放在哪里?

正如我们之前讨论过的,用于存储服务对象的两个基本文件夹是 lib/servicesapp/services,您可以选择任何一个。

另一个存储服务对象的良好实践是将其存储在不同的命名空间下,例如,您可以拥有 TripUpdateServiceTripCreateServiceTripDestroyServiceSendTripService 等等。但所有这些的共同点是它们都与 Trips 相关。所以我们可以将它们放在 app/services/trips 文件夹下,换句话说,放在 trips 命名空间下。

# app/services/trips/trip_update_service.rb
module Trips
  class TripUpdateService < BaseService
    ...
  end
end
# app/services/trips/send_trip_service.rb
module Trips
  class SendTripService < BaseService
    ...
  end
end

调用这些服务时,不要忘记使用新的命名空间,即 Trips::TripUpdateService.call(trip, params)Trips::SendTripService.call(trip, params)

将代码包装在事务块中

如果您的服务对象将执行多个对象的多个更新,最好将其包装在事务块中。在这种情况下,如果服务对象的任何方法失败,Rails 将回滚事务(即所有已执行的数据库更改)。这是一个好习惯,因为它可以在发生故障时保持数据库的一致性。

# archive route with all of its trips
class RouteArchiver < BaseService
  ...
  def call
    ActiveRecord::Base.transaction do
      # first archive the route
      @route.archive!

      # then archive route trips
      trips = TripsArchiver.call(route: @route)

      # create a change log record
      CreatChangelogService.call(
        change: :archive,
        object: @route,
        associated: trips
      )

      # return response
      { success: true, message: "Route archived successfully" }
    end
  end
end

这是一个在单个事务中更新多个记录的简单示例。如果任何更新因异常而失败(例如,路线无法存档,日志创建失败),事务将回滚,数据库将处于一致状态。

将数据传递给 Service Objects 并返回响应

基本上,您可以将几乎任何东西传递给您的服务对象,具体取决于它们执行的操作:ActiveRecord 对象、哈希、数组、字符串、整数等。但您应该始终将最少量的数据传递给您的服务对象。例如,如果您想更新一个行程,您应该传递行程对象和参数哈希,但不应该传递整个 params 哈希,因为它会包含大量不必要的数据。所以您应该只传递您需要的数据,即 TripUpdateService.call(trip, trip_params)

服务对象可以执行复杂的操作。它们可用于修改数据库中的记录、发送电子邮件、执行计算或调用第三方 API。因此,在这些操作过程中很有可能会出现问题。这就是为什么从服务对象返回响应是一个好习惯。您可以返回一个布尔值,或者一个带有布尔值和一些附加数据的哈希。例如,如果您想更新一个行程,您可以返回一个布尔值来指示行程是否更新成功,您也可以返回行程对象本身,以便在控制器操作中使用它。

您应该记住的是,您的服务对象的响应应该是可预测的。无论如何,它应该始终返回相同的响应。所以,如果您返回一个布尔值,它应该总是返回一个布尔值;如果您返回一个哈希,它应该总是返回一个具有相同键的哈希。这将使您的服务对象更具可预测性,并且更易于测试。

使用 Service Objects 有哪些好处?

服务对象是解耦应用程序逻辑与控制器的一种绝佳方式。您可以使用它们来分离关注点,并在应用程序的不同部分重用它们。使用此模式,您可以获得多重好处:

  • 干净的控制器。控制器不应处理业务逻辑。它只应负责处理请求,并将请求参数、会话和 cookie 转换为传递给服务对象的参数以执行操作。然后根据服务响应执行重定向或渲染。
  • 更轻松的测试。将业务逻辑分离到服务对象也允许您独立测试服务对象和控制器。
  • 可重用的服务对象。服务对象可以从应用程序控制器、后台作业、其他服务对象等调用。每当您需要执行类似操作时,都可以调用服务对象,它将为您完成工作。
  • 关注点分离。Rails 控制器只看到服务,并使用它们与域对象进行交互。这种耦合的减少使得扩展更容易,尤其是在您想从单体迁移到微服务时。您的服务可以轻松提取并迁移到新的服务,只需极少的修改。

Service Objects 最佳实践

  • 以清晰表明其功能的方式命名 Rails 服务对象。服务对象的名称必须表明其功能。以我们的行程示例为例,我们可以将服务对象命名为:TripUpdateServiceTripUpdaterModifyTrip 等。
  • 服务对象应只有一个公共方法。其他方法必须是 private,只能在特定服务对象内部访问。您可以随意调用这个单一的 public 方法,只要保持一致并在所有服务对象中使用相同的命名即可。
  • 将服务对象分组到公共命名空间下。如果您有很多服务对象,您可以将它们分组到公共命名空间下。例如,如果您有很多与行程相关的服务对象,您可以将它们分组到 Trips 命名空间下,即 Trips::TripUpdateServiceTrips::TripDestroyServiceTrips::SendTripService 等。
  • 不要忘记使用语法糖调用您的服务对象。在您的 BaseServiceApplicationService 中使用 proc 语法,并让其他服务继承它。然后,您可以使用服务对象类名上的 .call 来执行操作,即 TripUpdateService.call(trip, params)
  • 不要忘记捕获异常。当服务对象因异常而失败时,应正确捕获并处理这些异常。它们不应传播到调用堆栈。如果异常无法在 rescue 块中正确处理,您应该抛出特定于该服务对象的自定义异常。
  • 单一职责。尽量为每个服务对象保持单一职责。如果您有一个服务对象做了太多事情,您可以将其拆分为多个服务对象。

结论

服务对象是解耦应用程序逻辑与控制器的一种绝佳方式。它们可用于分离关注点,并在应用程序的不同部分重用它们。这种模式可以使您的应用程序更易于测试和维护,因为您添加的功能越来越多。它还使您的应用程序更易于扩展,并更容易从单体迁移到微服务。顺便说一句,Ruby on Rails 仅用于此示例,您可以在其他框架中使用相同的模式。如果您以前没有使用过服务对象,您应该绝对尝试一下。

历史

  • 2022 年 8 月 29 日:初始版本
© . All rights reserved.