跳至内容 跳至搜索

委托类型

Class 层级结构可以以多种方式映射到关系型数据库表。例如,Active Record 提供纯粹的抽象类,其中超类不持久化任何属性;还提供单表继承,其中层级的从所有层继承的属性都表示在单个表中。两者都有其用武之地,但都不是没有缺点。

纯抽象类的问题在于,所有具体的子类都必须在自己的表中持久化所有共享的属性(也称为类表继承)。这使得跨层级进行查询变得困难。例如,假设你有以下层级:

Entry < ApplicationRecord
Message < Entry
Comment < Entry

如何显示一个同时包含 MessageComment 记录的 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

如你所见,MessageComment 都不打算独立存在。这两个类的关键元数据都存在于 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

这允许你拥有像 ForwardsControllerRedeliverableController 这样的控制器,它们都作用于 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'
方法
D

实例公共方法

delegated_type(role, types:, **options)

将此定义为一个类,该类将为其传入的 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
# File activerecord/lib/active_record/delegated_type.rb, line 231
def delegated_type(role, types:, **options)
  belongs_to role, options.delete(:scope), **options, polymorphic: true
  define_delegated_type_methods role, types: types, options: options
end