Slide 1

Slide 1 text

for PHPerKaigi 2020 PHPとEventSauceで始める イベントソーシングアプリケーション 2020年2⽉10⽇ (⽉) 株式会社Nextat 中榮健⼆ Nextat Inc. 1

Slide 2

Slide 2 text

⾃⼰紹介 京都から来ました - 中榮健⼆ (なかえけんじ) - twitter: @n_1215  - 株式会社Nextat 取締役 - Laravel/Unityでソーシャルゲーム開発メイン → 最近はごった煮 - ここ最近はサーバサイドTypeScriptやインフラのコード Nextat Inc. 2

Slide 3

Slide 3 text

発表概要 1. はじめに 2. イベント 3. イベントソーシング 4. CQRSとの関係性 5. EventSauce⼊⾨ 6. なぜ今イベントソーシングなのか 7. まとめ Nextat Inc. 3

Slide 4

Slide 4 text

1. はじめに Nextat Inc. 4

Slide 5

Slide 5 text

会場の皆様に質問 イベントソーシングをご存じの⽅? イベントソーシングを取り⼊れたシステムを実運⽤している⽅? ソシャゲをやったことがある⽅?(サンプルコードの都合) Nextat Inc. 5

Slide 6

Slide 6 text

本スライドのサンプルコードについて スライドの都合上で省略多し(あとでGitHubに上げる予定) よくあるソーシャルゲームを想定 ガチャなどからキャラクターを⼊⼿ ユーザがキャラクターを強化や限界突破などで育てて ストーリーを進めてクエストに挑戦、バトルしたりする Lv. 80 ☆☆☆☆ Nextat Inc. 6

Slide 7

Slide 7 text

2. イベント Nextat Inc. 7

Slide 8

Slide 8 text

イベントとは 実際に起きた事象を表す ユーザ所持キャラクターが獲得された ユーザ所持キャラクターが強化された ユーザ所持キャラクターが限界突破した Lv. 1 → Lv.3 Nextat Inc. 8

Slide 9

Slide 9 text

イベントとイベントリスナ ○○したとき××するという要件を実現するためのパターン ユーザー所持キャラクターが獲得された時、アイコンをユーザに付与 ユーザー所持キャラクターが獲得された時、履歴を残す イベントを起こした処理とイベントに対応する処理(イベントリスナ)を疎 結合に Nextat Inc. 9

Slide 10

Slide 10 text

イベントなしの実装例 ユーザがキャラクターを獲得する処理 ユーザアイコンサービス、獲得履歴サービスに直接依存 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

Slide 11

Slide 11 text

イベントを利⽤する実装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

Slide 12

Slide 12 text

イベントを利⽤する実装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

Slide 13

Slide 13 text

イベントを利⽤しない場合と⽐較 ユーザアイコンサービス、獲得履歴サービスに直接依存しない ユーザ所持キャラクター獲得時の関連処理を変更・追加しやすい イベントリスナはキューなどを⽤いて⾮同期で動作させることも可能 履歴のロギングが多少遅れても良い場合、レスポンスは早く返せる 疎結合とシステム全体としての複雑さ(記述量も増加)のトレードオフ アプリケーションの外部拡張のフックポイント CMSやWeb FWなどでも活⽤されている Nextat Inc. 13

Slide 14

Slide 14 text

3. イベントソーシング Nextat Inc. 14

Slide 15

Slide 15 text

昔ながらのステートソーシング user_characters テーブル ユーザ所持キャラクターID キャラクターID(マスタID) レベル 限界突破数 ... ... ... ... 1234 1001 50 3 ... ... ... ... 変更があるたびにテーブルのレコードを更新 ステートソーシング:最新の状態をデータストアに保存する⽅式 Nextat Inc. 15

Slide 16

Slide 16 text

イベントソーシング イベントストア (イベントを記録しておくデータストアのこと) ユーザ所持キャラクターID イベント イベントのデータ 1234 LimitBreak {"before":0, "after":1} 1234 Enhanced {"experience": 50000} 1234 Acquired {"from": "gacha", "characterId": 1001} イベントソーシング:起こったイベントをデータストアに保存する⽅式 起こったイベントを時系列順に再実⾏(リプレイ)して最新の状態を得る 最新の状態はイベントが積み重なった結果に過ぎない Nextat Inc. 16

Slide 17

Slide 17 text

ところで アプリケーションで起こった出来事を記録する、といえば? Nextat Inc. 17

Slide 18

Slide 18 text

履歴(主にCSやデバッグ⽤) 「いつ何が起こったか後から確認できるようにしておいて!」 ↓ ぼく「わかりました」 history DB user_character_acquisition_logsテーブル user_character_enhancement_logsテーブル user_character_break_limit_logsテーブル Nextat Inc. 18

Slide 19

Slide 19 text

つまり履歴がイベントだったんだよ!!!! な、なんだってー Nextat Inc. 19

Slide 20

Slide 20 text

状態と履歴の関係 雑に⾔えば、履歴と状態の主従を逆転させるとほぼイベントソーシング 状態は履歴の積分、履歴は状態の微分のようなもの 全ての履歴を残しておけばそこから再計算して最新の状態を得ることができる ex) クエストへの⽇別挑戦回数をクエストの履歴から算出 イベントソーシングに近しい記録⽅法を採⽤すると嬉しい機能の例 ゲーム内通貨 ⼈気投票 null許容 or ミュータブルなカラムを減らしてイベントのようになる例 バトルテーブル(終了⽇時にnull許容) → バトル開始+バトル結果テーブル プレゼントテーブル → プレゼント配布 + プレゼント受取テーブル Nextat Inc. 20

Slide 21

Slide 21 text

⾝近なイベントソーシング イベントソーシング⾃体は古くからある考え⽅ バージョン管理システム データベース管理システムのトランザクションログによるリカバリ 家計簿の出⾦と⼊⾦の記録 ⇨ 過去の状態や変更を確認、再現できるというメリット Nextat Inc. 21

Slide 22

Slide 22 text

イベントソーシングのメリットとデメリット メリット 監査⽤に向く。証跡を残せる 状態が変化した理由や経緯がわかる 過去の状態も再現できる イベントのデータは不変。イベント⽤のデータストアが追記専⽤になる デメリット 最新状態を得るためのリプレイのコストがかかるためパフォーマンスや検索に難 データ量の増加とストレージのコスト 開発者のマインドセットの⼤きな変更を要求 (多くの場合)インフラ構成の変更が必要 Nextat Inc. 22

Slide 23

Slide 23 text

4. CQRSとの関係性 Nextat Inc. 23

Slide 24

Slide 24 text

ご注意 ⽤語の混乱・混同が多いのでじっくり⾏きます Nextat Inc. 24

Slide 25

Slide 25 text

CQS (コマンドクエリ分離) Command Query Separation すべてのメソッドはアクションを実⾏するコマンドまたは呼び出し側にデータを 返すクエリのいずれかでなくてはならず、双⽅であってはならない。 要するに、質問をしたことで答えを変化させてはならない コマンド(状態を変化させる副作⽤を持つ処理)+ クエリ(データの参照) クエリは参照透明であれ メソッドレベルでのコマンドとクエリの純粋性を要求 by Bertrand Meyer プログラミング⾔語Eiffelの開発者 契約による設計(Design by Contract / DbC)の創始者 Nextat Inc. 25

Slide 26

Slide 26 text

CQRS (コマンドクエリ責務分離) Command Query Responsibility Segregation クエリ側のモデルとコマンド側のオブジェクト(モデル)を分離 リードモデル(クエリモデル) ライトモデル(コマンドモデル) クエリ側の要求が変わっても、コマンド側への影響を最⼩限にした修正が可能 by Greg Young DDDへのイベントソーシング+CQRSの適⽤。昨今のESブームの⽕付け役 Nextat Inc. 26

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

イベントソーシングとCQRSの併⽤ コマンド側はイベントを扱うが、クエリ側は最新の状態さえあればいい いちいちイベントをフル再⽣して最新の状態を取得すると遅い+検索に弱い イベント群からクエリ側で求められるデータを作る(Projection)のは容易 イベントソーシングによるライトモデル 別⽴てで最新状態の記録を元にするリードモデル クエリ側の要求によるコマンド側への影響を最⼩化 Nextat Inc. 28

Slide 29

Slide 29 text

イベントソーシング + CQRS コマンド側でイベントを永続化し、クエリ側にメッセージを送って⾮同期で反映 コマンド側とクエリ側が疎結合になり、それぞれの都合で融通がきく Nextat Inc. 29

Slide 30

Slide 30 text

おまけ. CQRSにまつわるよくある誤解 CQRS導⼊の⼼理的ハードルが⾼い理由? CQRSはイベントソーシングと⼀緒に採⽤しなければならない ステートソーシング + CQRSという選択肢 反例) ランキング集計処理。最新の状態をRDBに保存、Redisにもデータを保 存しランキングの読み取りはRedisを元にする 反例) ECサイトの複雑な商品検索。最新の状態をRDBに保存、検索は最新の 状態から計算したElasticsearchのデータを元にする CQRSはデータストアを分けなければならない 反例) ライトモデルはRDB、リードモデルもRDBだが別のテーブルやビュー 反例) 同じテーブルを参照するがリードモデルとライトモデルが別のクラス Nextat Inc. 30

Slide 31

Slide 31 text

ここまでのまとめ イベントソーシングは最新の状態ではなく、イベントによる状態差分を重要視す る考え⽅ 履歴保存が必須とされるような場合には向いている ⾝近にもあるので知らず知らず実践している⽅も多いのでは? パフォーマンス⾯の劣化はCQRSを併⽤してカバーできる CQRSとイベントソーシングは必ずしもセットではない 疎結合性とシステム全体としての複雑性がトレードオフ 最新の状態しか必要のないアプリケーションでは無駄な複雑さを追加する Nextat Inc. 31

Slide 32

Slide 32 text

5. EventSauce⼊⾨ Nextat Inc. 32

Slide 33

Slide 33 text

イベントソーシング実装を⽀援するFW/ライブラリ イベントソーシング⾃体はプログラミング⾔語を問わない設計⽅法 PHP⽤のFW/ライブラリ Prooph http://getprooph.org EventSauce https://eventsauce.io Broadway https://github.com/broadway/broadway predaddy https://github.com/szjani/predaddy Nextat Inc. 33

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

特徴(公式サイトより) DXにフォーカス 複雑なビジネス要件への対応に明快さを与えるメッセージ駆動のアプローチ 利⽤者がフルコントロールできるように設計されている 表現⼒豊かなBDD(振る舞い駆動開発)スタイルのテスト コード⽣成による実装スピードアップ Nextat Inc. 35

Slide 36

Slide 36 text

EventSauceの設計思想 イベントソーシング + DDDのパターンである集約の考え⽅からの影響 Greg Youngの CQRS + ESからの流れ Aggregates and Event Sourcing (A+ES) イベントの主体 = 集約(エンティティ) 〇〇(=エンティティ) が XX した ※ cf. 実践ドメイン駆動設計 付録A 通信中⼼(Communication)、メッセージ駆動のインターフェース Nextat Inc. 36

Slide 37

Slide 37 text

⽤語補⾜1 AggregateRoot 集約 メインのモデリング対象。エンティティとも。IDで識別される。 モデルの整合性を保ち、不変条件を守り、コマンドによって起こったイベントを 保持する責務 このスライドのサンプルではユーザ所持キャラクターが相当。 Nextat Inc. 37

Slide 38

Slide 38 text

⽤語補⾜2 Message メッセージ 通知と永続化のためにEventをラップしたオブジェクト Nextat Inc. 38

Slide 39

Slide 39 text

⽤語補⾜3 AggregateRootRepository 集約リポジトリ 集約を取得、永続化する役⽬ + Consumerへのメッセージを通知する役⽬。 実際にはそれぞれの役⽬の⼤半を下記2つのオブジェクトに移譲している MessageRepository: メッセージの取得・永続化 MessageDispatcher: メッセージの通知 MessageRepository、MessageDispatcherもインターフェースとデフォ実装あ り Nextat Inc. 39

Slide 40

Slide 40 text

⽤語補⾜4 Consumer コンシューマ イベントはメッセージにラップして通知される 通知されたメッセージをハンドルする Nextat Inc. 40

Slide 41

Slide 41 text

使ってみました $ composer require eventsauce/eventsauce Nextat Inc. 41

Slide 42

Slide 42 text

実装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

Slide 43

Slide 43 text

実装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

Slide 44

Slide 44 text

実装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

Slide 45

Slide 45 text

実装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

Slide 46

Slide 46 text

(おまけ)20秒でわかるイベントとコマンドの違い 神は「光あれ」と⾔われた。すると光があった ーー 創世記 1章1-8節 コマンド(命令形) 光あれ イベント(過去形) 光があった コマンドは条件次第で失敗するかも イベントの代わりにコマンドを記録すると状態を再現できない Nextat Inc. 46

Slide 47

Slide 47 text

実装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

Slide 48

Slide 48 text

実装6. Aggregateにイベントの適⽤を記述 イベントに応じて集約の状態を変化させる コマンドの実⾏時とイベントのリプレイ時に同じロジックが使われる 例外を投げてはいけない=失敗してはいけない 集約の整合性はコマンド実⾏時に担保されているはず /** ユーザ所持キャラクター */ class UserCharacter implements AggregateRoot { private int $experience = 0; // 経験値 // apply{ イベントクラス名}() という命名規約で暗黙的に呼ばれる protected function applyUserCharacterEnhanced( UserCharacterEnhanced $event ): void { $this->experience += $event->getExperience(); } } Nextat Inc. 48

Slide 49

Slide 49 text

実装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

Slide 50

Slide 50 text

実装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

Slide 51

Slide 51 text

実装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

Slide 52

Slide 52 text

今回紹介できなかった機能など YAMLからEventとCommandのコードを⾃動⽣成 CQRSのためのProjection、ReadModel スナップショット システムクロックパターンのインターフェース Time / Clock ステートソーシングでも使えるEventDispatcher イベントストアのデータ構造についてのパターン考察 Nextat Inc. 52

Slide 53

Slide 53 text

感想 CQRSまでできればエンティティに読み取りのためのGetterをつけなくて済む コマンド実⾏時の事前条件の検証や不変条件を意識しやすい 重要な機能はインターフェースになっており、置き換えやすい AggregateRoot、Repositoryのデフォ実装がIDEの静的解析に引っかかる PHP7.4の共変反変の改善、8以降のstaticの戻り値型宣⾔があればもっと書 きやすそう EventSauce⾃体の設計・実装はシンプルで綺麗 ドキュメントの概念の説明は丁寧だが、実装詳細の説明が若⼲不親切 ソースを読めば⼤体わかる Nextat Inc. 53

Slide 54

Slide 54 text

適切な粒度のインターフェースで実装の詳細が隠蔽される → ES + CQRSの⼤掛かりな部分の実践は後に回せる データストアの詳細 スナップショット キューの導⼊ プロジェクションによるリードモデルの構築 → イベントソーシングの根本や個々の概念を段階を踏んで学習し やすい Nextat Inc. 54

Slide 55

Slide 55 text

EventSauceはいいぞ! Nextat Inc. 55

Slide 56

Slide 56 text

6. なぜ今イベントソーシングなのか Nextat Inc. 56

Slide 57

Slide 57 text

マイクロサービス、リアクティブシステムの⽂脈 疎結合なシステム間をつなぐ鍵がイベントやメッセージ ex) SlackとWeb Hook リアクティブなイベント・メッセージ駆動のアーキテクチャ (※ ステートソーシングでもメッセージ駆動の部品にはなれるが) 各社クラウドにもメッセージ駆動アーキテクチャのための部品が充実 イベント駆動型のFaaS (AWS Lambda、Cloud Function、Azure Function) メッセージキューとして使えるサービス群(Amazon SQS, Cloud Pubsub、 Azure Service Bus) イベントバス (Amazon EventBridge、Azure EventGrid) Nextat Inc. 57

Slide 58

Slide 58 text

まとめ 状態ではなく状態の変化した事象に着⽬するのがイベントソーシング 銀の弾丸ではないが、使い⽅を間違えなければ強⼒ EventSauceは段階的にイベントソーシングに⼊⾨できる良質なライブラリ イベントを重視してアプリケーションを設計すると新しい視点が得られるはず Nextat Inc. 58

Slide 59

Slide 59 text

PR Nextat Inc. 59

Slide 60

Slide 60 text

PR1: We're hiring! 株式会社 Nextat 受託開発 業務システム、ECサイト、ソシャゲ この度東京オフィスを本格始動 私も4⽉から東京の予定 設計の話に付き合ってくれる⽅⼤歓迎! Nextat Inc. 60

Slide 61

Slide 61 text

PR2: 沖縄でイベントをやります MESHミニハッカソンin沖縄 2020/03/04 (⽔) https://nextat2.connpass.com/event/163745/ Nextat Inc. 61

Slide 62

Slide 62 text

MESHというIoTブロックデバイスを使ったハッカソン 無線で繋がるボタンやセンサーを起点に⾊々なサービスをつなぐ直感的なプログ ラミングスタイルがウリ https://meshprj.com/jp/ Nextat Inc. 62

Slide 63

Slide 63 text

PR完 Nextat Inc. 63

Slide 64

Slide 64 text

ご清聴ありがとうございました Nextat Inc. 64