如何设计一款大型 Laravel 应用程序的架构(下)?

发布于:2020 年 6 月 2 日 16:46

如何设计一款大型Laravel应用程序的架构(下)?

如何设计一款大型Laravel应用程序的架构(下)?

第一篇——《如何设计一款大型 Laravel 应用程序的架构(上)?

本文最初发布于 Freek.dev 博客,经原作者授权由 InfoQ 中文站翻译并分享。

我与同事 Brent 一起,负责设计一款大型 Laravel 应用程序的架构。我们将这款应用程序分为传统部分和事件溯源部分。在这篇文章中,我想举一个实际例子来说明我们如何实现这一目标。

这是本系列文章中的第二篇。阅读本文前,请先阅读第一部分。此外,你还应该对事件溯源(event sourcing)、聚合和 projector 的作用有很好的理解。这篇介绍可以帮助你快速上手。

设置阶段

首先,我们快速回顾一下 Brent 的文章。我们的应用程序需要事件溯源。在应用的某些部分,我们需要根据过去的信息做出决策;并且随着时间的推移,我们需要能够生成报告,而无需事先了解这些报告中应该有什么内容。

这些问题可以通过事件溯源来解决。我们只存储发生在应用程序中的所有事件,因此我们可以使用聚合(aggregate)根,这些根可以根据事件中的过去信息来做出决策。我们可以创建 projector 并向其回放所有记录的事件,从而生成新报告。

一位名叫 Frank de Jonge 的聪明人曾说过:“事件溯源会让困难的部分变得简单,而让简单的部分变困难”,这说得很对。设置事件溯源需要花费一些时间,而且没那么容易。当你想做一些简单的事情时,还会遇到一些额外的开销。

在我们的应用程序中,我们还有一些相当粗糙的部分,它们不需要事件溯源的能力。在单个应用程序中同时获得两种方法的好处不是很好吗?我和 Brent 研究了这个主题,并结对编程做了一些实验。我们想分享其中一次实验的情况。

一个实际的例子

我们将实现 Brent 文章中提到的两个部分:产品和订单。Product 是很无聊的部分。我们在这里不需要历史记录或报告。对于 Order 部分,我们将使用事件溯源。

你可以在 GitHub 的这个仓库中找到示例的源代码。请记住,这是一个非常简单的示例。在实际应用中所涉及的逻辑可能会更复杂。在这个实验中,我们只是专注研究这样的部分应该 / 将要如何相互通信。

非事件溯源的部分

查看 Context 目录,你会看到两个部分:Order 和 Product。我们先看一下“Product”:

如何设计一款大型Laravel应用程序的架构(下)?

如你所见,这部分非常简单:这里只有模型和事件。这里的想法是,我们可以像以往那样构建它,不会有太多开销。我们唯一要做的就是在发生某些更改时触发特定事件,以便应用的其他部分能参与其中。

在 Product 模型中,你将看到以下代码

复制代码
protected $dispatchesEvents = [
'created' => ProductCreatedEvent::class,
'updated' => ProductUpdatedEvent::class,
'deleting' => DeletingProductEvent::class,
'deleted' => ProductDeletedEvent::class,
];

$dispatchesEvents中,你可以定义当模型发生某些事情时应触发的事件。这是标准的 Laravel 功能。当然,我们可以使用原生触发的 Eloquent 事件,但这里我们想要使用自己的事件类,这样才能明确说明问题。

让我们看一下其中一个事件

复制代码
namespace App\Context\Product\Events;
use App\Context\Product\Models\Product;
class ProductCreatedEvent
{
public Product $product;
public function __construct(Product $product)
{
$this->product = $product;
}
}

如你所见,这里没有什么特别的事情。我们只是将模型用作事件属性。

事件溯源部分

现在,我们转到应用的 Order 部分,该部分是事件溯源的。它的结构如下所示:

如何设计一款大型Laravel应用程序的架构(下)?

你会在此处看到 projector 和聚合根,因此很明显,这部分使用了事件溯源。我们正在使用我们自己的事件溯源包来处理有关存储事件、projector、聚合根……的逻辑。

在应用的 Product 部分,我们触发了事件。这些事件只是常规的 Laravel 事件,我们没有存储它们。ProductEventSubscriber正在侦听这些事件。看一下代码。

复制代码
namespace App\Context\Order\Subscribers;
use App\Context\Product\Events\DeletingProductEvent as AdminDeletingProductEvent;
use App\Context\Product\Events\ProductCreatedEvent as AdminProductCreatedEvent;
use App\Context\Order\Events\ProductCreatedEvent;
use App\Context\Order\Events\ProductDeletedEvent;
use App\Support\Events\EventSubscriber as BaseEventSubscriber;
use App\Support\Events\SubscribesToEvents;
class ProductEventSubscriber implements BaseEventSubscriber
{
use SubscribesToEvents;
protected array $handlesEvents = [
AdminProductCreatedEvent::class => 'onProductCreated',
AdminDeletingProductEvent::class => 'onDeletingProduct',
];
public function onProductCreated(AdminProductCreatedEvent $event): void
{
$event = $event->product;
event(new ProductCreatedEvent(
$event->getUuid(),
$event->stock,
));
}
public function onDeletingProduct(AdminDeletingProductEvent $event): void
{
$event = $event->product;
event(new ProductDeletedEvent(
$event->getUuid(),
));
}
}

这里发生的事情很有趣。我们将监听来自非事件溯源部分的事件。当来自 Product 上下文的事件(对于 Order 上下文感兴趣)进入时,我们将触发另一个事件。另一个事件是 Product 上下文的一部分。
因此,当出现App\Context\Product\Events\ProductCreatedEvent(且别名为AdminProductCreatedEvent,因为可能是管理员在 UI 上执行的操作导致该操作)时,我们将触发App\Context\Order\Events\ProductCreatedEvent(来自 Order 上下文)。

我们将获取对 Order 上下文感兴趣的所有属性,并将它们放在App\Context\Order\Events\ProductCreatedEvent中。

复制代码
public function onProductCreated(AdminProductCreatedEvent $event): void
{
$event = $event->product;
event(new ProductCreatedEvent(
$event->getUuid(),
$event->stock,
));
}

看一下这个事件本身。

复制代码
namespace App\Context\Order\Events;
use App\Support\ValueObjects\ProductUuid;
use Spatie\EventSourcing\ShouldBeStored;
class ProductCreatedEvent extends ShouldBeStored
{
public ProductUuid $productUuid;
public int $stock;
public function __construct(ProductUuid $productUuid, int $stock)
{
$this->productUuid = $productUuid;
$this->stock = $stock;
}
}

你可以看到这个事件扩展了ShouldBeStored。这个基类是我们的事件溯源包的一部分。这将导致该事件被存储下来。
我们立即使用这个存储的事件来建立一个包含存货(Stock)的 projection。看一下ProductStockProjector

复制代码
namespace App\Context\Order\Projectors;
use App\Context\Order\Events\OrderCancelledEvent;
use App\Context\Order\Events\ProductCreatedEvent;
use App\Context\Order\Events\ProductDeletedEvent;
use App\Context\Order\Events\OrderCreatedEvent;
use App\Context\Order\Models\Order;
use App\Context\Order\Models\ProductStock;
use App\Support\ValueObjects\ProductStockUuid;
use Spatie\EventSourcing\Projectors\Projector;
use Spatie\EventSourcing\Projectors\ProjectsEvents;
class ProductStockProjector implements Projector
{
use ProjectsEvents;
protected array $handlesEvents = [
ProductCreatedEvent::class => 'onProductCreated',
ProductDeletedEvent::class => 'onProductDeleted',
OrderCreatedEvent::class => 'onOrderCreated',
OrderCancelledEvent::class => 'onOrderCancelled',
];
public function onProductCreated(ProductCreatedEvent $event): void
{
ProductStock::create([
'uuid' => ProductStockUuid::create(),
'product_uuid' => $event->productUuid,
'stock' => $event->stock,
]);
}
public function onProductDeleted(ProductDeletedEvent $event): void
{
ProductStock::forProduct($event->productUuid)->delete();
}
public function onOrderCreated(OrderCreatedEvent $event): void
{
$productStock = ProductStock::forProduct($event->productUuid);
$productStock->update([
'stock' => $productStock->stock - $event->quantity,
]);
}
public function onOrderCancelled(OrderCancelledEvent $event): void
{
$order = Order::findByUuid($event->aggregateRootUuid());
$productStock = ProductStock::forProduct($order->product->uuid);
$productStock->update([
'stock' => $productStock->stock + $order->quantity,
]);
}
}

触发ProductCreatedEvent时,我们将调用ProductStock::createProductStock是常规的 Eloquent 模型。

复制代码
namespace App\Context\Order\Models;
use App\Context\Product\Models\Product;
use Illuminate\Database\Eloquent\Model;
class ProductStock extends Model
{
/**
* @param \App\Context\Product\Models\Product|\App\Support\ValueObjects\ProductUuid $productUuid
*
* @return \App\Context\Order\Models\ProductStock
*/
public static function forProduct($productUuid): ProductStock
{
if ($productUuid instanceof Product) {
$productUuid = $productUuid->getUuid();
}
return static::query()
->where('product_uuid', $productUuid)
->first();
}
}

ProductStockProjector中,当收到订单、取消订单或删除订单时,将更新ProductStock模型。
现在,我们与参与该项目的每位成员都达成一项协议,那就是不允许从其他上下文写入模型。Order 上下文可以从这个模型写入和读取,而 Product 上下文只能读取它。由于这是一个重要的规则,因此我们可能会很快用代码把它固定下来。

最后,我们看一下使用ProductStock模型进行决策的OrderAggregateRoot

复制代码
namespace App\Context\Order;
use App\Context\Product\Models\Product;
use App\Context\Order\Events\CouldNotCreateOrderBecauseInsufficientStock;
use App\Context\Order\Events\OrderCreatedEvent;
use App\Context\Order\Events\OrderCancelledEvent;
use App\Context\Order\Exceptions\CannotCreateOrderBecauseInsufficientStock;
use App\Context\Order\Models\ProductStock;
use Spatie\EventSourcing\AggregateRoot;
class OrderAggregateRoot extends AggregateRoot
{
public function createOrder(Product $product, int $quantity): self
{
$eventAvailability = ProductStock::forProduct($product);
if ($eventAvailability->availability < $quantity) {
$this->recordThat(new CouldNotCreateOrderBecauseInsufficientStock(
$product->getUuid(),
$quantity
));
throw CannotCreateOrderBecauseInsufficientStock::make();
}
$unitPrice = $product->unit_price;
$totalPrice = $unitPrice * $quantity;
$this->recordThat(new OrderCreatedEvent(
$product->getUuid(),
$quantity,
$unitPrice,
$totalPrice,
));
return $this;
}
public function cancelOrder(): self
{
$this->recordThat(new OrderCancelledEvent());
return $this;
}
}

在一个完整的应用中,createOrder函数可能由 UI 中执行的操作触发。产品可以被订购一定的数量。使用ProductStock,聚合根将决定是否有足够的库存来创建订单。

小结

应用的非事件溯源部分会触发常规事件。事件溯源部分会监听这些事件,并在它们自己的事件中记录它们感兴趣的数据。这些属于上下文的事件将被存储下来。这些存储的事件将提供给 projector。这些 projector 创建的模型可以由非事件溯源的部分读取。非事件溯源的部分只能读取这些模型,不能写入。聚合根可以使用事件和 projection 来做出决策。

这种方法可以带来很多好处:

  • 可以像我们过去那样构建非事件溯源的部分。我们只需要注意被触发的事件即可
  • 事件溯源部分可以记录自己的事件以采取行动
  • 我们可以创建新的 projector 并重播所有记录的事件,以建立新状态

我知道这些要吸收起来没那么容易。这种方法并不是一成不变的。我们尚处于探索阶段,但我们对于已经掌握的部分信心十足。

英文原文:

Mixing event sourcing in a traditional Laravel app

阅读数:2 发布于:2020 年 6 月 2 日 16:46

评论

发布
暂无评论