Upgrade to Pro — share decks privately, control downloads, hide ads and more …

なぜイベント駆動が必要なのか - CQRS/ESで解く複雑系システムの課題 -

なぜイベント駆動が必要なのか - CQRS/ESで解く複雑系システムの課題 -

かとじゅん

February 17, 2025
Tweet

More Decks by かとじゅん

Other Decks in Programming

Transcript

  1. 自己紹介 前職:Kubell 社( 旧Chatwork 社) テックリード IDEO PLUS 合同会社 代表。技術顧問&開発も少し手伝っている

    個人では、月1 万時間無制限でプライベートコーチやってます 執筆 プロダクトデザインやソフトウェア設計の本を書いてるけどいつ 出るか… レビュー 改訂版 良いコード/悪いコードで学ぶ設計入門 WEB+DB PRESS Vol.132 - 特集1 オブジェクト指向神話からの脱却 ドメイン駆動設計入門 Akka 実践バイブル エリックエヴァンスのドメイン駆動設計 twitter: j5ik2o github: j5ik2o
  2. 【課題】システム間連携に使う同期型API 通信 システムA からシステムB の機能を呼び出してシステムA の機能を実現する場合 同期型API 通信では故障の影響が波及しやすい システムA からシステムB

    への呼び出しで、システムB が故障するとシステムA も故障 システムB はシステムC やシステムD からも呼び出される。システムB が停止すると大事に… 同期型API 通信で連携していると本質的に疎結合にならない 分散システムでは、故障の範囲や境界を考慮する必要がある 非同期処理にしない限り、システム間の依存が強く残り続ける 障害シナリオ B が死んでいると A の処理も停⽌ システムA システムB 同期型API 通信
  3. 【課題】安易にダブルライトしがち問題 データ更新とイベント発火の整合性を維持するの が難しい 例: 「データ更新成功→イベント送信失敗」 「イ ベント発生→データ未更新」 トランザクション分断による不整合をどう防ぐか が課題 実際はほとんどのケースでうまくいくが…

    不整合が起きる故障を過小評価していることが 多い 不整合時の対応方法も考慮されていなかったり する… 某システムではEvent Sourcing でダブルライトを回 避している アプリ アプリ DB DB イベントキュー イベントキュー データ更新 (COMMIT) イベント送信 イベントの送信に失敗すると データ更新だけ成功してしまう 逆の順序でも同じ
  4. ダブルライト失敗の影響 ❌ EC サイトの注文処理 現象: 注文データをDB に登録したが、在庫システム向けにイベントを通知できなかった 影響: 在庫が更新されずに、売り切れ商品が購入可能になってしまう ❌

    決済システム 現象: 決済データをDB に登録したが、配送システム向けにイベントを通知できなかった 影響: ユーザーが支払ったのに、商品が出荷されない ときにはビジネスルールを壊す影響が発生することもある
  5. 【課題】後付けのイベント発火の実装 可能だが、ダブルライト問題が発生しや すい。データベースとメッセージブローカ ーの一貫性が崩れるリスク Event Sourcing ならよりスマートに解決で きる コマンド 集約(

    ドメインモデル群) イベント リポジトリ イベントパブリッシャ イベントサブスクライバ データベース メッセージブローカー (1) が成功して(2) が失敗したら? (2) が成功して(1) が失敗したら? RDB のトランザクションをロールバックできたとしてもSQS などのに送ったイベントはどうなる? fn execute(&mut self, param: &Param) -> Result<(), UseCaseError> { let mut group_chat = self.group_chat_repository .find_by_id(group_chat_id)?; let event = group_chat.post_message(message)?; self.group_chat_repository.store(&event, &group_chat)?; // (1) self.event_publisher.publish(&event)?; // (2) } fn execute(&mut self, param: &Param) -> Result<(), UseCaseError> { let mut group_chat = self.group_chat_repository .find_by_id(group_chat_id)?; let event = group_chat.post_message(message)?; self.event_publisher.publish(&event)?; // (2) self.group_chat_repository.store(&event, &group_chat)?; // (1) }
  6. CQS CQRS = Command Query Responsibility Segregation, コマンドクエリ責務分離 は、CQS =

    Command Query Separation, コマンドクエリ分離原則 の背景にあるアイデアをサービスのレベルまで拡張させたもの CQS をまず理解しよう。例えばリポジトリで考えると以下のようになる // CQS 違反のリポジトリ ( こういう設計はわかりにくいしスケールしないので避けよう!!!) trait GroupChatRepository { // 書き込んだ後に読み込んで返すの?RDB であればリーダーから読むのではなくライターから読むつもりなのか?? fn store(&mut self, group_chat: &GroupChat): Result<GroupChat, IOError>; // &mut self って内部で書き込みが起きるの?読み込みのメソッドなのに?? fn find_by_id(&mut self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } // CQS に沿ったリポジトリ trait GroupChatRepository { // 書き込み特化 fn store(&mut self, group_chat: &GroupChat): Result<(), IOError>; // 読み込み特化 fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; }
  7. CQRS CQRS = Command Query Responsibility Segregation, コマンドクエリ責務分離 実装イメージは以下。 ただ単に定義を二つにするだけでは不十分。モジュールレベルで明確にお互いが文字通り”

    隔離” されていな いといけない。C/Q は混ざってはいけません。 // コマンド用 trait GroupChatRepository { // 集約の更新 fn store(&mut self, group_chat: &GroupChat): Result<(), IOError>; // 更新のための読み込み。find_by_id したら最後にstore を呼ぶ必要がある fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } // クエリ用 trait GroupChatReadModelDao { // リードモデルの更新( リードモデル構築用。API からは利用しない) fn upsert(&mut self, group_chat: &GroupChatReadModel): Result<(), IOError>; // リードモデルの問い合わせ fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChatReadModel>, IOError>; // fn find_by_...(...); }
  8. CQRS の 基本的な構造 コマンド側(C) とクエリ側(Q) に分割されている C Q コマンド 集約(

    ドメインモデル群) ライトDB( ジャーナルDB) リードDB リードモデル クエリ 呼び出す 取得する
  9. 【コマンド】ドメインモデル メソッドを実行すると、状態が遷移しイベントが生成される CPU とメモリを中心に使う。永続 化とは距離を置く // ドメインモデル(正規型) pub struct GroupChat

    { id: GroupChatId, deleted: bool, name: GroupChatName, members: Members, messages: Messages, } impl GroupChat { // リネーム pub fn rename(&mut self, name: GroupChatName, executor_id: UserAccountId) -> Result<GroupChatEvent, GroupChatError> { // ... } // メンバーの追加 pub fn add_member(&mut self, member_id: MemberId, user_account_id: UserAccountId, role: MemberRole, executor_id: UserAccountId, ) -> Result<GroupChatEvent, GroupChatError> { // ... } // メッセージの投稿 pub fn post_message(&mut self, message: Message, executor_id: UserAccountId) -> Result<GroupChatEvent, GroupChatError> { // ... } }
  10. 【コマンド】ビジネスロジック リポジトリは閲覧ではなく振る舞いを起こす(書き込み)のための責務となる ユースケースなどでの使い方 trait GroupChatRepository { // 書き込みメソッド fn store(&mut

    self, event: &GroupChatEvent, group_chat: &GroupChat): Result<(), IOError>; // 書き込みのための読み込みメソッド。ID 以外の問い合わせは不要 fn find_by_id(&self, group_chat_id: &GroupChatId): Result<Option<GroupChat>, IOError>; } let mut group_chat = group_chat_repository.find_by_id(group_chat_id)?; // イベント集合から集約のリプレイ let group_chat_event = group_chat.post_message(message, executor_id)?; // ビジネスロジックの起動 group_chat_repository.store(&group_chat_event, &group_chat)?; // イベントをひたすら追記する。group_chat の保存は必須ではない
  11. Event Sourcing AggregateId Sequence Number Payload GroupChat- 01890535-c59c- 72d5-08a8- dcea316374c8

    1 {"type":"GroupChatCreated","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535-c59c- 72d5-08a8-dcea316374c8"},"seq_nr":1,"name":"test","members":{"members_ids_by_user_account_id": {"01H42KBHCWBDTZYQ7P78T8BTWX":"01H42KBHCWA8NE32M49YH544H1"},"members": {"01H42KBHCWA8NE32M49YH544H1":{"id":"01H42KBHCWA8NE32M49YH544H1","user_account_id": {"value":"01890535-c59c-5b75-ff5c-f63a3485eb9d"},"role":"Admin"}}},"occurred_at":"2023-06- 29T03:32:37.404481Z"} GroupChat- 01890535-c59c- 72d5-08a8- dcea316374c8 2 {"type":"GroupChatMessagePosted","id":"01H42KBHCW1BZG504J4ZXKA2F2","aggregate_id":{"value":"01890535- c59c-72d5-08a8-dcea316374c8"},"seq_nr":2,"message":"test","sender_id": "01890535-c59c-5b75-ff5c- f63a3485eb9d","occurred_at":"2023-06-29T03:32:37.404481Z"} 概念的には空の集約に適用すると最新状態になる 実際にはスナップショットに保存された集約に対してイベントを適用する let aggregate_latest = GroupChat::replay(events, GroupChat::empty())?; let aggregate_latest = GroupChat::replay(events, latest_snapshot)?;
  12. 【コマンド】集約のリプレイ イベントを読み込んだら、集 約に適用する イベントはコマンドに変換さ れ再実行される impl GroupChat { pub fn

    replay(events: Vec<GroupChatEvent>, snapshot: GroupChat) -> Self { events.iter().fold(snapshot, |mut result, event| { result.apply_event(event); result }) } fn apply_event(&mut self, event: &GroupChatEvent) { match event { // ... GroupChatEvent::GroupChatMessagePosted(body) => { self .post_message(body.message.clone(),body.executor_id.clone()) .unwrap(); } // ... } } }
  13. 【クエリ】非正規型のリードモデル pub struct GroupChat { id: String, name: String, owner_id:

    String, owner_name: String, members_count: u32, created_at: NaiveDateTime, updated_at: NaiveDateTime, } pub struct Member { id: String, group_chat_id: String, group_chat_name: String user_account_id: String, user_account_name: String, role: String, created_at: NaiveDateTime, updated_at: NaiveDateTime, } pub struct Message { id: String, group_chat_id: String, group_chat_name: String, user_account_id: String, user_account_name: String text: String, created_at: NaiveDateTime, updated_at: NaiveDateTime, }
  14. 【クエリ】データアクセスオブジェクト クライアントが求めるデータを返す pub trait GroupChatDao { fn get_group_chat(&self, group_chat_id: String,

    user_account_id: String,) -> Result<GroupChat, GroupChatDaoError>; fn get_group_chats(&self, user_account_id: String) -> Result<Vec<GroupChat>, GroupChatDaoError>; } pub trait MemberDao { fn get_member(&self, group_chat_id: String, user_account_id: String) -> Result<Member, MemberDaoError>; fn get_members(&self, group_chat_id: String, user_account_id: String) -> Result<Vec<Member>, MemberDaoError>; } pub trait MessageDao { fn get_message(&self, message_id: String, user_account_id: String) -> Result<Message, MessageDaoError>; fn get_messages(&self, group_chat_id: String, user_account_id: String) -> Result<Vec<Message>, MessageDaoError>; }
  15. CQRS/Event Sourcing の 基本的な構造 C とQ を繋ぐのがイベント Event Sourcing は状態がイベントに基づくことであり、Pub/Sub

    のことではない C Q コマンド 集約( ドメインモデル群) イベント ジャーナルDB プロジェクション リードDB リードモデル クエリ 呼び出す ⽣成する 追記する 状態再⽣ Pub/Sub 変換する 取得する
  16. 【解決】CQRS/ES はダブルライトせずに通知できる CQRS/ES では通知のため にダブルライトしない メッセージブローカーを 間に挟めば、容易に別シ ステムにイベントを通知 できる この方法は安全ではある

    が、レイテンシが必要に なる C Q コマンド 集約( ドメインモデル群) イベント ジャーナルDB プロジェクション リードDB リードモデル クエリ メッセージブローカー(Kafka or Kinesis) 別システム 呼び出す ⽣成する 追記する 状態再⽣ 変換する 取得する CDC
  17. CQRS/ES をサポートするフレームワークやライブラリ Object based Axon Framework: Java event-store-adapter: TypeScript, Go,

    Rust, Java, Kotlin, Scala, PHP cqrs-es-example Actor based akka: Java, Scala pekko: Java, Scala