Ruby on Rails模型的反模式和模式(附实例)

131 阅读3分钟

欢迎回到Ruby on Rails模式和反模式系列的第二篇文章。在上一篇博文中,我们介绍了什么是模式和反模式。我们还提到了一些在Rails世界中最有名的模式和反模式。在这篇博文中,我们将介绍一些Rails模型的反模式和模式。

如果你正在为模型而苦恼,这篇博文就是为你准备的。我们将快速浏览一下让你的模型减肥的过程,并以一些编写迁移时需要避免的事情作为结束。让我们直接跳入。

肥胖的超重模型

在开发一个Rails应用时,无论是一个完整的Rails网站还是一个API,人们都倾向于将大部分逻辑存储在模型中。在上一篇博文中,我们有一个Song类的例子,它做了很多事情。在模型中保留很多东西会破坏单一责任原则(SRP)。

让我们来看一下:

class Song < ApplicationRecord
  belongs_to :album
  belongs_to :artist
  belongs_to :publisher

  has_one :text
  has_many :downloads

  validates :artist_id, presence: true
  validates :publisher_id, presence: true

  after_update :alert_artist_followers
  after_update :alert_publisher

  def alert_artist_followers
    return if unreleased?

    artist.followers.each { |follower| follower.notify(self) }
  end

  def alert_publisher
    PublisherMailer.song_email(publisher, self).deliver_now
  end

  def includes_profanities?
    text.scan_for_profanities.any?
  end

  def user_downloaded?(user)
    user.library.has_song?(self)
  end

  def find_published_from_artist_with_albums
    ...
  end

  def find_published_with_albums
    ...
  end

  def to_wav
    ...
  end

  def to_mp3
    ...
  end

  def to_flac
    ...
  end
end

像这样的模型的问题是,它们变成了与歌曲相关的不同逻辑的垃圾场。方法开始堆积起来,因为它们被慢慢地添加,随着时间的推移一个接一个。

我建议将模型内的代码分割成更小的模块。但这样做,你只是把代码从一个地方移到另一个地方。尽管如此,四处移动代码可以让你更好地组织代码,避免肥胖的模型降低可读性。

有些人甚至诉诸于使用Rails关注点,发现逻辑可以跨模型重用。我以前写过这篇文章,有些人喜欢它,有些人不喜欢。总之,关注点的故事与模块类似。你应该意识到,你只是把代码移到了一个可以被包含在任何地方的模块中。

另一个选择是创建小类,然后在需要的时候调用它们。例如,我们可以将歌曲转换的代码提取到一个单独的类中:

class SongConverter
  attr_reader :song

  def initialize(song)
    @song = song
  end

  def to_wav
    ...
  end

  def to_mp3
    ...
  end

  def to_flac
    ...
  end
end

class Song
  ...

  def converter
    SongConverter.new(self)
  end

  ...
end

现在我们有了SongConverter ,其目的是将歌曲转换为不同的格式。它可以有自己的测试和关于转换的未来逻辑。 而且,如果我们想把一首歌曲转换为MP3,我们可以做以下事情:

@song.converter.to_mp3

对我来说,这看起来比使用一个模块或一个关注点更清晰一些。也许是因为我更喜欢使用组合而不是继承。我认为它更直观、更易读。我建议你在决定采用哪种方式之前,先审查这两种情况。或者,如果你想的话,你可以同时选择,没有人阻止你。

SQL帕尔马面

在现实生活中,谁不喜欢一些好的意大利面?另一方面,说到代码意大利面,几乎没有人是粉丝。而这是有原因的。在Rails模型中,你可以很快把你的Active Record的使用变成意大利面条,在代码库中到处乱窜。你如何避免这种情况呢?

有一些想法似乎可以让那些长的查询不至于变成意大利面条的行。让我们先看看与数据库相关的代码是如何随处可见的。让我们回到我们的Song模型。具体来说,就是当我们试图从其中获取一些东西时:

class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.where(status: :published)
                .where(artist_id: artist_id)
                .order(:title)

    ...
  end
end

class SongController < ApplicationController
  def index
    @songs = Song.where(status: :published)
                 .order(:release_date)

    ...
  end
end

class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.where(status: :published)

    ...
  end
end

在上面的例子中,我们有三个使用情况,其中歌曲模型被查询。在用于报告歌曲数据的SongReporterService中,我们试图从一个具体的艺术家那里获取已发布的歌曲。然后,在SongController中,我们获得已发布的歌曲,并按发布日期排序。最后,在SongRefreshJob中,我们只获得已发布的歌曲,并对其进行处理。

这一切都很好,但如果我们突然决定将状态名称改为发布,或对我们获取歌曲的方式做一些其他的改变呢?我们将不得不去单独编辑所有的事件。另外,上面的代码也不是干巴巴的。它在整个应用程序中重复自己。不要让这一点让你失望。幸运的是,这个问题是有解决办法的。

我们可以使用Rails的作用域来简化这段代码。作用域允许你定义常用的查询,这些查询可以在关联和对象上调用。这使得我们的代码具有可读性,并且更容易改变。但是,也许最重要的是,作用域允许我们将其他Active Record方法连锁起来,如Join、where等。让我们看看我们的代码在使用作用域后是怎样的:

class Song < ApplicationRecord
  ...

  scope :published, ->            { where(published: true) }
  scope :by_artist, ->(artist_id) { where(artist_id: artist_id) }
  scope :sorted_by_title,         { order(:title) }
  scope :sorted_by_release_date,  { order(:release_date) }

  ...
end

class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.published.by_artist(artist_id).sorted_by_title

    ...
  end
end

class SongController < ApplicationController
  def index
    @songs = Song.published.sorted_by_release_date

    ...
  end
end

class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.published

    ...
  end
end

就是这样。我们设法削减了重复的代码并把它放在模型中。但这并不总是最好的结果,特别是当你被诊断出有一个胖模型或一个上帝对象的情况下。在模型中添加越来越多的方法和责任可能不是一个好主意。

我的建议是尽量减少范围的使用,只在那里提取常用的查询。在我们的例子中,也许where(published: true)会是一个完美的作用域,因为它被用在任何地方。对于其他与SQL相关的代码,你可以使用一个叫做Repository模式的东西。让我们来看看它是什么。

存储库模式

我们将要展示的不是领域驱动设计一书中定义的1:1存储库模式。我们和Rails Repository模式背后的想法是将数据库逻辑和业务逻辑分开。我们也可以全力以赴,创建一个存储库类,为我们做原始的SQL调用,而不是Active Record,但除非你真的需要,否则我不推荐这种东西。

我们可以做的是创建一个SongRepository,把数据库逻辑放在那里:

class SongRepository
  class << self
    def find(id)
      Song.find(id)
    rescue ActiveRecord::RecordNotFound => e
      raise RecordNotFoundError, e
    end

    def destroy(id)
      find(id).destroy
    end

    def recently_published_by_artist(artist_id)
      Song.where(published: true)
          .where(artist_id: artist_id)
          .order(:release_date)
    end
  end
end

class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = SongRepository.recently_published_by_artist(artist_id)

    ...
  end
end

class SongController < ApplicationController
  def destroy
    ...

    SongRepository.destroy(params[:id])

    ...
  end
end

我们在这里所做的是将查询逻辑隔离到一个可测试的类中。另外,模型也不再关注作用域和逻辑了。控制器和模型都很薄,大家都很高兴。对吗?嗯,仍然有Active Record在那里做所有的工作。在我们的方案中,我们使用find,它产生了以下结果:

SELECT "songs".* FROM "songs" WHERE "songs"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]

正确的方法是在SongRepository中定义所有这些。正如我所说,我不建议这样做。你不需要它,你想拥有完全的控制权。离开Active Record的用例是,你需要一些复杂的SQL技巧,而Active Record不容易支持这些技巧。

谈到原始SQL和Active Record,我还必须提出一个话题。这个话题就是迁移,以及如何正确地进行迁移。让我们深入了解一下。

迁移--谁在乎呢?

在编写迁移时,我经常听到一种说法,即那里的代码不应该和应用程序的其他部分一样好。而这种说法让我很不舒服。人们倾向于用这个借口在迁移中设置难闻的代码,因为它只会被运行一次就被遗忘。如果你是和几个人一起工作,而且每个人都一直保持同步,那么这也许是真的。

现实往往是不同的。应用程序可以被更多的人使用,而不知道不同的应用程序部分会发生什么。如果你把一些有问题的一次性代码放在那里,你可能会因为损坏的数据库状态或只是一个奇怪的迁移而破坏别人的开发环境几个小时。不知道这是否是一种反模式,但你应该注意到它。

如何使迁移对其他人更方便?让我们通过一个列表,让项目中的每个人都能更方便地进行迁移。

请确保总是提供一个向下的方法

你永远不知道什么时候会有东西被回滚。如果你的迁移是不可逆的,一定要像这样引发ActiveRecord::IreversibleMigration异常:

def down
  raise ActiveRecord::IrreversibleMigration
end

尽量避免在迁移中使用Active Record

这里的想法是,除了应该执行迁移时的数据库状态外,尽量减少外部依赖性。所以不会有Active Record的验证来破坏(或者可能拯救)你的日子。你只能使用普通的SQL。例如,让我们写一个迁移,它将发布某个艺术家的所有歌曲:

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end

  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

如果你对歌曲模型有很大的需求,建议你在迁移中定义它。这样一来,你就可以使你的迁移不受应用/模型中实际的Active Record模型的任何潜在变化的影响。但是,这一切都很好吗?让我们来看看下一个问题。

将模式迁移与数据迁移分开

翻开Rails Guides中关于迁移的内容,你会看到以下内容:

迁移是Active Record的一项功能,它允许你随着时间的推移不断发展你的数据库模式。与其用纯SQL语言编写模式修改,迁移允许你使用Ruby DSL来描述表的变化。

在指南的摘要中,没有提到编辑数据库表的实际数据,只是提到了结构。所以,我们在第二点中使用常规迁移来更新歌曲的事实是不完全正确的。

如果你需要在你的项目中经常做类似的事情,可以考虑使用data_migrate gem。它是一种将数据迁移和模式迁移分开的好方法。我们可以用它轻松地重写我们之前的例子。为了生成数据迁移,我们可以做以下工作:

bin/rails generate data_migration update_artists_songs_to_published

然后在那里添加迁移逻辑:

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end

  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

这样一来,你就把所有的模式迁移放在了db/migrate目录内,而把所有处理数据的迁移放在了db/data目录内。

最后的想法

在Rails中处理模型并保持其可读性是一个持续的斗争。希望在这篇博文中,你能看到可能出现的陷阱和常见问题的解决方案。在这篇文章中,模型反模式和模式的清单还远远没有完成,但这些是我最近发现的最值得注意的。

如果你对更多的Rails模式和反模式感兴趣,请继续关注本系列的下一篇文章。在接下来的文章中,我们将讨论Rails MVC的视图和控制器方面的常见问题和解决方案。

直到下一次,欢呼吧!