编程

Repositories 及其真实意图

199 2024-02-16 01:39:00

由于使用了 Repository 模式,你已经替换了多少次底层数据库实现?

这就是为什么,在这篇博文中,我想进一步澄清这个完全被误解的软件设计模式,以及为什么反对使用它的第一个论点(上面的问题)实际上是微不足道的,几乎无关紧要。

定义 Repository

首先,让我们从定义 Repository 实际是什么开始。Repository 模式在 PoEAA 中定义如下:

使用类似集合接口的在域和数据映射层之间的中介,用于访问域对象。

至关重要的是,在进入其他章节之前,我们要确定以下事实。

(...) 访问域对象

域对象(Domain object) 是域层中的参与者,它们拥有一组权威的业务能力来执行某些任务。这些能力或行为作为公共方法暴露在所述参与者身上,以便进行一致的状态更改。域对象在DDD术语中也称为写模型、实体或聚合。

到目前为止,你可能已经听过无数次“业务逻辑”的概念了。这些模型实际上决定了“业务逻辑”应该包含什么。

(...) 类似集合的接口 (...)

在理想的情况下,实体不需要持久层,因为所有东西都可以从内存中的集合中添加和删除。例如:

final class Users
{
    private array $users;

    private function __construct(User ...$users)
    {
        $this->users = $users;
    }

    public static function empty(): self
    {
        return new self();
    }

    public function add(User $user): void
    {
        $this->users[$user->id()->asString()] = $user;
    }

    public function find(UserId $id): User
    {
        return $this->users[$id->asString()] 
            ?? throw CouldNotFindUser::becauseItIsMissing();
    }

    public function remove(User $user): void
    {
        unset($this->users[$user->id()->asString()]);
    }
}

不幸的是,现实世界往往与理想世界大相径庭。PHP 有其著名的请求-响应生命周期,一旦处理了传入的请求并向客户端发送了响应,就会导致每一个相关上下文的丢失。Repository 通过给人一种幻像来帮助我们近似这个理想情况,即我们可以对似乎永远存在的内存中的集合执行操作。 Repository 示例可以是:

interface UserRepository
{
    public function find(UserId $id): User;
    public function save(User $user): void;
    public function remove(User $user): void;
}

请注意此接口的最小签名。

面向集合 vs. 面向持久

《大红皮书》(iDDD)的作者 Vaughn-Vernon 在第12章中提到了面向集合和面向持久性的 Repository 实现。我想简单地提到这个事实,因为这就是为什么你可能会看到不同“风格”的 Repository 实现的原因。区别主要在于语义上。

面向集合的设计可以被视为传统设计,因为它遵守内存中集合的标准接口。

$users->add($user);

面向持久的设计也称为基于存储的 Repository

$users->save($user);

就我个人而言,由于 PHP 的短暂性,我更喜欢面向持久。

授权集合

Repository  是用于与特定类型的实体进行交互的授权集合。它可以用于根据应用的需要保存、过滤、检索和删除实体。换句话说,我们将记住某个实体存在的任务委托给 Repository

示例解释:发布文章

让我们看一个用例来巩固我们的理解。

final readonly class PublishPostHandler
{
    public function __construct(
        private PostRepository $posts,
    ) {}

    public function handle(PublishPost $command): void
    {
        $post = $this->posts->find($command->id);
        
        $post->publish();
        
        $this->posts->save($post);
    }
}

该用例假定你已经有一个 Post 实体,要进行发布。由于 PostRepository 是处理这些 Post 实体的授权集合,我们可以要求它为我们提供给定 PostIdPost 实体:

$post = $this->posts->find($command->id);

当我们收到 Post 实例,我们继续执行最初应该执行的任务:

$post->publish();

publish 方法暴露了负责实际“博客文章”的行为。如果我们再深入一点,我们可以看到它也在强制执行关键的不变量:

public function publish(): void
{
    if ($this->isPublished()) {
        throw CouldNotPublish::becauseAlreadyPublished();
    } elseif ($this->summary->isEmpty()) {
        throw CouldNotPublish::becauseSummaryIsMissing();
    } elseif ($this->body->isEmpty()) {
        throw CouldNotPublish::becauseBodyIsMissing();
    } elseif ($this->tags->isEmpty()) {
        throw CouldNotPublish::becauseTagsAreMissing();
    }

    // omitted for brevity
}

如果一切顺利,我们继续前进,并告诉 PostRepository 记住当前状态下的 Post

$this->posts->save($post);

下次我们与 PostRepository 交互并请求完全相同的实体时,我们可以期望接收这个状态下的 PostPostRepository 将确保始终满足此条件。毕竟,这是 PostRepository  最重要的一项职责。PostRepository 清楚地定义了应用服务的边界,这也带来了很多好处,例如隔离的可测试性和有目的地使(核心)域不受周围环境的影响。

持久性不可知论

Persistence agnosticity

让我们快速回顾一下 Random Techfluencer 最初的描述:

由于使用了 Repository 模式,你已经替换了多少次底层数据库实现?

Random Techfluencer 实际上不鼓励使用 Repository ,因为“你要交换多少次数据源?”。

现在,请让我把一些事情说清楚。数据源的交换是一个微不足道的论点,无论你是使用它来促进还是阻碍 Repository 的使用。你属于哪个阵营并不重要。你真的想在设计域时考虑交换数据源吗?在我看来,这种想法是有缺陷的。

Now, please let me make something absolutely clear. The swapping of the data source is a puny argument whether you use it to promote or obstruct the use of the Repository. It does not matter which camp (pro / contra) you belong to. Do you really want to think about swapping out data sources as you are designing the domain? This kind of thinking is - in my humble opinion - flawed.

点对点持久化交换

事实上,你以后可以轻松地交换数据源,这只是一个额外的好处,因为你可以小心地在应用周围设置边界。这就是“软件设计 101 之边界”,每次都试图将其作为主要卖点,这没有任何好处。

你可以通过在磁盘上使用简单的 JSON 文件来启动应用,并随着不同需求的出现逐渐向“更强大”的解决方案发展。

final class UserRepositoryUsingJsonFilesOnDisk implements UserRepository
{
    public function add(User $user): void
    {
        // add a user
    }

    public function find(UserId $id): User
    {
        // find a user
    }

    public function remove(User $user): void
    {
        // remove a user... you get the point
    }
}

不同的功能可以相互独立地发展,并且可以将基础设施成本保持在最低限度。如果 90% 的其他功能都非常适合像 SQLite 这样的存储机制,为什么要使用昂贵的云托管解决方案?如果有 10% 的功能非常适合 Elastic 和 Riak,为什么要让每个功能都使用 MySQL?

可测试性

与持久化不可知论类似,可测试性是通过在应用周围小心设置边界而获得的另一个好处。真实的 情况可以继续使用 DoctrinePostRepository,而测试可以使用 InMemoryPostRepository 来进行快速测试。

前面提到的“发布博客文章”用例的测试可能如下所示:

// Arrange
$post = $this->aPost(['id' => PostId::fromInt($id = 123)]); // draft
$repository = $this->aPostRepository([$post]); // in-memory repository
$handler = new PublishPostHandler($repository);

// Act
$handler->handle(new PublishPost($id));

// Assert
$this->assertTrue($repository->wasSaved($post));
$this->assertTrue($post->isPublished());

本例中,我们将测试由命令 handler 表示的应用服务。我们不需要测试 Repository 是否将数据存储在数据库或其他任何位置。我们需要测试 handler 的具体行为,即发布 Post 对象并将其传递到存储库中以保持其状态。

“这没什么大不了的”,你可以理直气壮地说,“我每次都可以在测试中命中持久化”。我不确定你是否知道有人参与过一个项目,他的测试套件因为花了太长时间才完成整个项目而被完全关闭?我确实知道这么一个人,并且不幸的是,那个人就是我。集成和系统/E2E 测试肯定有自己的位置,但单元测试的纯粹速度和快速反馈回路仍然是非常可取的。

缓解性能问题

性能是经常使用 Repository 的另一个原因。拥有数百万个特定实体类型的实例并不罕见,因此我们被迫将其卸载到外部数据存储中。

假设以下摘录自一个假想的 User 实体:

public function changeEmail(Email $newEmail, Users $allUsers)
{
    if ($allUsers->findByEmail($newEmail)) {
        throw new CannotChangeEmail::becauseEmailIsAlreadyTaken();
    }
    
    $this->email = $newEmail;
}

changeEmail 行为取决于 Users 集合,以确定是否可以使用新的电子邮件地址。(想象中的)域专家告诉我们,只要有另一个用户拥有新的电子邮件地址,电子邮件更改就可能不会发生。

在我们达到一定数量的用户之前,此代码将正常工作。集合的绝对大小将成为查询瓶颈。我们可以通过注入 UserRepository 来解决这个问题,而不是通过内存中的 Users 集合传递给已存在的每个 User

public function changeEmail(Email $newEmail, UserRepository $users)
{
    if ($users->findByEmail($newEmail)) {
        throw new CannotChangeEmail::becauseEmailIsAlreadyTaken();
    }
    
    $this->email = $newEmail;
}

通过这种方式,域模型仍将负责强制执行不变量;但我们不得不权衡领域模型的纯度与性能。尽管如此,这绝对是一种可以接受的权衡。

命令查询职责分离(CQRS)

我以为这篇博文是关于 Repository  模式的?CQRS 突然怎么乱入了……?”请让我解释一下。

写模型(命令)

目前为止,我们已经看到了 Repository 如何帮我们处理域对象的生命周期。我们确定了这样一个事实,即这些域对象也称为写模型/实体/聚合,负责以一致的方式执行状态更改。换言之,聚合代表了一个一致性边界,必须遵循业务规则并始终应用这些规则才能保持一致。当然,这些状态变化总是由于命令进入应用程序而发生的。

读模型(查询)

我们需要问问自己,我们是真的需要执行状态修改,还是只需要一些数据。为什么我们“只需要一些数据”?好你猜对了:用于查询。CQRS 是一种非常简单的模式,用于分离读写问题的逻辑模型——仅此而已。它与事件来源/最终一致性/分离的数据存储等无关。这些流行语经常被那些不知道自己在说什么的人抛到一起。涉及查询的用例将受益于更好的优化、专用的读取模型

示例解释:显示发票表格

让我们看一个用例来巩固我们的理解。

final readonly class ViewInvoicesController
{
    public function __construct(
        private GetMyInvoices $query,
        private Factory $view,
    ) {}

    public function __invoke(Request $request): View
    {
        $invoices = $this->query->get();

        return $this->view->make('view-invoices', [
            'invoices' => $invoices,
        ]);
    }
}

此用例负责向用户显示发票表。所有的魔法都发生在这一行:

$invoices = $this->query->get();

GetMyInvoices 查询 handler 提供给我们专用于此目的的 InvocieSummary 读取模型。单个 InvoiceSummary 实例可能像这样:

final readonly class InvoiceSummary
{
    public function __construct(
        public int $amountOfDiscountsApplied,
        public string $paymentTerms,
        public string $recipient,
        public int $totalAmountInCents,
    ) {}
}

眼尖的读者可能已经注意到,这实际上是一个 Data Transfer Object。DTO 通常只包含数据而没有行为。不过,这正是我们需要的:一个专门用于向用户显示一些相关数据的读取模型。你可能已经注意到,此模型不包含任何有关单个发票行项目的信息;这完全是故意的!表格视图无法显示单个发票行项目。因此,读取模型针对这个确切的用例进行了优化和精心制作。

写模型可能长这样:

final readonly class LineItem
{
    public __construct(private bool $isDiscount) {}

    public function isDiscount(): bool
    {
        return $this->isDiscount;
    }
}

final class Invoice
{
    private RecipientName $recipientName;
	
    private LineItems $lineItems;

    public function __construct(
        RecipientName $recipientName
    ) {
        $this->recipientName = $recipientName;
        $this->lineItems = LineItems::empty();
    }

    public function addLineItem($item): void
    {
        if (
            $item->isDiscount()
            && $this->lineItems->hasDiscountedItem()
        ) {
            throw CannotAddLineItem::multipleDiscountsForbidden($item);
        }

        $this->lineItems->add($item);
    }
}

因此,更准确地说,我们直接访问了数据源本身,而不是试图将一个用例硬塞进一个发票写模型中,该模型完全不是为实现专门的基于查询的读取用例而设计的。为什么要承担实例化这个复杂的写模型的负担,以实现一个甚至不需要这个写模型中定义的任何行项目的用例?写入模型需要所有行项目以保持其状态一致,但读取模型不需要。

Repository 属于哪里:应用层还是域层?

我们可以将应用层视为多层体系结构中的特定层,处理应用特有的实现细节,如数据库持久化、互联网协议知识(发送电子邮件、API 交互)等。现在,让我们将域层建立为主要处理业务规则和业务逻辑的多层体系结构中的层。

有了这些定义,我们的 Repository 到底在哪里适合呢?让我们重新审视我们前面讨论的源代码示例的变体:

final class InMemoryUserRepository implements UserRepository
{
    private array $users = [];

    public function find(UserId $id): User
    {
        return $this->users[$id->asString()]
            ?? throw CouldNotFindUser::becauseItIsMissing();
    }

    public function remove(User $user): void
    {
        unset($this->users[$user->id()->asString()]);
    }

    public function save(User $user): void
    {
        $this->users[$user->id()->asString()] = $user;
    }
}

我观察到许多可以被视为“噪声”的实现细节。因此,这个实现细节属于应用层。让我们去除这些噪音,看看我们剩下的是什么:

final class InMemoryUserRepository implements UserRepository
{
    private array $users = [];

    public function find(UserId $id): User
    {
    }

    public function remove(User $user): void
    {
    }

    public function save(User $user): void
    {
    }
}

这让你想起了什么?比如这个?

interface UserRepository
{
    public function find(UserId $id): User;
    public function save(User $user): void;
    public function remove(User $user): void;
}

将接口放置在层边界需要以下含义:虽然接口本身可以包含特定领域的概念,但它的实现不应该包含。在 repository 接口的上下文中,它们属于域层。repository 的实现属于应用层。因此,我们可以在域层内自由地使用 repository 的类型提示,而不需要依赖于应用层。

各种其他好处

以下是 Repository 可以带来的各种其他好处的非详尽列表:

  • 访问 decorator 模式以添加额外的关注点,而不必修改域,例如为类似 YouTube 的标识符使用类似哈希的东西。
  • 为关键任务、事件驱动的系统实现事务发件箱模式的能力。
  • 如果应用主要依赖于数据模型并且你希望迁移出去,则中心化访问/持久化逻辑。
  • 在持久化实体时自动添加审核信息。

...

小结

基本上,如果我们列举使用 Repository 的所有好处,那么持久化不可知论肯定会排在最后,或者至少接近最后。因此,我希望可以停止从表面上看概念,而是更深入地研究它们,以挖掘实际的用例和应该使用它们的上下文

  • Repository是安全收集和保存实体以及管理其生命周期的权威参与者
  • 底层交换持久化驱动的能力只是一个额外的好处

在没有实际持久化驱动的情况下轻松测试的能力只是一个额外的好处

请让写入模型使用 Repository 

不要将 Repository 用于读取模型:请转到数据源

感谢阅读!如有任何想法,欢迎留言交流。