前言
如何设计一套类似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 来关联其他模型
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
这里相当于图片资源,可能属于是用户的,也可能属于是产品的。未来可能还有其他地方也使用图片。那这里在图片模型中就会定义两个字段:
通过 rails 的关联模型,就能直接扩展和关联想要的数据,在复用历史模型的情况下,支持可拓展性
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进行关联。
这本质上利用了树结构进行灵活的扩展,通过自连接替换掉了普通的模型关联。对于多层级但又是同一类型的实体,这种模式很方便。
允许多个模型存储在单个数据库表中。当你拥有不同类型实体,它们共享共同属性和行为,但又有特定行为时,这非常有用。
例如,假设我们有 Car 、 Motorcycle 和 Bicycle 模型。这些模型将共享诸如 color 和 price 之类的字段,但每个模型都有独特的行为。它们也各自拥有自己的控制器。
class Vehicle < ApplicationRecord
end
class Car < Vehicle
end
class Motorcycle < Vehicle
end
所有模型使用一张表(vehicle),通过一个type字段来区分不同的类型。这样可以避免重复建表,又保留了扩展性。不同的类型,做各自特别的处理。
不过 STI 有个问题:随着业务复杂化,多个类型需要存储特定的数据,这些数据对有的类型有用,对有的类型无用,会导致原本的一张表中字段越来越多,很多字段对某些类型来说就是NULL值。
这正是为了解决 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
通过一个实际的业务场景来理解这些设计模式的选择。
假设我们有一个报价单系统,报价单下包含多种服务,这些服务大致类似,但又有各自不同的订阅内容。
公共信息:订阅时长、原价格、实际价格、创建人、创建时间、更新人、更新时间、价格版本
特定信息:
# 报价单表
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
现在遇到两个场景:
统计可能稍好一些,大不了就是多查几个表。但分页获取怎么做呢?
按照委托模式设计的话,可以这样:
# 报价单表
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表获取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)Page、Section、Picture是具体内容(Recordable)回到 37Signals 文章提出的概念
存储所有内容的公共属性:ID、创建时间、创建者、位置信息等。相当于给所有内容穿了一个统一的外套。
存储特定类型的具体数据。文档有文档的数据结构,评论有评论的数据结构。
关键设计:Recordable 是不可变的。用户操作修改时,不是修改原来的 Recordable,而是拷贝并创建一条新的数据,同时创建一个 Event。
记录操作历史,关联新旧版本 ID。历史版本查看实际上就是查看一系列 Event,回滚到特定版本只需要把相应的 Recordable 挂载到 Recording 上。
在 CMS 系统中往往有文档历史更新记录的查看功能,所以在设计上,Recordale 是不能修改的。如果用户操作修改,则拷贝并创建一条新的数据,和一个 Event。Event 关联两个版本 ID,Recordable 挂载到新的 Recording 上,历史版本查看,实际上就是一个个事件,如果要回滚文档到某个具体的版本,只需要把 Recordable 挂载到特定的 Recording 上。
在 CMS 系统中,内容往往需要支持树形结构:文档可以包含子文档,评论可以有子评论。
37Signals 文章中还提到一个值得学习的点:通过自连接来替换关联关系。实际上就是 Recordable 本身有一个parent_id。
用 Confluence 举例,假设有这样的文档结构:
- A文档
- B文档
- C文档
- 1评论
- 2评论
- D文件列表
看起来就像一个目录结构,文档、评论、文件列表可以自由地挂载到任意位置。其他服务也可以挂载。通过parent_id就可以做到:被挂载的服务无需知道以后会有什么挂载在自己下面,只要遵守规范,就能获得一致性处理。
这种设计的优雅之处在于:
Delegated Types + Self Joins这种设计模式为构建复杂 CMS 系统提供了一个优雅的解决方案:
这种架构的核心优势在于:将内容类型的变化与内容结构的变化解耦。当需要新增内容类型时,只需实现新的 Recordable;当需要调整组织结构时,只需调整parent_id关系。两者互不干扰,系统维护性和扩展性都得到了保证。
对于需要处理复杂内容关系、支持未来扩展的现代应用系统,这一模式提供了一个经过实践验证的优秀设计范式。