Ruby on Rails控制器模式和反模式的详细指南

100 阅读5分钟

欢迎回到Ruby on Rails模式和反模式系列的第四篇文章。

在此之前,我们讨论了一般的模式和反模式,以及与Rails模型和视图有关的模式。在这篇文章中,我们将分析MVC(模型-视图-控制器)设计模式的最后一部分--控制器。 让我们深入了解与Rails控制器相关的模式和反模式。

在前线

由于Ruby on Rails是一个Web框架,HTTP请求是它的一个重要部分。 各种各样的客户端通过请求到达Rails的后端,这就是控制器的作用。控制器处于接收和处理请求的第一线。这使得它们成为Ruby on Rails框架的一个基本部分。当然,在控制器之前还有一些代码,但控制器代码是我们大多数人可以控制的。

一旦你在config/routes.rb ,定义了路由,你就可以在设定的路由上打到服务器上,相应的控制器就会处理剩下的事情了。阅读前面的句子可能会给人一种印象,认为一切都那么简单。但是,很多时候,很多重量都落在了控制器的肩上。 有认证和授权的问题,然后是如何获取需要的数据,以及在哪里和如何执行业务逻辑的问题。

所有这些在控制器中出现的问题和责任,都会导致一些反模式。其中最 "著名 "的是 "肥胖 "控制器的反模式。

胖(肥胖)的控制器

把太多的逻辑放在控制器中的问题是,你开始违反了单一责任原则(SRP)。这意味着我们在控制器中做了太多的工作。通常,这将导致大量的代码和责任堆积在那里。这里,"胖 "是指控制器文件中包含的大量代码,以及控制器支持的逻辑。它通常被认为是一种反模式。

关于控制器应该做什么,有很多意见。一个共同点是,控制器应该有的职责包括以下内容。

  • 认证和授权--检查请求背后的实体(通常是用户)是否是它所说的人,是否被允许访问资源或执行操作。通常,认证被保存在会话或cookie中,但控制器仍应检查认证数据是否仍然有效。
  • 数据获取--它应该调用逻辑,根据请求中的参数找到正确的数据。在完美的世界里,它应该是对一个方法的调用,完成所有的工作。控制器不应该做繁重的工作,而应该将其进一步委托。
  • 模板渲染- 最后,它应该以适当的格式(HTML、JSON等)渲染结果来返回正确的响应。或者,它应该重定向到一些其他路径或URL。

遵循这些想法可以使你避免在控制器动作和控制器内部有太多的事情发生。在控制器层面保持简单,可以让你把工作委托给应用程序的其他区域。委托责任并逐一测试,将确保你的应用程序开发得很稳健。

当然,你可以遵循上述原则,但你一定很想知道一些例子。让我们深入了解一下,我们可以用什么模式来减轻控制器的一些负担。

查询对象

发生在控制器动作里面的一个问题是对数据的查询太多。如果你关注我们的博文《Rails Model anti-patterns and patterns》,我们经历了一个类似的问题,即模型有太多的查询逻辑。但是,这一次我们将使用一种叫做查询对象的模式。查询对象是一种技术,它将你的复杂查询隔离到一个单一的对象中。

在大多数情况下,查询对象是一个用ActiveRecord关系初始化的Plain Old Ruby对象。一个典型的查询对象可能看起来像这样:

# app/queries/all_songs_query.rb

class AllSongsQuery
  def initialize(songs = Song.all)
    @songs = songs
  end

  def call(params, songs = Song.all)
    songs.where(published: true)
         .where(artist_id: params[:artist_id])
         .order(:title)
  end
end

它被制作成在控制器内使用,就像这样:

class SongsController < ApplicationController
  def index
    @songs = AllSongsQuery.new.call(all_songs_params)
  end

  private

  def all_songs_params
    params.slice(:artist_id)
  end
end

你也可以尝试另一种查询对象的方法:

# app/queries/all_songs_query.rb

class AllSongsQuery
  attr_reader :songs

  def initialize(songs = Song.all)
    @songs = songs
  end

  def call(params = {})
    scope = published(songs)
    scope = by_artist_id(scope, params[:artist_id])
    scope = order_by_title(scope)
  end

  private

  def published(scope)
    scope.where(published: true)
  end

  def by_artist_id(scope, artist_id)
    artist_id ? scope.where(artist_id: artist_id) : scope
  end

  def order_by_title(scope)
    scope.order(:title)
  end
end

后一种方法通过使params,使查询对象更加强大。另外,你会注意到,现在我们可以调用AllSongsQuery.new.call 。如果你不太喜欢这样,你可以借助类方法。如果你用类方法编写你的查询类,它将不再是一个 "对象",但这是一个个人品味的问题。为了说明问题,让我们看看我们如何AllSongsQuery ,更简单地在野外调用。

后一种方法通过使params,使查询对象更加健壮。另外,注意我们现在可以调用AllSongsQuery.new.call 。如果你不太喜欢这样做,你可以求助于类方法。如果你用类方法编写你的查询类,它将不再是一个 "对象",但这是一个个人品味的问题。为了说明问题,让我们看看如何使AllSongsQuery ,在野外调用时更简单:

# app/queries/all_songs_query.rb

class AllSongsQuery
  class << self
    def call(params = {}, songs = Song.all)
      scope = published(songs)
      scope = by_artist_id(scope, params[:artist_id])
      scope = order_by_title(scope)
    end

    private

    def published(scope)
      scope.where(published: true)
    end

    def by_artist_id(scope, artist_id)
      artist_id ? scope.where(artist_id: artist_id) : scope
    end

    def order_by_title(scope)
      scope.order(:title)
    end
  end
end

现在,我们可以调用AllSongsQuery.call ,我们就完成了。我们可以用artist_id ,传入params。另外,如果我们因为某些原因需要改变初始范围,我们可以传递初始范围。如果你真的想避免在查询类上调用new ,可以试试这个 "技巧":

# app/queries/application_query.rb

class ApplicationQuery
  def self.call(*params)
    new(*params).call
  end
end

你可以创建ApplicationQuery ,然后在其他查询类中继承它:

# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
  ...
end

你仍然保留了AllSongsQuery.call ,但你让它变得更加优雅。

查询对象的伟大之处在于,你可以孤立地测试它们,确保它们在做它们应该做的事情。此外,你可以扩展这些查询类,并对它们进行测试,而不必太担心控制器中的逻辑。需要注意的是,你应该在其他地方处理你的请求参数,而不是依赖查询对象来处理。你怎么看,你打算给查询对象一个机会吗?

准备好服务

好了,我们已经处理了将数据的收集和获取委托给查询对象的方法。我们该如何处理从数据收集到呈现数据这一步之间堆积的逻辑呢?很高兴你问了这个问题,因为其中一个解决方案是使用所谓的服务。一个服务通常被视为一个PORO(Plain Old Ruby Object),它执行一个单一的(业务)动作。下面我们将继续探讨这个想法。

想象一下,我们有两个服务。一个创建一个收据,另一个像这样向用户发送收据:

# app/services/create_receipt_service.rb
class CreateReceiptService
  def self.call(total, user_id)
    Receipt.create!(total: total, user_id: user_id)
  end
end

# app/services/send_receipt_service.rb
class SendReceiptService
  def self.call(user)
    receipt = user.receipt.last

    UserMailer.send_receipt(receipt).deliver_later
  end
end

然后,在我们的控制器中,我们会像这样调用SendReceiptService

# app/controllers/receipts_controller.rb

class ReceiptsController < ApplicationController
  def create
    receipt = CreateReceiptService.call(total: receipt_params[:total],
                                        user_id: receipt_params[:user_id])

    SendReceiptService.call(receipt)
  end
end

现在你有两个服务在做所有的工作,而控制器只是调用它们。你可以分别测试这些,但问题是,这些服务之间没有明确的联系。是的,在理论上,所有的服务都在执行一个单一的业务动作。但是,如果我们从利益相关者的角度考虑抽象层次--他们对创建一个收据的动作的看法是发送一个电子邮件。谁的抽象水平是 "正确的"™️?

为了使这个思想实验更加复杂,让我们增加一个要求,即在创建收据的过程中,必须计算或从某个地方获取收据的总金额。那我们该怎么做呢?编写另一个服务来处理总和的计算?答案可能是遵循单一责任原则(SRP),将事物相互抽象化:

# app/services/create_receipt_service.rb
class CreateReceiptService
  ...
end

# app/services/send_receipt_service.rb
class SendReceiptService
  ...
end

# app/services/calculate_receipt_total_service.rb
class CalculateReceiptTotalService
  ...
end

# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
  def create
    total = CalculateReceiptTotalService.call(user_id: receipts_controller[:user_id])

    receipt = CreateReceiptService.call(total: total,
                                        user_id: receipt_params[:user_id])

    SendReceiptService.call(receipt)
  end
end

通过遵循SRE,我们确保我们的服务可以一起组成更大的抽象,比如ReceiptCreation process。通过创建这个 "流程 "类,我们可以将完成流程所需的所有动作分组。你对这个想法有什么看法?一开始听起来可能太抽象了,但如果你到处调用这些动作,可能会证明它是有益的。

通过遵循SRP,我们确保我们的服务可以被组成更大的抽象,比如ReceiptCreation流程。通过创建这个 "流程 "类,我们可以将完成该流程所需的所有动作分组。 你对这个想法怎么看?一开始听起来可能是太多的抽象,但如果你到处调用这些动作,它可能会被证明是有益的。如果这对你来说听起来不错,可以看看开拓者的操作

总而言之,新的CalculateReceiptTotalService 服务可以处理所有的数字计算。我们的CreateReceiptService ,负责把收据写到数据库中。SendReceiptService 则负责向用户发送关于收据的电子邮件。拥有这些小而集中的类可以使它们在其他用例中的组合更加容易,从而使代码库更容易维护和测试。

总而言之,新的CalculateReceiptTotalService 服务可以处理所有的数字计算。我们的CreateReceiptService ,负责将收据写入数据库。SendReceiptService 则负责向用户发送关于收据的电子邮件。有了这些小而集中的类,可以使它们在其他用例中的组合更加容易,从而使代码库更容易维护和测试。

服务的背景故事

在Ruby的世界里,使用服务类的方法也被称为行动、操作,以及类似的。这些都归结为命令模式。 命令模式背后的想法是,一个对象(或在我们的例子中,一个类)封装了执行业务行动或触发事件所需的所有信息。命令的调用者应该知道的信息是:

  • 命令的名称
  • 要在命令对象/类上调用的方法名称
  • 要传递给方法参数的值

所以,在我们的案例中,命令的调用者是一个控制器。这个方法非常相似,只是Ruby中的命名是 "服务"。

分割工作

如果你的控制器正在调用一些第三方服务,并且它们阻碍了你的渲染,也许是时候提取这些调用,用另一个控制器动作单独渲染它们。一个例子是,当你试图渲染一本书的信息,并从其他一些你无法真正影响的服务(如Goodreads)中获取它的评级:

# app/controllers/books_controller.rb

class BooksController < ApplicationController
  def show
    @book = Book.find(params[:id])

    @rating = GoodreadsRatingService.new(book).call
  end
end

如果Goodreads发生故障或类似的情况,你的用户将不得不等待对Goodreads服务器的请求超时。或者,如果他们的服务器有什么问题,页面的加载速度会很慢。你可以像这样把调用第三方服务的动作提取到另一个动作中:

# app/controllers/books_controller.rb

class BooksController < ApplicationController
  def show
    @book = Book.find(params[:id])
  end

  def rating
    @rating = GoodreadsRatingService.new(book).call

    render partial: 'book_rating'
  end
end

然后,你将不得不从你的视图中调用评级路径,但嘿,你的展示动作不再有阻断器了。另外,你还需要 "book_rating "部分。为了更容易做到这一点,你可以使用render_async gem。 你只需要在渲染你的书的评级的地方加上以下语句:

<%= render_async book_rating_path %>

提取用于渲染评级的HTML到book_rating 部分,并把:

<%= content_for :render_async %>

在你的布局文件里面,一旦你的页面加载,该宝石将用AJAX请求调用book_rating_path ,当评级被获取后,它将在页面上显示出来。这样做的一个大好处是,通过单独加载评级,你的用户可以更快地看到图书页面。

或者,如果你愿意,你可以使用Basecamp的Turbo Frames。这个想法是一样的,但你只是在你的标记中使用<turbo-frame>元素,像这样:

<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>

无论你选择什么方案,其目的是将繁重或不稳定的工作从你的主控制器动作中分离出来,并尽快向用户展示页面。

最后的想法

如果你喜欢把控制器保持得很薄,把它们想象成其他方法的 "调用者",那么我相信这篇文章带来了一些关于如何保持它们的见解。当然,我们在这里提到的几种模式和反模式并不是一个详尽的清单。如果你有什么更好的想法,或者你喜欢什么,请在Twitter上联系,我们可以讨论。

请继续关注这个系列,我们将至少再写一篇博文,总结常见的Rails问题和这个系列的收获。

直到下一次,欢呼吧!