PHPとEventSauceで始めるイベントソーシングアプリケーション

Df4978f14401325586e9e286b140ac4c?s=47 n1215
February 10, 2020

 PHPとEventSauceで始めるイベントソーシングアプリケーション

2019/02/10(月) PHPerKaigi 2020 の発表資料です。
https://phperkaigi.jp/2020/

サンプルコードはこちら:
https://github.com/n1215/eventsauce-example

Df4978f14401325586e9e286b140ac4c?s=128

n1215

February 10, 2020
Tweet

Transcript

  1. 2.

    ⾃⼰紹介 京都から来ました - 中榮健⼆ (なかえけんじ) - twitter: @n_1215  - 株式会社Nextat

    取締役 - Laravel/Unityでソーシャルゲーム開発メイン → 最近はごった煮 - ここ最近はサーバサイドTypeScriptやインフラのコード Nextat Inc. 2
  2. 3.

    発表概要 1. はじめに 2. イベント 3. イベントソーシング 4. CQRSとの関係性 5.

    EventSauce⼊⾨ 6. なぜ今イベントソーシングなのか 7. まとめ Nextat Inc. 3
  3. 10.

    イベントなしの実装例 ユーザがキャラクターを獲得する処理 ユーザアイコンサービス、獲得履歴サービスに直接依存 class UserCharacterService { public function add(UserId $userId,

    Character $character): void { // ユーザが所持キャラクターを獲得 $userCharacter = $this->userCharacterFactory->initiate($userId, $character); $this->userCharacterRepository->persist($userCharacter); // ユーザにアイコンを付与 $this->userIconService->addCharacterIcon($userCharacter); // キャラクター獲得履歴を残す $this->userCharacterAcquisitionHistoryService->add($userCharacter); } } Nextat Inc. 10
  4. 11.

    イベントを利⽤する実装1 イベントの発⽕ // ユーザ所持キャラクター獲得イベント class UserCharacterAcquired implements Event { //

    略 } class UserCharacterService { public function add(UserId $userId, Character $character): void { // ユーザが所持キャラクターを獲得 $userCharacter = $this->userCharacterFactory ->initiate($userId, $character); $this->userCharacterRepository->persist($userCharacter); // イベントを発⽕ $event = new UserCharacterAcquired($userCharacter); $this->eventDispatcher->fire($event); } } Nextat Inc. 11
  5. 12.

    イベントを利⽤する実装2 イベントリスナ // ユーザにアイコンを付与するイベントリスナ class AddUserCharacterIcon { public function handle(UserCharacterAcquried

    $event): void { $this->userIconService ->addCharacterIcon($event->getUserCharacter()); } } // 履歴を残すイベントリスナ class RecordCharacterAcquisition { public function handle(UserCharacterAcquried $event): void { $this->userCharacterAcquisitionHistoryService ->add($event->getUserCharacter()); } } $eventDispatcher->listen( // 別途、イベントにイベントリスナを関連づける UserCharacterAcquired::class, [AddUserCharacterIcon::class, RecordCharacterAcquisition::class] ); Nextat Inc. 12
  6. 15.

    昔ながらのステートソーシング user_characters テーブル ユーザ所持キャラクターID キャラクターID(マスタID) レベル 限界突破数 ... ... ...

    ... 1234 1001 50 3 ... ... ... ... 変更があるたびにテーブルのレコードを更新 ステートソーシング:最新の状態をデータストアに保存する⽅式 Nextat Inc. 15
  7. 16.

    イベントソーシング イベントストア (イベントを記録しておくデータストアのこと) ユーザ所持キャラクターID イベント イベントのデータ 1234 LimitBreak {"before":0, "after":1}

    1234 Enhanced {"experience": 50000} 1234 Acquired {"from": "gacha", "characterId": 1001} イベントソーシング:起こったイベントをデータストアに保存する⽅式 起こったイベントを時系列順に再実⾏(リプレイ)して最新の状態を得る 最新の状態はイベントが積み重なった結果に過ぎない Nextat Inc. 16
  8. 27.

    CQS と CQRSの違い CQRSはMeyer⽒のコマンドとクエリと同じ定義を使いますが、この2つは純粋 であるべきという視点を貫いています。CQRSでは、CQS と異なり、オブジェク トを2 種類に分けます。(複数の)コマンドを持つオブジェクトと、(複数の)クエリ を持つオブジェクト です。

    CQRS Documents by Greg Young https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf ⽇本語訳付: http://www.minato.tv/cqrs/cqrs_documents_jp.pdf オブジェクトが分けられる → システム・アーキテクチャレベルでの分離も可能 イベントソーシングとの相性◎ Nextat Inc. 27
  9. 30.

    おまけ. CQRSにまつわるよくある誤解 CQRS導⼊の⼼理的ハードルが⾼い理由? CQRSはイベントソーシングと⼀緒に採⽤しなければならない ステートソーシング + CQRSという選択肢 反例) ランキング集計処理。最新の状態をRDBに保存、Redisにもデータを保 存しランキングの読み取りはRedisを元にする

    反例) ECサイトの複雑な商品検索。最新の状態をRDBに保存、検索は最新の 状態から計算したElasticsearchのデータを元にする CQRSはデータストアを分けなければならない 反例) ライトモデルはRDB、リードモデルもRDBだが別のテーブルやビュー 反例) 同じテーブルを参照するがリードモデルとライトモデルが別のクラス Nextat Inc. 30
  10. 34.

    EventSauce https://eventsauce.io A pragmatic event sourcing library for PHP with

    a focus on developer experience. by Frank de Jonge. https://frankdejonge.nl/ The Creator of Flysystem (league/flysystem) https://flysystem.thephpleague.com/v1/docs/ Nextat Inc. 34
  11. 36.

    EventSauceの設計思想 イベントソーシング + DDDのパターンである集約の考え⽅からの影響 Greg Youngの CQRS + ESからの流れ Aggregates

    and Event Sourcing (A+ES) イベントの主体 = 集約(エンティティ) 〇〇(=エンティティ) が XX した ※ cf. 実践ドメイン駆動設計 付録A 通信中⼼(Communication)、メッセージ駆動のインターフェース Nextat Inc. 36
  12. 42.

    実装1. AggregateRoot (集約) AggregateRootBehaviorというTraitがインターフェースの実装を補助 集約のID = AggregateRootIdを持つ必要がある /** ユーザ所持キャラクター */

    class UserCharacter implements AggregateRoot { use AggregateRootBehaviour; private UserId $userId; ユーザID private CharacterId $characterId; // キャラクターのマスタID private int $experience = 0; // 獲得経験値累計 // コンストラクタはprivate 。static メソッドが名前付きコンストラクタの代わり public static function initiate(): AggregateRoot { $userCharacterId = UserCharacterId::fromString(Uuid::uuid4()->toString()); return new static($userCharacterId); } } Nextat Inc. 42
  13. 43.

    実装2. AggregateRootId (集約ID) fromString()とtoString()を実装していればOK /** ユーザ所持キャラクターID */ class UserCharacterId implements

    AggregateRootId { private string $id; private function __construct(string $id) { $this->id = $id; } public function toString(): string { return $this->id; } public static function fromString(string $id): UserCharacterId { return new static($id); } } Nextat Inc. 43
  14. 44.

    実装3. Event (イベント) Serialize,Deserializeのためのメソッドを実装+Getter /** ユーザ所持キャラクター強化イベント */ class UserCharacterEnhanced implements

    SerializablePayload { private UserCharacterId $userCharacterId; private int $experience; // 獲得経験値 // コンストラクタ省略 // Getter 省略 public static function fromPayload(array $payload): SerializablePayload { return new self( UserCharacterId::fromString($payload['userCharacterId']), (int) $payload['experience'], ); } public function toPayload(): array { return [ 'userCharacterId' => $this->userCharacterId->toString(), 'experience' => $this->experience, ]; } } Nextat Inc. 44
  15. 45.

    実装4. Command (コマンド) ほぼGetterのみのDTO。単純な処理の場合、イベントと似た感じになる /** ユーザ所持キャラクター獲得コマンド */ class EnhanceUserCharacter { private

    UuidInterface $id; private UserCharacterId $userCharacterId; private int $experience; // コンストラクタ省略 // Getter 省略 public static function new( UserId $userId, CharacterId $characterId ): AcquireUserCharacter { return new self(Uuid::uuid4(), $userId, $characterId); } } Nextat Inc. 45
  16. 47.

    実装5. Aggregateにコマンドの実⾏を記述 集約が受け付けたコマンドを適⽤できる状態かどうかをまず検証 recordThat()でイベントを記録するのがポイント /** ユーザ所持キャラクター */ class UserCharacter implements

    AggregateRoot { public function performEnhance(EnhanceUserCharacter $command): void { // 条件の検証 if ($this->isMaxLevel()) { throw new AlreadyMaxLevelException(' 最⼤レベルです'); } // イベントの記録 $this->recordThat(new UserCharacterEnhanced( $command->getUserCharacterId(), $command->getExperience() )); } } Nextat Inc. 47
  17. 48.

    実装6. Aggregateにイベントの適⽤を記述 イベントに応じて集約の状態を変化させる コマンドの実⾏時とイベントのリプレイ時に同じロジックが使われる 例外を投げてはいけない=失敗してはいけない 集約の整合性はコマンド実⾏時に担保されているはず /** ユーザ所持キャラクター */ class

    UserCharacter implements AggregateRoot { private int $experience = 0; // 経験値 // apply{ イベントクラス名}() という命名規約で暗黙的に呼ばれる protected function applyUserCharacterEnhanced( UserCharacterEnhanced $event ): void { $this->experience += $event->getExperience(); } } Nextat Inc. 48
  18. 49.

    実装7. Consumer Eventを包んだMessageを受け取るので好きに処理する /** イベントをロギング */ class LogEvents implements Consumer

    { public function handle(Message $message) { $event = $message->event(); Log::info(get_class($event) . ' event handled.', array_merge( [ 'aggregateRootId' => $message->aggregateRootId()->toString(), 'aggregateRootVersion' => $message->aggregateVersion(), ], $event->toPayload(), )); } } Nextat Inc. 49
  19. 50.

    実装8. 処理全体の流れ サービス、コマンドハンドラなどに記述してくださいとある ステートソーシングの場合と⾒た⽬はあまり変わらない $repository = new ConstructingAggregateRootRepository( UserCharacter::class, new

    InMemoryMessageRepository(), new SynchronousMessageDispatcher(new LogEvents()) ); // リポジトリからの集約の取得 $userCharacter = $repository->retrieve($newUserCharacterId); $userCharacterId = $userCharacter->aggregateRootId(); // 強化 $command = EnhanceUserCharacter::new($userCharacterId, $exp = 1000); $userCharacter->performEnhance($command); // 永続化 $userCharacterRepository->persist($userCharacter); Nextat Inc. 50
  20. 51.

    実装9. テスト PHPUnitを継承してBDDスタイルで書ける仕組みを提供 then()の代わりにexpectToFail()で準正常系のテストも書ける class UserCharacterTest extends AggregateRootTestCase { public

    function test_enhance(): void { $userCharacterId = $this->aggregateRootId(); // ユーザキャラクタを獲得後に // 強化コマンドを実⾏すると強化されたというイベントが発⽣ $this->given( new UserCharacterAcquired( $userCharacterId, UserId::fromString('user1'), CharacterId::fromInt(1) ) ) ->when(EnhanceUserCharacter::new($userCharacterId, 1000)) ->then(new UserCharacterEnhanced($userCharacterId, 1000)); } Nextat Inc. 51