委托类型简介
来源:Tighten
作者:Tony Messias
发布时间:2026-02-11T07:00:00-06:00
早在 2024 年,37signals 团队发布了 Writebook,这是他们 ONCE 产品线下的又一力作。Writebook(“世界上最简单的在线出书方式”)采用了我最喜欢的一种模式:委托类型(Delegated Types),这是一种在 Active Record ORM 中表示类层次结构的方式。你可以把它理解为“Dog extends Animal”,但并没有使用继承。
他们最近开始在公司博客上更多地谈论这个模式。在这篇文章中,我们将探讨委托类型是什么样的,讨论它们的好处,将其与单表继承等其他表示层次结构的方式进行比较,并探讨它们支持的几个有趣的用例。
引言
在 Active Record ORM(如 Eloquent)中,有几种表示层次结构的方式。最常见的是单表继承(STI),它将所有子类型的属性存储在一个数据库表中。这种模式适用于子类型共享相似数据但实现不同行为的层次结构。
举个例子,我们将基于 37signals 的第一个 ONCE 产品 Campfire 来重新创建一些聊天应用的模型。在 Campfire 中,我们有一个表示聊天室的 Room 模型。聊天室可以是 open、closed 或 direct。他们使用 STI 来表示不同类型的房间:
use Illuminate\Database\Eloquent\Model;
use Parental\HasChildren;
use Parental\HasParent;
class Room extends Model
{
use HasChildren;
public function members() { /* ... */ }
public function creator() { /* ... */ }
}
class Closed extends Room
{
use HasParent;
}
class Open extends Room
{
use HasParent;
public static function booted()
{
static::saved(function (Open $room) {
$room->ensureAllUsersHaveAccess();
});
}
public function ensureAllUsersHaveAccess(): void
{ /* ... */ }
}
为了简洁起见,这里只展示了 open 和 closed 房间。Eloquent 本身并不支持 STI,但我们可以通过 Composer 安装 Parental 包来使用它:
composer require tightenco/parental
在这个例子中,房间的子类型之间没有区别。它们都适合放在一个数据库表中:
rooms
id
user_id # 创建者
name
type # STI
Parental 使用那个 type 属性,根据其值在运行时决定实例化哪个类。
这里不同行为的一个例子是模型回调。当保存一个 open 房间时,会调用 ensureAllUsersHaveAccess() 方法。这很有用,因为在 Campfire 中,房间在其生命周期内可能会改变类型。一个 closed 房间之后可能会变成 open,这会触发回调并授予所有人访问权限。另一方面,Closed 房间只应授予明确获得访问权限的用户访问。
当每个子类型需要不同的数据时,STI 可能会迅速变得复杂。我们最终会在数据库表中出现很多 nullable 字段(或者更糟糕的是,无模式的 JSON 字段)。
这个限制的答案其实一直都在:多态关联。Adam Wathan(Tighten 校友,Tailwind CSS 的创建者)早在 2015 年就通过他的文章“将多态推送到数据库”探索了这种技术。多年后的今天,我们终于为它起了一个合适的名字:委托类型!
委托类型
这个模式在这个 Rails 的 PR 中被记录下来,它似乎是从 HEY 的代码库中提取出来的。
委托类型在多态关联之上工作,但它将多态关系反转了。我来解释一下我的意思。假设我们的应用同时有 Video 和 Post 模型。两者都可以接收 Comments。我们可能已经习惯了看到最基本的多态关联示例:
class Comment extends Model
{
public function commentable(): MorphTo
{
return $this->morphTo();
}
}
trait Commentable
{
public function comments(): MorphMany
{
return $this->morphMany(Comment::class, 'commentable');
}
}
class Post extends Model
{
use Commentable;
}
class Video extends Model
{
use Commentable;
}
使用多态关联,我们定义的是水平层次结构,而不是我们习惯的传统垂直层次结构。注意,这里几乎像有一个 不可见的层次结构;我们甚至给它起了个名字:Commentable。Post 和 Video 都是这个 不可见层次结构 的一部分。这里的数据库结构大致如下:
videos
id
source_path
# ...
posts
id
title
content
# ...
comments
id
commentable_id # 相关模型的 ID
commentable_type # 相关模型的类型
content
# ...
多态关联需要一对列(id 和 type)才能工作,这样它们可以存储对不同数据库表的引用。
尽管有些情况下,常规的多态关联(如上面所示)就足够了,但使用委托类型,我们可以显式地对层次结构进行建模。而且,更好的是,我们可以依赖委托而不是继承来做到这一点。
下面是一个工作示例。我们在 Commentable 类型和 Comments 之间创建了一个实体,称之为 Entry。entryable() 这个 MorphTo 关系定义了一个一对一的多态关系,允许一个 Entry 被附加到任何其他类型:
class Entry extends Model
{
public function entryable(): MorphTo
{
return $this->morphTo();
}
}
trait Entryable
{
public function entry(): MorphOne
{
return $this->morphOne(Entry::class, 'entryable');
}
}
class Post extends Model
{
use Entryable;
}
class Video extends Model
{
use Entryable;
}
通过这种方式构建我们的领域模型,我们在层次结构中的所有记录之间获得了一个共享实体。我们的数据库结构现在看起来像这样:
entries
id
entryable_id # 相关模型的 ID
entryable_type # 相关模型的类型
# ...
videos
id
source_path
# ...
posts
id
title
content
# ...
这样,我们可以确保所有的 Video 和 Post 都有一个关联的 Entry 模型。由于所有模型都是 Entryable 的,我们现在有了一个共享的地方来附加公共行为。这意味着,与其让我们的 Comment 模型是多态的,我们现在可以将其附加到 Entry 模型上,并将其变成简单的 has many/belongs to 关系对:
class Entry extends Model
{
public function entryable() { /** ... */ }
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}
class Comment extends Model
{
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
}
例如,如果我们需要获取一个 Video 的评论,我们将通过相关的 Entry 来获取。我们的数据库结构现在看起来大致如下:
entries
id
entryable_id # 相关模型的 ID
entryable_type # 相关模型的类型
# ...
videos
id
source_path
# ...
posts
id
title
content
# ...
comments
id
entry_id
content
# ...
关于根模型命名的说明
这里选择 “Entry” 作为层次结构根的名称是任意的。在 Basecamp,他们似乎称之为 Recording。在你的应用中,它可能被叫做别的名字。不过,“Entry” 似乎已经成为这个模式的常规名称,因为它代表“用户输入的数据”,而且我相信这也是他们在 HEY 中的叫法(原始 PR 示例就是来自那里)。
这种设计上的改变开启了新的用例,并使应用变得更加灵活。现在,我们可以轻松地在所有 Entryable 模型上添加共享行为,表示更丰富的层次结构,并且(也许是我最喜欢的)使我们的数据“不可变”。接下来我们将探讨这些用例。
1. 共享行为
使用常规的多态方法,评论是所有 Entryable 之间共享的行为。那么我们如何实现它呢?让我们从 REST 的角度来思考:
POST /posts/{post}/comments
POST /videos/{video}/comments
POST /comments/{comment}/comments
...
如果评论是我们系统中唯一的共享行为,这不算什么大问题。但一旦我们开始添加更多这样的共享行为,我们就能闻到坏味道:
POST /posts/{post}/comments
POST /videos/{video}/comments
POST /comments/{comment}/comments
...
POST /posts/{post}/likes
POST /videos/{video}/likes
POST /comments/{comment}/likes
...
POST /posts/{post}/reactions
POST /videos/{video}/reactions
POST /comments/{comment}/reactions
...
这样,路由(和控制器!)会迅速膨胀。现在,让我们将其与委托类型版本进行比较。由于我们应用中所有相关的 Entryable 都是同一个抽象的一部分,并且有一个关联的根 Entry 模型,而且 Commentable 行为被简化为 Entry 上的 hasMany/belongsTo 关系,我们的路由和控制器可以简化:
POST /entries/{entry}/comments
POST /entries/{entry}/likes
POST /entries/{entry}/reactions
...
现在我们有了一个单一的路由和控制器来创建评论:
class EntryCommentsController
{
public function store(Request $request, Entry $entry)
{
$comment = $entry->comments()->create($request->validate([
'content' => ['required', 'max:255'],
]));
return back()->withFragment(sprintf(
'#%s',
dom_id($comment),
));
}
}
如果出于某种原因,你想对特定的 Entryable 禁用这种共享行为,我们可以通过委托来实现:
class EntryCommentsController
{
public function store(Request $request, Entry $entry)
{
$this->authorize($entry, 'addComment');
// ...
}
}
class EntryPolicy
{
public function addComment(User $_user, Entry $entry): bool
{
return $entry->entryable->canHaveComments();
}
}
trait Entryable
{
// ...
public function canHaveComments(): bool
{
return true;
}
}
class Video extends Model
{
use Entryable;
public function canHaveComments(): bool
{
return $this->allow_comments;
}
}
在这个例子中,如果一个视频的 allow_comments 标志被关闭,我们就禁用了它的评论功能。这个标志会存储在 videos 表中,而不是 entries 表中。换句话说,我们正在委托给 Entryable,如果愿意,我们可以在那里覆盖行为。当然,这只是一个例子,如果能够禁用评论是所有 Entryable 共享的功能,那么这些数据也可以移到 Entry 模型的表中。
我们现在可以更轻松地在整个系统中添加共享行为。例如,如果我们要构建一个 Reactions 功能,我们可以在 Entry 上实现它,并为所有 Entryable 资源启用它。另一个例子是“稍后提醒我”功能,也可以在 Entry 资源层面实现,依此类推。
2. 更丰富的层次结构
使用 Active Record 时,我们习惯于用关系型数据库的方式思考,比如“一篇博文有多个评论”。这很好很简单,我们都喜欢简单。但是,有时 真实世界™ 比这更复杂。如果一篇博文既有面向客户端的评论,也有内部笔记,并且对于某些用户来说,所有这些都应该列在同一个 feed 中呢?或者,如果一条评论也有评论,像线程一样呢?
使用委托类型,我们可以通过给 Entry 模型添加一个 nullable 的 parent_id 属性来使其变得递归!这样,我们就可以表示更复杂的层次结构。例如,我们可以将我们的 Comment 模型也变成一个 Entryable:
class Entry extends Model
{
public function entryable(): MorphTo
{
return $this->morphTo();
}
public function parent(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
public function children(): HasMany
{
return $this->hasMany(Entry::class, 'parent_id');
}
}
trait Entryable
{
public function entry(): MorphOne
{
return $this->morphOne(Entry::class, 'entryable');
}
}
class Post extends Model
{
use Entryable;
}
class Video extends Model
{
use Entryable;
}
class Comment extends Model
{
use Entryable;
}
我们的数据库结构现在看起来像这样:
entries
id
entryable_id # 相关模型的 ID
entryable_type # 相关模型的类型
parent_id # 父级 entry 引用(可为空)
# ...
videos
id
source_path
# ...
posts
id
title
content
# ...
comments
id
content
# ...
这开启了一系列新的交互方式,为我们的模型嵌套方式提供了更大的灵活性。例如,我们可以在任何东西上(甚至是其他评论上)添加评论,并且可以在同一个层次级别的同一个 Entry 下混合不同类型的 Entryable 作为子级。我知道这听起来很复杂,但我来展示一下我的意思。
例如,在邮件应用中,我们可以将邮件分组到不同的文件夹/邮箱中。在 HEY 中,他们表示这个概念模型叫做 Box,我们可以为它预定义一些记录:Imbox(不是拼写错误)、Feed、Papertrail 等等。
同一封邮件的邮件和回复也被分组在一起,形成了一组邮件的单个 thread。在 HEY 中,他们似乎称邮件线程为 Topic(我是根据 URL 猜测的,类似于 https://app.hey.com/topics/123123,显示该线程的所有邮件)。
然而,在 Hey 中,嵌套在 Topic 中的模型不仅仅是邮件。一个商业账户可能有更多的人处理同一个邮件账户。为了帮助团队协调对特定邮件的回复,他们可以给彼此留下私人笔记。与发送给线程中所有邮件收件人的电子邮件不同,私人笔记仅供内部使用,它们永远不会作为邮件离开应用,只有商业账户的团队成员可以看到。
这些笔记与实际的邮件消息并排显示在同一个 topic 中。以下是我从他们的 Play Store 获得的一张营销图片中它们的样子(你可以看到图片中私人笔记有蓝色背景):

如果我们用委托类型来构建它,我们的数据库结构可能是这样的:
boxes
id
name
# ...
entries
id
box_id # 此 entry 所属的 box
entryable_id # 相关模型的 ID
entryable_type # 相关模型的类型
parent_id # 父级 entry(可为空)
# ...
topics
id
name # 主题标题
# ...
messages
id
subject
content
# ...
notes
id
content
# ...
注意,我们的层次结构主要在 Entry 模型中实现,而我们的实际领域模型保持精简——它们只关心自己的实际数据属性,其他什么都不管。然后,我们的 Eloquent 模型看起来像这样:
class Box extends Model
{
public function entries(): HasMany
{
return $this->hasMany(Entry::class);
}
public function topics(): HasMany
{
return $this->entries()->topics();
}
}
class Entry extends Model
{
#[Scope]
public function topics(Builder $query): void
{
$query->where('entryable_type', (new Topic)->getMorphClass());
}
public function box(): BelongsTo
{
return $this->belongsTo(Box::class);
}
public function entryable(): MorphTo
{
return $this->morphTo();
}
public function parent(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
public function children(): HasMany
{
return $this->hasMany(Entry::class, 'parent_id');
}
}
trait Entryable
{
public function entry(): MorphOne
{
return $this->morphOne(Entry::class, 'entryable');
}
}
class Topic extends Model
{
use Entryable;
}
class Message extends Model
{
use Entryable;
}
class Note extends Model
{
use Entryable;
}
这里的 Box 模型就像一个“entry 的桶”。所有 entry 都属于一个单独的 Box,无论它们在层次结构中有多深。在应用中,我们可以强制执行 Topics 始终是 box 中第一级的 entry。而在一个 Topic 内部,我们可以有 Message 和 Note entry。
我们可以添加 affordances 来帮助导航这个 图。例如,Box 模型中的 topics 关系可以只过滤 Entryable 是 Topic 的 entry:
$box->topics # [Entry<Topic>, Entry<Topic>, ...]
$box->entries # [Entry<Topic>, Entry<Message>, Entry<Note>, Entry<Topic>, ...]
而且,由于 Entry 是递归的(意味着我们可以在它们上面嵌套 entry),我们可以使用 Topic 的 Entry 的 children 关系来列出其下的所有消息和笔记:
$topic->entry->children # [Entry<Message>, Entry<Note>, ...]
这样,我们就可以构建更丰富的功能。我们只需要确保以相同的方式创建 Entry。例如,当我们在一个 Topic 上创建 Notes 时,URL 可能是这样的:
POST /topics/{topic}/notes
但我们传递给它的应该是 Topic 的 Entry 的 ID,而不是 Topic 的 ID:
<form action="{{ route('topics.notes.store', ['topic' => $topic->entry]) }}" method="post">
<!-- ... -->
</form>
然后,在我们的 TopicNotesController@store 动作中,我们会为 $topic 资源类型提示 Entry 模型,这样路由模型绑定就能按预期工作。然后,我们会为 Note 创建一个新的 Entry,一旦创建,它就会与根 Box 实例关联。我们可以从 Topic 的 Entry::box() 关系中获取它:
class TopicNotesController
{
use AuthorizesRequests;
public function store(Request $request, Entry $topic)
{
$this->authorize($topic, 'addNotes');
$topic->box->createEntry(
entryable: $this->makeNote($request),
parent: $topic,
);
return back(fallback: route('topics.show', $topic))
->with('notice', __('笔记已创建。'));
}
private function makeNote(Request $request): Note
{
return Note::make($request->validate([
'body' => ['required'],
]));
}
}
class Box extends Model
{
// ...
public function createEntry(Model $entryable, ?Entry $parent = null): Entry
{
return DB::transaction(function () use ($entryable, $parent) {
return $this->entries()->create([
'entryable' => tap($entryable)->saveOrFail(),
'parent' => $parent,
]);
});
}
}
注意,在 store() 动作方法中,我们为 $topic 变量类型提示了 Entry 模型。这是因为,在这个例子中,我仍然想把资源命名为“topics”,路由会定义为:
Route::resource('topics.notes', TopicNotesController::class)
->only(['store']);
这将产生如下路由:
POST /topics/{topic}/notes name: topics.notes.store
将关系设置为属性
顺便提一下,你可能已经注意到,这里我们将 entryable 和 parent 关系作为模型的属性传递。我希望这在 Laravel 中是原生的,但可惜不是(至少在撰写本文时不是)。然而,我们可以通过修改器来实现:
class Entry extends Model
{
protected $fillable = [
// ...
'entryable',
'parent',
];
// ...
public function setEntryableAttribute($entryable): void
{
$this->entryable()->associate($entryable);
}
public function setParentAttribute($parent): void
{
$this->parent()->associate($parent);
}
}
好吧,这只是个题外话。让我们回到委托类型。
在 Basecamp,他们的丰富层次结构似乎已经拉满了。Project 里面的几乎所有东西都是一个 Recording(他们的 Entry 模型)。而且似乎也存在着完全相同的递归。查看他们关于 recordings 的这篇文章以了解更多。
在我们的例子中,我们的数据库模式从看起来像这样的:

……变成了像这样的:

我们本质上是在将我们的关系型数据库当作一个图来使用:

与软件中的任何事情一样,这是有取舍的:事情比以前更抽象了一些。然而,这提供的灵活性是有帮助的。尤其是在接下来的两个主题中。
3. 不可变性与历史记录
在演讲“值的价值”中,Rich Hickey(Clojure 的创建者)谈到我们正在构建的信息系统只关心事物的“当前状态”。他称之为“面向位置”。他声称,信息系统必须能够保留所有先前状态的历史信息才能有用。
这时人们通常会转向事件溯源。但委托类型在这里也能帮上忙。为了能够保留过去的信息,我们只需要几样东西:
- 将我们的“编辑”变成系统中的一等公民
- 只暴露 Entry ID,永远不暴露底层的 Entryable 资源 ID(我们在前面的例子中已经看到了实例)
有了这两个约束条件,我们可以将我们的 Entry 模型视为指向其所代表的 Entryable 当前状态的指针。这样,与其更新 Entryable 模型的状态,不如创建一个新的,并更新 Entry 对当前 Entryable 的引用,使其指向新的记录!
对于这个例子,让我们继续使用上一节中的邮箱示例。以下是 TopicNotesController@update 动作的可能样子:
class TopicNotesController
{
// ...
public function update(Request $request, Entry $note)
{
$this->authorize('update', $note);
$note->edit(entryable: $this->makeNote($request));
return to_route('topics.show', $note->parent);
}
}
class Entry extends Model
{
use Editable;
// ...
}
trait Editable
{
public function edits(): HasMany
{
return $this->hasMany(Edit::class);
}
public function edit(Model $entryable, ?User $user = null): static
{
$user ??= auth()->user();
return DB::transaction(function ($entryable, $user) {
$this->edits()->create([
'entryable' => $this->entryable,
'user' => $user,
]);
$this->update([
'entryable' => tap($entryable)->saveOrFail(),
]);
return $this;
});
}
}
class Edit extends Model
{
protected $guarded = [];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function entryable(): MorphTo
{
return $this->morphTo();
}
public function setEntryableAttribute($entryable): void
{
$this->entryable()->associate($entryable);
}
public function setUserAttribute($user): void
{
if ($user) {
$this->user()->associate($user);
} else {
$this->user()->dissociate();
}
}
}
在我们更新 Entry::entryable 关系以指向 Entryable 的当前状态之前,我们确保创建一个新的 Edit 记录,该记录指向上一个状态的 Entryable。
现在,我们可以通过 edits() 关系检索任何 Entry 的先前版本:
// 始终指向当前状态...
$entry->entryable # Note{id: 123, content: ...}
// 检索历史编辑...
$entry->edits # [Edit{entryable: Note{id: 1}}, Edit{entryable: Note{id: 2}}, ...]
这就是我所说的“Entry 模型作为一个指针工作”的含义。我们的 Entry 模型可以访问其所代表的 Entryable 资源的每一次迭代的历史。这会在数据库存储方面花费更多,但如果你需要能够保留数据的不可变历史信息,这是值得的。
话虽如此,为 Entryable 记录的每次更改都创建一个编辑记录可能太多了。例如,如果我们有一个“自动保存”功能,每隔几毫秒就有一次小改动,那么它就会创建一整条新记录。例如,在 Writebook 中,10 分钟时间窗口内的所有更改都会更新最新的 Entryable,而不是创建一个新的。我们可以根据需要调整行为。
结论
正如我所说,委托类型是我在使用 Active Record ORM 的 Web 应用中最喜欢的模式之一。希望你能看到它与单表继承和常规多态关联等其他技术的不同之处。我也希望你能看到它如何启用使用 Active Record ORM 的新方式:委托类型为你提供了更大的层次结构、数据不可变性和历史信息——所有这些都不需要你求助于图数据库或事件溯源。
这些技术并不相互排斥。在文章 Active Record, nice and blended 中,Jorge Manrubia 展示了他们在 HEY 和 Basecamp 中如何结合使用所有这些模式。那里解释的一切也适用于 Laravel 应用。
希望你喜欢这篇文章!如果你想提问、分享你使用委托类型的方式,或者只是想打个招呼,请在 Twitter @TightenCo 给我们留言。👋
译者总结
委托类型已在 HEY、Writebook 等产品中经过充分验证,收益显著。它不仅将原本复杂的多态关联简化为统一的一对多关系,更通过引入抽象层(Entry)让共享行为(如评论、喜欢、收藏)、复杂层次结构和历史版本管理变得自然统一,大幅提升了系统的可扩展性与可维护性。
在这种数据结构上,如果要添加一种新的类型内容,只需创建一个新的 Entryable 模型并关联到 Entry,而不必修改扩充现有的数据库表结构和业务逻辑。这种模式非常适合多类型内容或需要频繁添加新类型内容的系统,且能保持代码和数据结构的清晰与一致。
相关链接
- Tighten:委托类型简介
- Tony Messias 作者页面
- 37signals 官网
- ONCE Writebook
- ONCE 官网
- 37signals 博客:Rails 委托类型模式
- Basecamp ONCE Campfire GitHub
- Tighten Parental 包
- Laravel Eloquent 关系文档
- Adam Wathan Twitter
- Tailwind CSS
- Adam Wathan:将多态推送到数据库
- Rails PR:委托类型
- HEY 官网
- HEY:Imbox 功能
- HEY:线程笔记功能
- HEY Google Play Store
- Rich Hickey:值的价值演讲
- Martin Fowler:事件溯源
- 37signals 博客:Active Record, nice and blended
- Jorge Manrubia 个人网站
- Tighten Twitter