PHP 事件溯源

本文转载自【何以解耦】:codedecoupled.com/php-es.html

PHP 事件溯源

事件溯源(Event Sourcing)是领域驱动设计(Domain Driven Design)设计思想中的架构模式之一。领域驱动设计是面向业务的一种建模方式。它帮助开发者建立更贴近业务的模型。

在传统的应用程序中,我们将状态储存在数据库中,当状态发生改变时,我们即时更新数据库中相对应的状态值。事件溯源则采用一种截然不同的模式,它的核心是事件,所有的状态都来源于事件,我们通过播放事件来获取应用中的状态,所以它叫事件溯源。

在本文中,我们将运用事件溯源模式编写一个简化的购物车,以此分解事件溯源的几个重要组成概念。我们也将使用 Spatie 的事件溯源库来避免重复造轮。

在我们的案例中,用户可以添加,删除以及查看购物车内容,同时它具备两个业务逻辑:

  • 购物车不可添加超过 3 种产品。
  • 当用户添加第 4 种产品时,系统将自动发出一个预警邮件。

要求以及声明

  • 本文使用 Laravel 框架。
  • 本文使用特定版本 spatie/laravel-event-sourcing:4.9.0 以避免不同版本之间的语法问题。
  • 本文并非手把手的分步教程,你必须有一定 Laravel 基础才可以理解本文,请避免咬文嚼字,关注架构模式的组成结构。
  • 本文的重点是阐述事件溯源的核心思想,此库中对事件溯源的实现方式并非唯一方案。

领域事件(Domain Event)

事件溯源中的事件被称为领域事件,与传统的事务事件不同,它有以下几个特点:

  • 它与业务息息相关,所以它的命名往往夹带业务名词,而不应该与数据库挂钩。比如购物车增添商品,对应的领域事件应该是 ProductAddedToCart, 而不是 CartUpdated
  • 它是指发生过的事情,所以它一定是过去式,比如 ProductAddedToCart 而不是 ProductAddToCart
  • 领域事件只可追加,不可以删除或者更改,如果需要删除,我们需要使用具备删除效果的领域事件,比如 ProductRemovedFromCart

根据以上信息,我们构建三种领域事件:

  • ProductAddedToCart:
<?php
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class ProductAddedToCart extends ShouldBeStored
{
    public int $productId;

    public int $amount;

    public function __construct(int $productId, int $amount)
    {
        $this->productId = $productId;
        $this->amount = $amount;
    }

}
  • ProductRemovedFromCart:
<?php
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class ProductRemovedFromCart extends ShouldBeStored
{
    public int $productId;

    public function __construct(int $productId)
    {
        $this->productId = $productId;
    }

}
  • CartCapacityExceeded:
<?php
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class CartCapacityExceeded extends ShouldBeStored
{
    public array $currentProducts;

    public function __construct(array $currentProducts)
    {
        $this->currentProducts = $currentProducts;
    }

}

事件 ProductAddedToCartProductRemovedFromCart 分别代表商品加入购物车以及被从购物车中移除,事件 CartCapacityExceeded 代表购物车中商品超标,这是我们前面提到的业务逻辑之一。

聚合(Aggregate)

在领域驱动设计中,聚合(Aggregate)是指一组紧密相关的类,他们自成一体形成一个有边界的组织,边界外部的对象只可以通过聚合根(Aggregate Root)与此聚合交互,聚合根是聚合中的一种特殊的类。我们可以将聚合想象中一个家庭户口本,对此户口本进行任何操作,都必须通过户主(聚合根)。

聚合具有以下几个特点:

  • 它确保核心业务的不变性。也就是说我们在聚合做验证,对违反业务逻辑的操作抛出异常。
  • 它是领域事件的产生地。领域事件在聚合根中产生。也就是说我们可在领域事件已完成业务要求。
  • 它自成一体,具有明显的边界,也就是说,只能通过聚合根调用聚合中的方法。

聚合是服务于业务逻辑的主要以及最直接的部分,我们使用它直观地为我们的业务建立模型。

综上所述,让我们构建一个 CartAggregateRoot 聚合根:

<?php

use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class CartAggregateRoot extends AggregateRoot
{
    public function addItem(int $productId, int $amount)
    {
    }

    public function removeItem(int $productId)
    {
    }

}

CartAggregateRoot 具备两个方法 addItemremoveItem,分别代表添加以及移除商品。

另外我们还需要加些属性来记录购物车内容:

<?php

use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class CartAggregateRoot extends AggregateRoot
{
    private array $products;

    public function addItem(int $productId, int $amount)
    {
    }

    public function removeItem(int $productId)
    {
    }

}

private array $products; 将记录购物车中的商品,那么我们什么时候可以为其赋值呢?在事件溯源中,这是在事件发生以后,所以我们首先需要发布领域事件:

<?php

use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class CartAggregateRoot extends AggregateRoot
{
    private array $products;

    public function addItem(int $productId, int $amount)
    {
        $this->recordThat(
            new ProductAddedToCart($productId, $amount)
        );
    }

    public function removeItem(int $productId)
    {
        $this->recordThat(
            new ProductRemovedFromCart($productId)
        );
    }

}

在调用 addItemremoveItem 事件时,我们分别发布 ProductAddedToCartProductRemovedFromCart 事件,与此同时,我们通过 apply 魔术方法为 $products 赋值:

<?php

use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class CartAggregateRoot extends AggregateRoot
{
    private array $products;

    public function addItem(int $productId, int $amount)
    {
        $this->recordThat(
            new ProductAddedToCart($productId, $amount)
        );
    }

    public function removeItem(int $productId)
    {
        $this->recordThat(
            new ProductRemovedFromCart($productId)
        );
    }

    public function applyProductAddedToCart(ProductAddedToCart $event)
    {
        $this->products[] = $event->productId;
    }

    public function applyProductRemovedFromCart(ProductRemovedFromCart $event)
    {
        $this->products[] = array_filter($this->products, function ($productId) use ($event) {
            return $productId !== $event->productId;
        });
    }
}

apply* 是 Spatie 的事件溯源库自带的魔术方法,当我们使用 recordThat 发布事件时,apply* 会被自动调用,它确保状态的改动是在事件发布以后。

现在 CartAggregateRoot 已通过事件获取了需要的状态,现在我们可以加入第一条业务逻辑:购物车不可添加超过 3 种产品。

修改 CartAggregateRoot::addItem,当用户添加第 4 种产品时,发布相关领域事件 CartCapacityExceeded

public function addItem(int $productId, int $amount)
{
    if (count($this->products) >= 3) {

        $this->recordThat(
            new CartCapacityExceeded($this->products)
        );

        return;
    }

    $this->recordThat(
        new ProductAddedToCart($productId, $amount)
    );
}

现在我们已经完成了聚合根工作,虽然代码很简单,但是根据模拟业务而建立的模型非常直观。

加入商品时,我们调用:

CartAggregateRoot::retrieve(Uuid::uuid4())->addItem(1, 100);

移除商品时,我们调用:

CartAggregateRoot::retrieve($uuid)->removeItem(1);

放映机(Projector)

UI 界面是应用中不可缺少的部分,比如向用户展示购物车中的内容,通过重播聚合根或许会有性能问题。此时我们可以使用放映机(Projector)。

放映机实时监控领域事件,我们通过它可以建立服务于 UI 的数据库表。放映机的特点是它可以重塑,当我们发现代码中的 bug 影响到 UI 数据时,我们可以重塑此放映机建立的表单。

让我们写一个服务于用户的放映机 CartProjector

<?php


use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

class CartProjector extends Projector
{
    public function onProductAddedToCart(ProductAddedToCart $event)
    {
        $projection = new ProjectionCart();
        $projection->product_id = $event->productId;
        $projection->saveOrFail();
    }

    public function onProductRemovedFromCart(ProductRemovedFromCart $event)
    {
        ProjectionCart::where('product_id', $event->productId)->delete();
    }

}

放映机 CartProjector 会根据监听的事件来增加或者删除表单 projection_cartsProjectionCart 是一个普通的 Laravel 模型,我们仅使用它来操作数据库。

当我们的 UI 需要展示购物车中的内容时,我们从 projection_carts 读取数据,这和读写分离有异曲同工之妙。

反应机(Reactor)

反应机(Reactor)和放映机一样,实时监控领域事件。不同的是反应机不可以重塑,它的用途是用来执行带有副作用的操作,所以它不可以重塑。

我们使用它来实现我们的第二个业务逻辑:当用户添加第 4 个产品时,系统将自动发出一个预警邮件。

<?php


use Spatie\EventSourcing\EventHandlers\Reactors\Reactor;

class WarningReactor extends Reactor
{
    public function onCartCapacityExceeded(CartCapacityExceeded $event)
    {
        Mail::to('admin@corporation.com')->send(new CartWarning());
    }
}

反应机 WarningReactor 会监听到事件 CartCapacityExceeded, 我们就会使用 Laravel Mailable 发送一封警报邮件。

总结

至此我们简单的介绍了事件溯源的几个组成部分。软件的初衷是运用我们熟悉的编程语言来解决复杂的业务问题。为了解决现实中的业务问题,大神们发明了面向对象编程(OOP),于是我们可以避免写出面条代码,可以建立最贴近现实的模型。但是由于某种原因, ORM 的出现让大多数开发者的模型停留在了数据库层面,模型不应该是对数据库表的封装,而是对业务的封装。面向对象编程赋予我们的是对业务对象更精确的建模能力。数据库的设计,数据的操作并不是软件关注的核心,业务才是。

在软件设计中,我们应该忘记数据库设计,将注意力放到业务上面。

本文转载自【何以解耦】: codedecoupled.com/php-es.html ,如果你也对 TDD,DDD以及简洁代码感兴趣,欢迎关注公众号【何以解耦】,一起探索软件开发之道。

ddd
本作品采用《CC 协议》,转载必须注明作者和本文链接
Know how, know why meanwhile.
xuding
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 2

666

4年前 评论
xuding (楼主) 4年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!