PHP 事件溯源

xuding發表於2021-07-09

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

事件溯源(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以及簡潔程式碼感興趣,歡迎關注公眾號【何以解耦】,一起探索軟體開發之道。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
Know how, know why meanwhile.

相關文章