Ruby 利用 Delegated Types 与 Self Joins 设计高扩展性系统

qinsicheng · 2026年01月15日 · 60 次阅读

前言

如何设计一套类似Confluence语雀Notion这类 CMS 系统,在一个容器内,里面含有大量的不同形式的内容,同时多种内容又有很多类似的操作:移动、复制、删除、支持评论等。一个文档下可以写文本,也可以再次建立子文档,文本评论也可以建立子评论。一篇文档能够清晰的查看操作历史、变化内容。同时一个容器内可能还会新增一些不同的内容,又同时也要支持上述的操作。

https://dev.37signals.com/the-rails-delegated-type-pattern/ 文章中探讨了 37Signals 团队如何设计系统架构,保证高维护性和高扩展性。其核心概念就是 Recording、Recordable 和 Event。本质上,他们利用了Delegated Types + Self Joins模式设计架构,来最大程度上支持高扩展性和可维护性。

参考资料:

Active Record Associations — Ruby on Rails Guides

https://api.rubyonrails.org/classes/ActiveRecord/DelegatedType.html

Active Record Associations — Ruby on Rails Guides

以下几种模式需要清楚:

基础模型关联

最常见的关联关系,相当于每个独立的表,通过一个索引关联 ID 来关联其他模型

高级模型关联

Polymorphic Associations

class Picture < ApplicationRecord
  belongs_to :imageable, polymorphic: true
end

class Employee < ApplicationRecord
  has_many :pictures, as: :imageable
end

class Product < ApplicationRecord
  has_many :pictures, as: :imageable
end

这里相当于图片资源,可能属于是用户的,也可能属于是产品的。未来可能还有其他地方也使用图片。那这里在图片模型中就会定义两个字段:

  • imageable_id
  • imageable_type

通过 rails 的关联模型,就能直接扩展和关联想要的数据,在复用历史模型的情况下,支持可拓展性

Self Joins

class Employee < ApplicationRecord
  # an employee can have many subordinates.
  has_many :subordinates, class_name: "Employee", foreign_key: "leader_id"

  # an employee can have one leader.
  belongs_to :leader, class_name: "Employee", optional: true
end

一个领导有很多员工,但领导本身也是一个员工,也可能有上级领导。统一存在一个表中,通过一个leader_id进行关联。

这本质上利用了树结构进行灵活的扩展,通过自连接替换掉了普通的模型关联。对于多层级但又是同一类型的实体,这种模式很方便。

Single Table Inheritance 单表继承

允许多个模型存储在单个数据库表中。当你拥有不同类型实体,它们共享共同属性和行为,但又有特定行为时,这非常有用。

例如,假设我们有 CarMotorcycleBicycle 模型。这些模型将共享诸如 colorprice 之类的字段,但每个模型都有独特的行为。它们也各自拥有自己的控制器。

class Vehicle < ApplicationRecord
end

class Car < Vehicle
end

class Motorcycle < Vehicle
end

所有模型使用一张表(vehicle),通过一个type字段来区分不同的类型。这样可以避免重复建表,又保留了扩展性。不同的类型,做各自特别的处理。

不过 STI 有个问题:随着业务复杂化,多个类型需要存储特定的数据,这些数据对有的类型有用,对有的类型无用,会导致原本的一张表中字段越来越多,很多字段对某些类型来说就是NULL值。

Delegated Types 委托类型

这正是为了解决 STI 的问题而出现的:

可能一开始使用单表继承很方便,但随着业务的复杂化,可能多个类型需要存储特定的数据,这些特定的数据,对有的类型有用,对有的类型无用,会导致原本的一张表中,字段越来越多,而又不是每个类型,都要使用的。那就可以把核心公共字段放在原始表上,然后创建各个类型具体字段到具体的表中。

# Schema: entries[ id, entryable_type, entryable_id, created_at, updated_at, created_by, updated_by ]
class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy
end

module Entryable
  extend ActiveSupport::Concern

  included do
    has_one :entry, as: :entryable, touch: true
  end
end

# Schema: messages[ id, subject, body ]
class Message < ApplicationRecord
  include Entryable
end

# Schema: comments[ id, content ]
class Comment < ApplicationRecord
  include Entryable
end

数据创建时使用:

Entry.create! entryable: Message.new(subject: "hello!")

数据使用时:

比如我希望文本搜索 Entry 中的内容时,可以将命令委托到特定的类上

class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[ Message Comment ], dependent: :destroy
  delegate :searchable_content, to: :leafable
end

class Message < ApplicationRecord
  include Entryable

  def searchable_content
    body
  end
end

class Comment < ApplicationRecord
  include Entryable

  def searchable_content
    content
  end
end

从业务场景看设计选择

通过一个实际的业务场景来理解这些设计模式的选择。

假设我们有一个报价单系统,报价单下包含多种服务,这些服务大致类似,但又有各自不同的订阅内容。

公共信息:订阅时长、原价格、实际价格、创建人、创建时间、更新人、更新时间、价格版本

特定信息

  • A 服务需要:订阅数量
  • B 服务需要:订阅类型
  • C 服务可能还需要其他信息...

传统设计

# 报价单表
class Quotation < ApplicationRecord
  # 关联A服务
  has_many: :project_service
  # 关联B服务
  has_many: :bid_info
end
# A服务 存储:订阅服务多久,原价格,实际价格,创建人,创建时间,更新人,更新时间,价格版本,订阅数量
class ProjectService < ApplicationRecord
  belongs_to :quotation
end

# B服务 存储:订阅服务多久,原价格,实际价格,创建人,创建时间,更新人,更新时间,价格版本,订阅类型
class BidInfo < ApplicationRecord
  belongs_to :quotation
end

可以看到每个服务核心内容是一致的,但是又有一些各自的信息。假如说我再加一个新的服务,就需要再修改

# 报价单表
class Quotation < ApplicationRecord
  # 关联A服务
  has_many: :project_service
  # 关联B服务
  has_many: :bid_info
  # 关联C服务
  has_many: :market_service
end
# C服务
class MarketService < ApplicationRecord
  belongs_to :quotation
end

现在遇到两个场景:

  1. 我想查看一个报价单下有哪些订阅服务,并分页查看,怎么写?
  2. 我想统计报价单下某些类别的金额,怎么写?

统计可能稍好一些,大不了就是多查几个表。但分页获取怎么做呢?

按照委托模式设计的话,可以这样:

# 报价单表
class Quotation < ApplicationRecord
  has_many: :quotion_service
end
# 服务表
class Service < ApplicationRecord
  delegated_type :serviceable, types: %w[ ProjectService BidInfo ], dependent: :destroy
end
module Serviceable
  extend ActiveSupport::Concern

  included do
    has_one :service, as: :serviceable, touch: true
  end
end

class ProjectService < ApplicationRecord
  include Serviceable
end

class BidInfo < ApplicationRecord
  include Serviceable
end

这个方案虽然多了一张表,关系也更复杂,但带来的好处很明显:

  • 所有服务的统计、分页、批处理都能直接从services表获取
  • 新增服务类型只需要添加新的 Recordable 类型,不需要修改报价单模型
  • 统一的管理接口,便于维护

WriteBook 源码查看

源码:https://once.com/writebook

class Book < ApplicationRecord
  has_many :leaves, dependent: :destroy
end

class Leaf < ApplicationRecord
  include Editable, Positionable, Searchable

  belongs_to :book, touch: true
  delegated_type :leafable, types: %w[ Page Section Picture ], dependent: :destroy
  delegate :searchable_content, to: :leafable

  scope :with_leafables, -> { includes(:leafable) }
end

class Page < ApplicationRecord
  include Leafable
end

class Section < ApplicationRecord
  include Leafable
end

class Picture < ApplicationRecord
  include Leafable
end

这里的设计很清晰:

  • Book是容器
  • Leaf是通用记录层(Recording)
  • PageSectionPicture是具体内容(Recordable)

37Signals 的核心设计思想

回到 37Signals 文章提出的概念

Recording(记录外壳)

存储所有内容的公共属性:ID、创建时间、创建者、位置信息等。相当于给所有内容穿了一个统一的外套。

Recordable(记录内核)

存储特定类型的具体数据。文档有文档的数据结构,评论有评论的数据结构。

关键设计:Recordable 是不可变的。用户操作修改时,不是修改原来的 Recordable,而是拷贝并创建一条新的数据,同时创建一个 Event。

Event(事件)

记录操作历史,关联新旧版本 ID。历史版本查看实际上就是查看一系列 Event,回滚到特定版本只需要把相应的 Recordable 挂载到 Recording 上。

在 CMS 系统中往往有文档历史更新记录的查看功能,所以在设计上,Recordale 是不能修改的。如果用户操作修改,则拷贝并创建一条新的数据,和一个 Event。Event 关联两个版本 ID,Recordable 挂载到新的 Recording 上,历史版本查看,实际上就是一个个事件,如果要回滚文档到某个具体的版本,只需要把 Recordable 挂载到特定的 Recording 上。

树形结构设计:Self Joins 的应用

在 CMS 系统中,内容往往需要支持树形结构:文档可以包含子文档,评论可以有子评论。

37Signals 文章中还提到一个值得学习的点:通过自连接来替换关联关系。实际上就是 Recordable 本身有一个parent_id

用 Confluence 举例,假设有这样的文档结构:

- A文档
  - B文档
    - C文档
      - 1评论
      - 2评论
    - D文件列表

看起来就像一个目录结构,文档、评论、文件列表可以自由地挂载到任意位置。其他服务也可以挂载。通过parent_id就可以做到:被挂载的服务无需知道以后会有什么挂载在自己下面,只要遵守规范,就能获得一致性处理。

这种设计的优雅之处在于:

  1. Delegated Types负责处理内容的水平扩展(不同类型的内容)
  2. Self Joins负责处理内容的垂直组织(树形结构)

总结

Delegated Types + Self Joins这种设计模式为构建复杂 CMS 系统提供了一个优雅的解决方案:

  • Delegated Types解决了“多种内容类型统一管理”的问题
  • Self Joins解决了“无限层级嵌套结构”的问题
  • 不可变 Recordable + Event解决了“完整版本历史”的问题

这种架构的核心优势在于:将内容类型的变化与内容结构的变化解耦。当需要新增内容类型时,只需实现新的 Recordable;当需要调整组织结构时,只需调整parent_id关系。两者互不干扰,系统维护性和扩展性都得到了保证。

对于需要处理复杂内容关系、支持未来扩展的现代应用系统,这一模式提供了一个经过实践验证的优秀设计范式。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号