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
关系,例如orders
、comments
、children
等
如果我们开始向用户 class 添加类似的 process_orders
、process_comments
、process_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 感觉更好,因为:
- 用户模型保持整洁。
- 进程函数被调用
process
而不是 process_posts
。现在我们也可以为其他 classes 设置 process
并将它们调用为:User.find(1).orders.process
等而不是 User.find(1).process_orders
(方法 1)。
注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
舞台
让我们谈谈我们遇到的最常见的关联类型。
我有一个用户 :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
关系,例如orders
、comments
、children
等
如果我们开始向用户 class 添加类似的 process_orders
、process_comments
、process_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 感觉更好,因为:
- 用户模型保持整洁。
- 进程函数被调用
process
而不是process_posts
。现在我们也可以为其他 classes 设置process
并将它们调用为:User.find(1).orders.process
等而不是User.find(1).process_orders
(方法 1)。
注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