C#とCosmosDBによる自作イベントソーシングフレームワークの設計とコンセプト
株式会社ジェイテックジャパン CTOの 高丘 です。最近私たちは社内のプロジェクト用に独自イベントソーシングフレームワーク『Sekiban』を設計開発しています。この記事では、どのような経緯でイベントソーシングを採用したのか、C#とCosmosDBを使用してフレームワークをどのように設計しているのかを書きたいと思います。
2023/12/17追記:
ジェイテックジャパンで開発してきた、イベントソーシング・CQRSフレームワーク、「Sekiban」をリリースしました! www.sekiban.dev/jp/
前提
今回の記事は、「イベントソーシングとは何か」などの基本的な話は含んでいません。対象読者は、イベントソーシングの実装とデータベース設計に関心がある方です。また別の記事で、イベントソーシングの基本や、イベントソーシングでシステムを作ったときのメンテナンスプランなどについても書くことができたらと思います。
イベントソーシングを採用した背景
イベントソーシングは最近日本の開発者の中でも話題となってきていますが、基本的にマイクロサービスや大規模システムの背景で語られることが多く、イベントソーシングとCQRSは大規模システムにスケールしても安定した動作をするアーキテクチャとして用いられています。
ジェイテックジャパンでは主に企業向けビジネスアプリケーション、データベースシステムを開発していて、コンシューマ向けのシステムに比べて、データが少なく、マイクロサービス化すると言っても、片手で数えられるくらいのサービスを各システムで並行運用しています。と言っても、AzureではFunctionsによる並列処理などのクラウドによる分散システムの恩恵は受けています。
ビジネスアプリケーションはデータ量はコンシューマー向けの物に及びませんが、ロジックが複雑になることがあり、内部的な計算や集計、それによるビジネス分析に必要な情報を作成するという、ドメインモデルをつかった開発にあったユースケースが多くあります。
今回チームメンバーで新しいシステムのアーキテクチャを考慮するにあたり、これまでの SQL Serverを使用したRDBシステムで起きる問題を考慮しました。RDBシステムだとどうしても分散したサーバーインスタンスから扱うとトランザクションの管理が難しく、トランザクションエラーが発生したらしばらくしてリトライすることによってデータの一貫性を維持していました。そのため、基本的に追記で扱い、トランザクションエラーが発生しにくいアーキテクチャであるNoSQLやイベントソーシングについて調査を始めました。これまでAzure及びC#を基本的に使用しており、C#の言語としてはとても気に入っているため、新規プロジェクトの基盤としてCosmosDBを使ったビジネスアプリケーション用のイベントソーシングフレームワークを開発することにしました。
以下、イベントソーシングに特化した言葉をいくつか使っています。
集約 : ドメインエンティティ、つまり入出力のあるデータの一単位のことですが、イベントソーシング的にはAggregateRootという言葉がよく使われます。レコードとは違いますが、レコードやその詳細レコードを含んだ1つのオブジェクトのことを指すかと思います。
プロジェクション:集約内、もしくはイベント全体のイベントをリプレイして、その集約の現在のステータスを取得する作業、(投影ともいうかもしれません。)
設計及び開発方針
個人的にはGreg Youngの話がとても分かりやすく、2014年の以下の講演を筆頭に一通りの彼の講演をもとに、すでにできたフレームワークを使うのではなく、自前で用途に合うフレームワークを構築していくことにしました。英語がわからなくても、字幕を日本語に自動翻訳しても見れるため、おすすめです。(やはり英語のできないメンバーには難しく感じるようですが、Greg Youngの作成したPDFの日本語版を訳してくださった方がいるので、そちらもおすすめです。)
CQRS Documents by Greg Young 和訳版
インフラとしては、基本的にシンプルな構成を考えていて、
- App ServiceによるAPIサーバー
- バックグラウンド処理をAzure Function で行う
- 順序が重要になる場合は Durable Function を使用する
- メッセージングとして、EventGrid、クライアント通知にはSignalRを使用
- CosmosDbにイベント、コマンド、スナップショットなどを保存する
これくらいの構成でスタートする予定です。
日本語での情報は かとじゅんさん や nrsさん がサンプルプログラムを含め、多くの情報を公開してくださっているので、とても参考になります。
イベントソーシングフレームワーク自作について
先日かとじゅんさんからDynamoDbを使用したCQRS/イベントソーシングシステムの構築方法について書いた記事を紹介いただき、読んでみたところ、とてもシンプルおよび効率的に構成されていました。最新のモデルステートを保存しているのが興味深いと思いましたが、実際にはこの構成によるパフォーマンスの確保はできそうで、とても参考になりました。私たちのプロジェクトでCosmosDBの変更フィードをどのように使用するかは迷っているのですが、DynamoDbの同じような機構があり、しかもDAXフィードというキャッシュシステムがあるのはとても良いなと感じました。
基本的に、集約ごとのパーティションを分けることのできるドキュメントデータベースであれば、イベントソーシングの実装は可能です。特に大規模システムでない場合、集約をプロジェクションしたオブジェクトをインメモリで管理し、それをクエリモデルとするところからスタートして、必要に応じてマテリアライズドビューやキャッシュを導入していくことが可能になると考えています。
まだ実装途中で完成していないのですが、記録も兼ねて、現在のCosmos DBを使ったデータ設計を書いておきます。特にデータ量が小なめの顧客に対して、データベースとWebの費用の合計を月毎に数千円-1万円レベルで収めるための工夫についての記事は英語でのリソースを含め、あまりないと思います。
Cosmos DB の使用方針
Cosmos DBはMicrosoft Azureの提供するドキュメント型データベースで、内部の実装はSQL、Mongo、Cassandraなど分かれており、それぞれにより機能が違います。当プロジェクトでは、デフォルト実装のSQL実装を使用しています。
プロビジョニングスループットか、サーバーレスか
こちらCosmos DBを使うときに最初に悩む点です。プロビジョニングスループットを使用すると、最低が400RU/秒からのスタートですので、最低がコンテナごとに約3300円となります。これでイベント、コマンド、スナップショットなどを混ぜたくない場合、それだけで1万円近くなってしまいます。対して、サーバーレスはRU(消費リソース)毎の費用がプロビジョニングスループットの倍近くとなります。それをみて、サーバーレスを敬遠していたのですが、CosmosDbはWeb上でクエリのRU(消費リソース)を確認できるので、テストしたところ、シンプルなクエリを使用する場合、使用するRUは非常に少ないということがわかり、サーバーレスを使用することにしました。データがかなり増えるまで、サーバーレス前提で設計して問題ないと思います。半月くらいサーバーレスでテストのイベントの読み書きをしていますが、今だ費用は数十円レベルです。
パーティションに関して
ドキュメント型データベースの特徴として、バーティション毎の読み書きが効率的に行えるという利点があります。その利点を最大限に活用する方法として、Greg Youngも説明していましたが、「集約毎のイベントを一括で効率よく取得することができる」ことがイベントソーシングのデータベースの要求となります。そのため、イベントコンテナに関しては、集約毎にパーティションを切っています。ただ、複数の集約が同じAggregateIdを使用することを許容するため、
$"{AggregateTypeName}_{AggregateId}"
をパーティションキーとしています。
並べ替え順に関して
集約内のイベントの順序は、イベントソーシングにとって最も大切なパラメーターです。これがズレると計算結果が変わるため、非常に大切です。例えば、ポイントを足すイベントと、2倍にするイベントがある場合
- ある時点の計算結果 (現在ポイント10)
- イベントを追加(3ポイント追加) (現在ポイント13となる)
- イベントを追加(現在ポイントを2倍するイベント) (現在ポイント26となる)
が正しいとします。イベントの順番が変わると
- ある時点の計算結果(現在ポイント10)
- イベントを追加(現在ポイントを2倍するイベント) (現在ポイント20となる)
- イベントを追加(3ポイント追加) (現在ポイント23となる)
となり、結果がおかしくなります。そのため、イベントの順番は客観的にみて順番が確実にわかるようにすることが必須となります。一つの解決方法は楽観ロックを使用して、参照の対象バージョンに対してしかイベントを追加できないようにする方法です。この方法は多くの場合にうまくいきますが、並列システムからのデータ追加で同時書き込みが失敗することが多くなってしまいます。そのため当システムでは、楽観ロックを使う場合も、使わない場合も選択できるようにしました。
それにより、最近起きたイベントなどのランダムなソースからイベントを受け取り、楽観ロックを使わなくても良い集約などを設計することが可能となります。
CosmosDbはパーティション内の順番が決まっており、"_ts"と言うパラメータに保存時間が記録されています。ただ、これに問題があります。同じ集約内でも、同時に複数イベントを置くと、ミリ秒単位の時間 "_ts"が同じになってしまいます。かといって、CosmosDb内の順番は取得した順となるのですが、プログラム上で "_ts" が同じで、データとして順番を担保できるキーデータがないと扱いにくいと言う問題が発生しました。そのため、当システムでは自前の並べ替え可能及び一意のIDをフレームワークの方で付与することにしました。マイクロ秒単位の時間+Guidのハッシュ値を使用して、30桁の数字でのIDとして、これを並べ替えることによる、複数のイベントの順番を決定できるキーとして定義しました。
これにより、イベントを同時書き込みすることが可能になります。ただ、CosmosDbからセレクトするときに、必ず自前の一意IDで Order Byしてからデータを取得しないと、システムとして正しいデータを取得できないと言う問題はあります。幸い、CosmosDbのSQLインスタンスは、自動的にCosmosDB側がよく使われる項目でのインデックスを内部的に生成するため、自前のIDでのOrder Byをする場合としない場合のRUがそれほど変わらないことをドキュメントエクスプローラーから確認できています。
トランザクションに関して
こちらまだ悩んでいる点で、楽観ロックする場合、集約の構成のために読み込むところから、集約に対してイベントを書き込むところまででロックしないと正しくロックできないので、この形でトランザクションをかけると、結局ロックによるトランザクションエラーが起きるのではないかと懸念しています。そのため、上記の自前の一意IDで順番を担保するため、トランザクションをかけずにデータを保存する形を使用しようと考えています。その場合、楽観ロックを使用しても、しなくても、レアケースでイベントの順番が狂ってしまうことがあり得ます。これに関しては、システムとしては許容した上で、各集約の責務として、おかしなデータが存在したときに、打ち消しイベントを発行するようにしたいと考えています。
こちらに関しては、Sagaパターンや、プロセスマネージャをどのようにシンプルかつうまく設定していくかのこれからの課題と考えています。
良い資料、扱い方などありましたら、コメントいただければ嬉しいです。
ストーリーテストコード
上記の設計の動作を確認するために、APIからテストするのには時間や手間がかかるためストーリーテストを作成しフレームワークを調整するたびに流して動作を確認しています。ストーリーには
- 集約作成イベント
- 変更イベント
- 集約によってイベントが発生し、それによって派生して作成される集約
- N回毎に自動的にスナップショットを取得する
- スナップショット作成後はスナップショット及びスナップショット以降のイベントから集約を生成する
- 集約に対して、歴史を辿るなどのカスタムプロジェクションに対応
- 1集約種類の全オブジェクトを生成してリストを作成する
- 楽観ロックを使わずに並列で数十個保存しつつスナップショットもその途中で取得し、スナップショット以降のイベント取得も正しく動作する
上記のコードが含まれており、フレームワークを変更した際にデータをリセットして上記のコードを連続して流すことにより、変更が正しく作成されたかを検証しています。
Sekiban フレームワーク
イベントソーシングの技術はこれから多くのプロジェクトで使用できる技術ですので、まずは社内で共通利用するフレームワークとして開発しています。また、将来的にブラッシュアップして公開することも視野に入れて、フレームワークに名称をつけました。 Sekiban - Event Sourcing Framework と言う名称です。"Sekiban" はイベントソーシングの消さないデータをイメージさせる「石板」から取りました。石板に書いた時のように消えずに安心して利用できるデータソースを目指しています。これからZennでイベントソーシングの開発の奮闘記について時々報告していきたいと思います。
まとめ
これまで社内フレームワークを作って、ストーリーテストコードを流して実験した結果、Cosmos DBを使用したイベントソーシングが実現可能であると確信できるところまで開発が進んできました。会社として昨年より、DDDに取り組んで、成果を上げてきたのですが、イベントソーシングにより、より集約内の一貫性に注意を払いつつ、イベントドリブンで他の集約に対しての影響を定義することができるようになることにより、より不具合が少なく、効率的に開発する道のりが進んできているように感じます。まだ作成中のフレームワークですが引き続き経過についての記事を時々書いていきたいと思います。
Discussion
良い記事でした。いくつかFBさせてください。
・スループット(RU/s) について
サーバレスでは使えませんがプロビジョニングスループットには1サブスクリプションあたり1つのAzure Cosmosアカウントに対して適用できる無料枠(Free tier)があります。毎月、最初の1000RU/25GBまでは無料で使えるものです。消費するRUが少ないという事だったので、こちらも検討してみてください。
・トランザクション(項目更新の順序)について
_ts について触れられていましたが、Cosmos DBには _etag というシステム列もあります。これはCosmos DBが楽観ロック(オプティミスティック同時実行制御)を採用しているために、項目が更新されていないかをPut/Patchする前に確認できるようにしている項目です。ここも見てみるのはいいかもしれません。
またどういうコンテナー設計をしているかによりますが、場合によってはストアドやUDFなどを使って処理順を担保したり、一貫性レベルをより強いものにして(処理スピードを少し犠牲にして)データ更新の一貫性を維持したりもしたりします。見てみてもらえればと思います。
まっぴぃさん、お返事ありがとうございました!参考になる情報をありがとうございます。
・スループット(RU/s) について
こちらはとてもお得ですね。ドキュメントを読んでみた理解では、1コンテナおよび、Change Feedあたり最低400RU/sを計上するとのことで2コンテナ分くらいは無料になるのかなと考えています。今の所、1アプリで、最低2コンテナ(1つはイベント用、1つはコマンド用)を考えているので、無料枠の2つでスタートしてみるのも考慮してみたいと思います。使うに従って、Change Feed、Analyze Data なども使用していきたいと思っています。
・トランザクション(項目更新の順序)について
こちらもありがとうございます。現在のところ、イベント用のコンテナは追記のみを考えているのですが、スナップショットや、Readモデル用のステート、また、認証用に.net core Identity のデータなどは更新も使用する予定ですので、 _etag も活用していきたいと思います。
CosmosDBを使い始めてまだ数ヶ月で、勉強することばかりですが、使ってみた感想としては、多くのシステムで採用できそうだと感じています。まっぴぃさんの記事や、#jcdugのタグから学んでいきたいと思います