Rails 协会的最佳代码结构

Best code structure for Rails associations

舞台

让我们谈谈我们遇到的最常见的关联类型。

我有一个用户 :has_many Post(s)

class User < ActiveRecord::Base
  has_many :posts
end

class Post < ActiveRecord::Base
  belongs_to :user
end

问题陈述

我想对一个用户的所有帖子做一些(非常简单和快速的)处理。我正在寻找构建我的代码以实现它的最佳方法。以下是几种方法以及它们有效或无效的原因。

方法一

User class 本身做。

class User < ActiveRecord::Base
  has_many :posts

  def process_posts
    posts.each do |post|
      # code of whatever 'process' does to posts of this user
    end
  end
end

Post class保持不变:

class Post < ActiveRecord::Base
  belongs_to :user
end

方法调用为:

User.find(1).process_posts

为什么这看起来不是最好的方法

用用户的帖子做某事的逻辑应该属于Post class。在现实世界中,用户可能还与许多其他 class 有 :has_many 关系,例如orderscommentschildren

如果我们开始向用户 class 添加类似的 process_ordersprocess_commentsprocess_children (yikes) 方法,这将导致一个巨大的文件包含很多其中大部分代码可以(并且应该)分发到它所属的位置,即目标关联。

方法二

代理关联和范围

这两种构造都需要向用户 class 添加 methods/code,这又使其变得臃肿。我宁愿将 all 实现转移到目标 classes.

方法三

Class 目标方法 Class

在目标 class 中创建 class 方法并在 User 对象上调用这些方法。

class User < ActiveRecord::Base
  has_many :comments
  # all target specific code in target classes
end

class Post < ActiveRecord::Base
  belongs_to :user

  # Class method
  def self.process
    Post.all.each do |post|  # see Note 2 below
      # code of whatever 'process' does to posts of this user
    end
  end
end

方法调用为:

User.find(1).posts.process   # See Note 1 below

现在,这看起来比方法 1 和 2 感觉更好,因为:

注1:

是的,您可以在关联上调用这样的 class 方法。 Read why here。 TL;DR 是 User.find(1).posts returns 一个 CollectionProxy 对象,它可以访问目标 (Post) class 的 class 方法。它还方便地传递一个 scope_attributes,它存储调用 posts.process 的用户的 user_id。这很方便。请参阅下面的注释 2。

注二:

当我们在 class 方法中执行 Post.all.each 时,对于不确定发生了什么的人,它 returns 用户的所有帖子都是此方法针对数据库中的所有帖子调用

所以当调用 User.find(99).posts.process 时,Post.all 执行:

SELECT "notes".* FROM "posts" WHERE "posts"."user_id" =   [["user_id", 99]]

这是用户 ID: 99 的所有帖子。

根据@Jesuspc 下面的评论,Post.all.each 可以简洁地写成 all.each。它更加地道,不会让人觉得我们正在查询数据库中的所有帖子。

我正在寻找的答案

就我个人而言,我认为 方法 1 是最干净的方法。这样写会非常干净易懂:

Class User < ActiveRecord::Base
  has_many :posts

  def process_posts
    posts.each do |post|
     post.process
    end
  end
end

并将process方法的所有逻辑放在Post模型中(带有实例变量):

Class Post < ActiveRecord::Base
  belongs_to :user

  def process
     # Logic of your Post process
  end
end

这样,Post 进程的逻辑就属于 Post class。即使您的 User 模型有许多 "process" 功能,这些功能也非常基本且很小。作为开发人员,这对我来说似乎很干净。

方法 3 有很多技术含义,非常复杂且不直观(您必须澄清您的问题)。

注意:如果你想要更好的性能,也许你应该使用 eager loading 来减少 ActiveRecord 调用,但这超出了这个问题的范围。

首先请原谅我的自以为是的回答。

ActiveRecord 模型是一个有争议的问题。它的本质违反了单一责任原则,因为它们通过class方法处理数据库交互,并通过其实例处理领域对象(用于实现自己的行为)。同时他们也打破了Liskov Substitution Principle因为模型不是ActiveRecord::Base的子case并且实现了他们自己的一套方法。最后,ActiveRecord 范式经常导致代码违反 Demeter 法则,如您对第三种方法的提议:

User.find(1).posts.process

因此,有一种趋势是为了减少耦合,建议仅使用 ActiveRecord 对象与数据库交互,因此不应向它们添加任何行为(在您的情况下 process方法)。在我看来,这是较小的邪恶,尽管它仍然不是一个完美的解决方案。

因此,如果我要实现您所描述的内容,我将拥有一个 ProcessablePostsCollection 对象(其中可以自定义名称 Processable 以更好地描述处理的内容,甚至可以完全忽略,因此您可以简单地拥有一个 PostsCollection class) 可能是使用 SimpleDelegator 的帖子列表的包装器,并且会有一个方法 process.

class ProcessablePostsCollection < SimpleDelegator
  def self.from_collection(collection)
    new collection
  end

  def initialize(source)
    super source
  end

  def process
    # code of whatever 'process' does to posts
  end
end

用法类似于:

ProcessablePostsCollection.from_collection(User.find(1).posts).process

尽管 from_collection 和对 process 的调用应该发生在不同的类中。

此外,如果您有大量帖子 table,分批处理内容可能是明智的。为此,您的 process 方法可以在您的帖子 ActiveRecord::Relation.

上调用 find_in_batches

但一如既往,这取决于您的需要。如果你只是简单地构建一个原型,那么让你的模型变胖是非常好的,如果你正在构建一个巨大的应用程序,Rails 本身可能不是最好的选择,因为不鼓励一些 OOP 最佳实践,例如活动记录模型。

还有第四个选项。将此逻辑完全移出模型:

class PostProcessor
  def initialize(posts)
    @posts = posts
  end

  def process
    @posts.each do |post|
      # ...
    end
  end
end

PostProcessor.new(User.find(1).posts).process

这有时称为服务对象模式。这种方法的一个非常好的好处是它使得为这个逻辑编写测试变得非常简单。这是一个很棒的博客 post,介绍了重构 "fat" 模型的这种方法和其他方法:http://blog.codeclimate.com/blog/2012/10/17/7-ways-to-decompose-fat-activerecord-models/

你不应该把它放在 User 模型中 - 把它放在 Post 中(除非 - 当然 - process 的范围涉及 User模型直接):

#app/models/post.rb
class Post < ActiveRecord::Base
    def process
       return false if post.published?
       # do something
    end
end

然后您可以使用 ActiveRecord 关联扩展将功能添加到 User 模型:

#app/models/user.rb
class User < ActiveRecord::Base
   has_many :posts do
      def process
          proxy_association.target.each do |post|
             post.process
          end
      end
   end
end

这样您就可以调用...

@user = User.find 1
@user.posts.process