🥫

実装で体験するイベントソーシング(laravel-event-sourcingを触ってみた)

2024/12/16に公開

この記事はNE Advent Calendar 2024の16日目の記事です。

はじめに

NE株でバックエンドエンジニアをしている谷口(@taniguhey)です。

PHPでイベントソーシングをサポートしてくれるライブラリを調べているときに見つけた、laravel-event-sourcingが、日本語の情報が少なくどんな感じなのか気になったので触ってみることにしました。

ついでに、

  • イベントソーシングをあまり理解できていない
  • 実装ではどうなるかイメージできていない

ようなイベントを基準とした処理の流れを知ってみたい人に向けての記事にもしました。

公式のチュートリアルをもとに、理解のため内容を少しアレンジし、基本の使い方を試すところまでの紹介になります。イベントソーシング自体の踏み込んだ解説は省きます。

laravel-event-sourcing

環境

laravelと付いている通りLaravelと一緒に使う前提なので、Laravelの環境は
https://qiita.com/ucan-lab/items/5fc1281cd8076c8ac9f4
の環境をそのまま借りました。
Requirementsにある通りの環境であればなんでも良いです。

  • Laravel Framework 11.35.0
  • mysql 9.0.1
  • laravel-event-sourcing 7.10.1

導入

上記の環境を構築して、Laravelの環境が立ち上がっている状態とします。

Installation & setupのページをもとに進めていきます。

composer require spatie/laravel-event-sourcing

を実行しライブラリを導入した後、

php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-migrations"

を実行すると、stored_eventsテーブルとsnapshotsテーブルのマイグレーションを生成されるので、そのまま

php artisan migrate

まで実行します(今回はsnapshotsは使いません)。

次に、

php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" --tag="event-sourcing-config"

を実行するとconfig/event-sourcing.phpが作成されます。今回試す範囲では、設定はデフォルトのままでいくので、何も書き換えません。

EventとProjector

次は、イベントを元にアプリケーションが動作する設計をまずは体験してみます。

公式でいうところのWriting your first projectorをベースに進めます。
チュートリアルでは銀行口座を題材にしていますが、普段私が業務で慣れ親しんでいる注文モデルを題材にしてみます。

テーブル

まずは、注文テーブルを作っておきます。

テーブル
public function up(): void
{
    Schema::create('orders', function (Blueprint $table) {
        $table->id();
        $table->string('uuid');
        $table->string('status');
        $table->string('buyer_name');
        $table->string('item_name');
        $table->integer('quantity');
        $table->timestamps();
    });
}

モデル

次に、この注文テーブルに該当する注文モデルを作成します。モデルにはSpatie\EventSourcing\Projections\Projectionを継承させておきます。

モデル
use Spatie\EventSourcing\Projections\Projection;

class Order extends Projection
{
    protected $guarded = [];

    public static function uuid(string $uuid): ?Order
    {
        return static::where('uuid', $uuid)->first();
    }
}

ProjectionIlluminate\Database\Eloquent\Modelを継承しているため、一旦通常のモデルと同じだと考えて問題ないです。
uuid()は、この後の実装を楽するためのただのヘルパーです。

イベント

次に、注文に対するイベントを考えます。イベントはモデルに対して起こる出来事を過去形を使って表したものだと考えてください。

今回はまずは、注文を受け付けたときに注文が作成されたというイベントを実装するとします。

イベント
use Spatie\EventSourcing\StoredEvents\ShouldBeStored;

class OrderCreated extends ShouldBeStored
{
    public function __construct(
        public readonly string $uuid,
        public readonly string $buyerName,
        public readonly string $itemName,
        public readonly int $quantity,
    ) {
    }
}

イベントは、内容を保持するだけにしておき、Spatie\EventSourcing\StoredEvents\ShouldBeStoredを継承させておきます。

イベントを発火

これで、モデルとイベントができたのでモデルを作成するとイベントが発生するという部分を実装します。

モデル
class Order extends Projection
{
    public static function create(
        string $uuid,
        string $buyerName,
        string $itemName,
        int $quantity,
    ): Order {
        event(new OrderCreated($uuid, $buyerName, $itemName, $quantity));

        return static::uuid($uuid);
    }

肝となるのはcreate()内では値をセットしたりせず、event()にイベントを渡している部分です。
event()はLaravelのヘルパーとして実装されており、Laravel自体のEvent機能dispatch()を呼び出したときと同じく、Listenerを呼び出しているだけだと思われます。
https://github.com/laravel/framework/blob/a30619310dd739c22f561579c3de07f33d109612/src/Illuminate/Foundation/Events/Dispatchable.php#L12-L15

つまり、Listenerにあたるクラスがあれば、このイベントを処理させるという実装になっています。

実行してみる

Listenerにあたる部分がProjectorになります。Projectorを作る前に、イベント自体が保存されていることを確認してみます。

以下のようなコードをどこかに書き実行してみます。

実行用
$uuid = (string) Uuid::uuid4();
Order::create($uuid, 'satou', 'ringo', 1);

stored_eventsテーブルににイベントやメタデータが保存されていることが確認できたと思います。しかし、これだけではordersテーブルには保存されていません。

Projetor

イベントを履歴として残すだけではなく、注文が作成されたイベントを受けとり、読み取り用のテーブルに反映するOrderProjectorを作ります。

プロジェクター
use Spatie\EventSourcing\EventHandlers\Projectors\Projector;

class OrderProjector extends Projector
{
    public function onOrderCreated(OrderCreated $event)
    {
        (new Order([
            'uuid' => $event->uuid,
            'status' => 'accepted', // 注文が作成されたときなので固定値
            'buyer_name' => $event->buyerName,
            'item_name' => $event->itemName,
            'quantity' => $event->quantity
        ]))->writeable()->save();
    }
}

プロジェクターにはSpatie\EventSourcing\EventHandlers\Projectors\Projectorを継承させます。
メソッドの引数には処理したいイベントを受け取り、実装はEloquentを使ってモデルの保存処理を行っているだけです。

これで改めて先ほどと同じOrder::create()を実行すると、今度はstored_eventsordersが両方保存されたことが確認できます。

ここまでのまとめ

ここまでで、注文が作成されたというイベントを基準に

  • イベントが保存されること
  • Projectorを経由してordersテーブルに保存されること

が分かれていることが確認できました。ここまではLaravelのEventなどを利用して簡単に再現できそうで、Listenerの登録などの設定を各種クラスの継承によって省けている程度です。

イベントを基準とした処理の流れを体験するのが初めての人は、OrderCreatedを処理する別のProjectorを用意すれば、

  • 注文が作成されたら決済テーブルにデータを保存する」
  • 注文が作成されたら誰かにメールを送信する」

などの処理も行えることなどを理解いただけると良いかと思います。

Aggregate

集約

(以下、DDDではAggregateのことを集約と呼ぶので集約と訳します。)

次は、Aggregateを使って、読み取りも含めてイベントを使ってみることにします。
Writing your first aggregateをベースに進めます。

先ほどの例では注文を作成するにはOrder::create()を使いましたが、まずはこれを集約(Aggregate)を利用してモデルを作成するようにしてみます。

集約
use Spatie\EventSourcing\AggregateRoots\AggregateRoot;

class OrderAggregate extends AggregateRoot
{
    public function createOrder(string $uuid, string $buyerName, string $itemName, int $quantity)
    {
        $this->recordThat(new OrderCreated(
            $uuid,
            $buyerName,
            $itemName,
            $quantity
        ));

        return $this;
    }
}

集約にはSpatie\EventSourcing\AggregateRoots\AggregateRootを継承させます。
envet()だったものがrecordThat()に変わっています。
このrecordThat()は実行しただけではまだイベントは発火されたことにならないので、テーブルにも保存されずProjectorも呼び出されません。

これを発火するには、以下のようにpersist()を実行します。

実行用
$uuid = (string) Uuid::uuid4();
- Order::create($uuid, 'satou', 'ringo', 1);
+ OrderAggregate::retrieve($uuid)
+     ->createOrder($uuid, 'tanaka', 'mikan', 2)
+     ->persist();

retrieve($uuid)$uuidが一致するイベントをstored_eventsから検索してくれます。今回は作成のケースなので、新しい集約が出来上がることになります。

persist()したタイミングでrecordThat()されているイベントが発火され、Projectorが呼び出されます。

OrderCreateイベントの作成を集約に移せたので、Order::create()は不要になりました。
これによってOrderクラスはProjectorでのみ利用され、呼び出し側には現れなくなりました。

他のイベント

次に、注文が作成された以外のイベントとして、注文が出荷されたを増やしてみます。
注文が出荷されたイベントとして、OrderShippedを作ります。

イベント
class OrderShipped extends ShouldBeStored
{
}

このイベントが発火したときに対応するProjectorの処理も追加します。

プロジェクター
class OrderProjector extends Projector
{
    public function onOrderShipped(OrderShipped $event)
    {
        $order = Order::uuid($event->aggregateRootUuid());

        $order->status = 'shipped'; // ステータスを更新

        $order->writeable()->save();
    }
}

そして、集約からこのイベントを起こせるようにします。

集約
class OrderAggregate extends AggregateRoot
{
    public function shipOrder()
    {
        $this->recordThat(new OrderShipped());

        return $this;
    }
}

これを実行してみましょう。お試しで、作成してすぐ出荷してみます。

実行用
$uuid = (string) Uuid::uuid4();
OrderAggregate::retrieve($uuid)
    ->createOrder($uuid, 'tanaka', 'mikan', 2)
    ->persist();

+ OrderAggregate::retrieve($uuid)
+     ->shipOrder()
+     ->persist();

これによって新しいイベントが2つ保存され、作成されたordersテーブルのレコードはstatus'shipped'になっているはずです。

読み取りとビジネスロジック

ここからが集約の本題です。
実際は作成してすぐ出荷されることはないので、出荷の操作が行われてから$uuidを検索し、shipOrder()するという処理になります。

ここで、一度出荷した注文を再度注文してみるとします。

実行用
// statusがshippedな注文
OrderAggregate::retrieve('809b64ab-hoge-fuga-piyo-48364b5baec0')
    ->shipOrder()
    ->persist();

今のコードではこれが実行できてしまい、OrderShippedが無限に保存できてしまいます。
ビジネスロジック的にはこれでは問題があるかもしれません。例えばOrderShippedが発火するたびに在庫数を減らしたり、何かを通知するようにしていたりすると、それが何度でも行えてしまいます。

なぜこうなるかというと、集約がrecordThat()によってイベントを保持するタイミングで、何も判定していないからです。つまり集約上にstatusがacceptedでなければ出荷できないというビジネスロジックを書く必要があります。
しかし、いざ書こうとしてもshipOrder()内ではOrderのstatusがわかりません。

ここで必要となるのが過去のイベントを適用して、現在の状態を再現するという処理です。
つまり、起こりうる各イベントごとに、それが起こったら集約はどのような状態なのかというのを記述していきます。

具体的にはapplyという接頭辞をつけたメソッドを集約に定義します。

集約
class OrderAggregate extends AggregateRoot
{
+    private string $status = 'accepted';
    
+    public function applyOrderShipped(OrderShipped $event)
+    {
+        $this->status = 'shipped';
+    }
}

これにより、OrderAggregate::retrieve($uuid)したタイミングで、過去のイベントを取得し対応するイベントのapplyメソッドが実行され、集約の状態が更新されます。

あとは、ビジネスロジックをそのまま書くだけです。

集約
class OrderAggregate extends AggregateRoot
{
    public function shipOrder()
    {
+        if ($this->status !== 'accepted') {
+            throw new \Exception('出荷済みです!');
+        }

        $this->recordThat(new OrderShipped());

        return $this;
    }
}

これで、出荷済み(=OrderShippedイベントが保存されたことがある)注文は、集約を取得したときに$this->status'shipped'が代入されるので出荷できないことになります。

このように過去のイベントを元に状態を現在と未来の状態を管理するのが集約の役割です。

ここまでのまとめ

ビジネスロジックを1箇所にまとめ状態を保持するのは、DDDでいうところの集約とも近しい存在になっています。また、自然とDB(Eloquent)とビジネスロジックが分離されている状態にもなっています。

イベントを基準とした処理の流れを体験するのが初めての人は、shipOrder()のビジネスロジックに関わる一連の処理で、orders.statusを一切参照せずに処理できていることに注目していただきたいです。

つまり、ordersテーブルにはstatusカラムは本来必要なく、アプリケーションの要件に合わせて読み取り(表示)に特化した物理構造をとることが可能になっています。
orders以外にも分析用のテーブルにも書き込みをしたり、ユーザーに表示するために適切なインデックスを貼ったり、計算済みのデータを保存しておいたりなど。

これがCQRSでイベントソーシングが使われる理由になります。

最後に

laravel-event-sourcingの使い方と、イベントソーシングの体験いかがだったでしょうか。

こういう設計の基礎の部分を自作するのは大変なので、Listenerの設定やイベントのリプレイを簡単に行えるのはとても魅力的でした。
時間があればSnapshotやProjectorの非同期化なども調べてみたいです。

NE株式会社の開発ブログ

Discussion