委托类型简介

来源: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 模型。聊天室可以是 opencloseddirect。他们使用 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 之间创建了一个实体,称之为 Entryentryable() 这个 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 模型添加一个 nullableparent_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 内部,我们可以有 MessageNote 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),我们可以使用 TopicEntrychildren 关系来列出其下的所有消息和笔记:

$topic->entry->children # [Entry<Message>, Entry<Note>, ...]

这样,我们就可以构建更丰富的功能。我们只需要确保以相同的方式创建 Entry。例如,当我们在一个 Topic 上创建 Notes 时,URL 可能是这样的:

POST /topics/{topic}/notes

但我们传递给它的应该是 TopicEntry 的 ID,而不是 Topic 的 ID:

<form action="{{ route('topics.notes.store', ['topic' => $topic->entry]) }}" method="post">
    <!-- ... -->
</form>

然后,在我们的 TopicNotesController@store 动作中,我们会为 $topic 资源类型提示 Entry 模型,这样路由模型绑定就能按预期工作。然后,我们会为 Note 创建一个新的 Entry,一旦创建,它就会与根 Box 实例关联。我们可以从 TopicEntry::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

将关系设置为属性

顺便提一下,你可能已经注意到,这里我们将 entryableparent 关系作为模型的属性传递。我希望这在 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,而不必修改扩充现有的数据库表结构和业务逻辑。这种模式非常适合多类型内容或需要频繁添加新类型内容的系统,且能保持代码和数据结构的清晰与一致。

相关链接