委托类型¶ ↑
Class 层级结构可以以多种方式映射到关系型数据库表。例如,Active Record 提供纯粹的抽象类,其中超类不持久化任何属性;还提供单表继承,其中层级的从所有层继承的属性都表示在单个表中。两者都有其用武之地,但都不是没有缺点。
纯抽象类的问题在于,所有具体的子类都必须在自己的表中持久化所有共享的属性(也称为类表继承)。这使得跨层级进行查询变得困难。例如,假设你有以下层级:
Entry < ApplicationRecord Message < Entry Comment < Entry
如何显示一个同时包含 Message 和 Comment 记录的 feed,并且可以轻松分页?嗯,你做不到!Message 由 messages 表支持,Comment 由 comments 表支持。你不能同时从两个表中提取数据并使用一致的 OFFSET/LIMIT 方案。
你可以通过使用单表继承来解决分页问题,但现在你被迫使用一个单一的巨型表,其中包含所有子类的所有属性。无论它们有多么不同。如果 Message 有一个主题,但 Comment 没有,那么 Comment 现在也得有一个!因此,STI 在子类及其属性之间几乎没有差异时效果最好。
但还有第三种方法:委托类型。通过这种方法,“超类”是一个具体的类,它由自己的表表示,其中存储了所有在所有“子类”之间共享的超类属性。然后,每个子类都有自己的独立表,用于存储其实现特有的其他属性。这类似于 Django 中称为多表继承的东西,但它不是实际的继承,而是使用委托来形成层级结构和共享职责。
让我们使用委托类型来看一下那个 entry/message/comment 示例
# Schema: entries[ id, account_id, creator_id, entryable_type, entryable_id, created_at, updated_at ] class Entry < ApplicationRecord belongs_to :account belongs_to :creator delegated_type :entryable, types: %w[ Message Comment ] end module Entryable extend ActiveSupport::Concern included do has_one :entry, as: :entryable, touch: true end end # Schema: messages[ id, subject, body, created_at, updated_at ] class Message < ApplicationRecord include Entryable end # Schema: comments[ id, content, created_at, updated_at ] class Comment < ApplicationRecord include Entryable end
如你所见,Message 和 Comment 都不打算独立存在。这两个类的关键元数据都存在于 Entry “超类”中。但 Entry 本身在查询能力方面绝对可以独立存在。你现在可以轻松地执行诸如以下的操作:
Account.find(1).entries.order(created_at: :desc).limit(50)
这正是你在显示 comment 和 message 时想要的。Entry 本身可以轻松地作为其委托类型进行渲染,如下所示:
# entries/_entry.html.erb
<%= render "entries/entryables/#{entry.entryable_name}", entry: entry %>
# entries/entryables/_message.html.erb
<div class="message">
<div class="subject"><%= entry.message.subject %></div>
<p><%= entry.message.body %></p>
<i>Posted on <%= entry.created_at %> by <%= entry.creator.name %></i>
</div>
# entries/entryables/_comment.html.erb
<div class="comment">
<%= entry.creator.name %> said: <%= entry.comment.content %>
</div>
使用 concerns 和 controllers 共享行为¶ ↑
Entry “超类”也是放置所有适用于 message 和 comment 的共享逻辑的理想场所,并且这些逻辑主要作用于共享属性。设想一下:
class Entry < ApplicationRecord include Eventable, Forwardable, Redeliverable end
这允许你拥有像 ForwardsController 和 RedeliverableController 这样的控制器,它们都作用于 entries,从而为 message 和 comment 提供共享功能。
创建新记录¶ ↑
通过同时创建委托者和被委托者,你可以创建一个使用委托类型的新记录,如下所示:
Entry.create! entryable: Comment.new(content: "Hello!"), creator: Current.user, account: Current.account
如果你需要更复杂的组合,或者需要执行依赖验证,你应该构建一个工厂方法或类来处理这些复杂的需求。这可以很简单,比如:
class Entry < ApplicationRecord def self.create_with_comment(content, creator: Current.user, account: Current.account) create! entryable: Comment.new(content: content), creator: creator, account: account end end
跨记录查询¶ ↑
委托类型的一个后果是,跨多个类查询属性会变得稍微棘手一些,但并非不可能。
最简单的方法是将“超类”连接到“子类”,并在适当的位置应用查询参数(即 where)。
Comment.joins(:entry).where(comments: { content: 'Hello!' }, entry: { creator: Current.user } )
为了方便起见,在 concern 中添加一个 scope。现在所有实现该 concern 的类都会自动包含该方法。
# app/models/concerns/entryable.rb scope :with_entry, ->(attrs) { joins(:entry).where(entry: attrs) }
现在查询可以大大简化。
Comment.where(content: 'Hello!').with_entry(creator: Current.user)
添加进一步的委托¶ ↑
委托类型不应该仅仅回答底层类名称的问题。事实上,这在大多数情况下是一种反模式。你构建这个层级的原因是为了利用多态性。所以这里有一个简单的例子:
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment ] delegate :title, to: :entryable end class Message < ApplicationRecord def title subject end end class Comment < ApplicationRecord def title content.truncate(20) end end
现在你可以列出所有 entries,调用 Entry#title,多态性将为你提供答案。
嵌套属性¶ ↑
在 delegated_type 关联上启用嵌套属性允许你一次性创建 entry 和 message。
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment ] accepts_nested_attributes_for :entryable end params = { entry: { entryable_type: 'Message', entryable_attributes: { subject: 'Smiling' } } } entry = Entry.create(params[:entry]) entry.entryable.id # => 2 entry.entryable.subject # => 'Smiling'
实例公共方法
delegated_type(role, types:, **options) Link
将此定义为一个类,该类将为其传入的 role 的类型委托给 types 中引用的类。这将创建一个多态的 belongs_to 关系到该 role,并添加所有委托类型的便捷方法。
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy end @entry.entryable_class # => Message or Comment @entry.entryable_name # => "message" or "comment" Entry.messages # => Entry.where(entryable_type: "Message") @entry.message? # => true when entryable_type == "Message" @entry.message # => returns the message record, when entryable_type == "Message", otherwise nil @entry.message_id # => returns entryable_id, when entryable_type == "Message", otherwise nil Entry.comments # => Entry.where(entryable_type: "Comment") @entry.comment? # => true when entryable_type == "Comment" @entry.comment # => returns the comment record, when entryable_type == "Comment", otherwise nil @entry.comment_id # => returns entryable_id, when entryable_type == "Comment", otherwise nil
你也可以声明命名空间类型。
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment Access::NoticeMessage ], dependent: :destroy end Entry.access_notice_messages @entry.access_notice_message @entry.access_notice_message?
选项¶ ↑
options 被直接传递给 belongs_to 调用,所以这里是你声明 dependent 等的地方。以下选项可包含用于专门化委托类型便捷方法的行为。
:foreign_key-
指定用于便捷方法的外部键。默认情况下,它被推断为传入的
role加上“_id”后缀。因此,一个定义了delegated_type :entryable, types: %w[ Message Comment ]关联的类将使用“entryable_id”作为默认的:foreign_key。 :foreign_type-
指定用于存储关联对象的类型的列。默认情况下,它被推断为传入的
role加上“_type”后缀。一个定义了delegated_type :entryable, types: %w[ Message Comment ]关联的类将使用“entryable_type”作为默认的:foreign_type。 :primary_key-
指定用于便捷方法关联对象主键的方法。默认情况下是
id。
选项示例
class Entry < ApplicationRecord delegated_type :entryable, types: %w[ Message Comment ], primary_key: :uuid, foreign_key: :entryable_uuid end @entry.message_uuid # => returns entryable_uuid, when entryable_type == "Message", otherwise nil @entry.comment_uuid # => returns entryable_uuid, when entryable_type == "Comment", otherwise nil