実装で体験するイベントソーシング(laravel-event-sourcingを触ってみた)
この記事はNE Advent Calendar 2024の16日目の記事です。
はじめに
NE株でバックエンドエンジニアをしている谷口(@taniguhey)です。
PHPでイベントソーシングをサポートしてくれるライブラリを調べているときに見つけた、laravel-event-sourcingが、日本語の情報が少なくどんな感じなのか気になったので触ってみることにしました。
ついでに、
- イベントソーシングをあまり理解できていない
- 実装ではどうなるかイメージできていない
ようなイベントを基準とした処理の流れを知ってみたい人に向けての記事にもしました。
公式のチュートリアルをもとに、理解のため内容を少しアレンジし、基本の使い方を試すところまでの紹介になります。イベントソーシング自体の踏み込んだ解説は省きます。
laravel-event-sourcing
環境
laravelと付いている通りLaravelと一緒に使う前提なので、Laravelの環境は
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();
}
}
Projection
はIlluminate\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を呼び出しているだけだと思われます。
つまり、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_events
とorders
が両方保存されたことが確認できます。
ここまでのまとめ
ここまでで、注文が作成された
というイベントを基準に
- イベントが保存されること
-
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株式会社のエンジニアを中心に更新していくPublicationです。 NEでは、「コマースに熱狂を。」をパーパスに掲げ、ECやその周辺領域の事業に取り組んでいます。 Homepage: ne-inc.jp/
Discussion