こんにちは。
今回は、Railsで rails_event_storeを用いたCQRSとイベントソーシングの実装について試してみたので、イベントソーシングとその実装方法について紹介してみます。
Railsでのイベントソーシングについて検討する際の参考になれば幸いです。
なお、今回のサンプルアプリケーションの実装は以下のGitHubにあります。
イベントソーシングとは
イベントソーシングについて話す前に、従来のデータ永続化の方式であるCRUDモデルについて話します。
これにより、CRUDモデルでの課題に対してイベントソーシングがどのように対処しようとしているか理解しやすくなります。
CRUDモデル
従来のアプリケーションでは、データストアに保存するデータを「現在の最新の状態」に限定し、「作成(Create)」「読み取り(Read)」「更新(Update)」「削除(Delete)」の操作を行います。
この方法は「最新の状態の取得が簡単なこと」「データ量が抑えられること」がメリットと言えます。
一方、以下のようなデメリットも存在します。
- データ更新をデータストアに対して直接行うため、トランザクションロック等の操作によりパフォーマンス・スケーラビリティに影響する
- データ更新に対して競合が発生する可能性がある
- 個別にログ等を保持する機能がない限り、変更履歴が残らない
- 現在の状態を保存時に適切に計算するために複雑な処理が発生することがある
イベントソーシング
イベントソーシングでは、最新の状態ではなく、発生した(ドメイン)イベントをデータストアに書き込みます。 最新の状態を取得するためには、イベントを再生して再現します。
これにより、CRUDモデルでの問題に対して、以下のように対応ができます。
- データストアへの「更新」「削除」がないため、トランザクションロック等によるパフォーマンス・スケーラビリティへの影響を減らすことができる
- イベントを記録するだけのため、保存の操作を簡素化できる
- 変更履歴をイベントとして残すことができる
このような特性から、CQRS(コマンドクエリ責務分離)で、使われるパターンとなっています。 CQRSは、データストアの読み取りと更新を分離する手法です。これは、読み取りと更新の様々な非対称性による問題を解決するものです。
ただし、イベントソーシングを利用することで以下のようなデメリットも存在します。
- 最新の状態を取得するためにイベントを再生する必要がある
- イベントが多くなると、最新の状態の取得に必要な時間が増加するため、定期的なスナップショットの作成等の工夫が必要になる
- 最終的な集計データ等は、イベントに対して別途データストアに個別に保存する
イベントソーシングでは、上記のような最新の状態の取得に対する複雑性があります。そのため、CRUDモデルでは難しい問題を解決するために導入するのが望ましいです。
rails_event_storeとは?
Rails上でイベント駆動型アーキテクチャを実現するためのgemです。
具体的にはイベントの Publish / Subscribe、データストアへの保存・取得、保存されたイベントから最新の状態の再現をサポートしています。
rails_event_storeの使い方
rails_event_store自体は色々な使い方や機能がありますが、今回はイベントソーシングを達成するための機能のみに限定します。
導入
公式における導入方法は以下のドキュメントに記載されています。
https://railseventstore.org/docs/v2/install/
まずは Gemfile に gem "rails_event_store"
を書いて、 bundle install
を実行してgemを追加します。
次に必要な設定等を作成するため以下を実行します。
bin/rails app:template LOCATION=https://railseventstore.org/new
イベントの実装
イベントを RailsEventStore::Event
を継承して定義します。このイベント単位でデータベースに保存されます。
class OrderSubmitted < RailsEventStore::Event; end class OrderExpired < RailsEventStore::Event; end
集約の実装
AggregateRoot
をMix-inして集約オブジェクトを実装します。
class Order include AggregateRoot ... end
また、集約オブジェクトでの実際のイベント発行については、以下のように apply
で行います。
class Order include AggregateRoot def submit apply OrderSubmitted.new(data: { ... }) end ... end
イベントでは、initializeでデータとして data
、またメタデータとして metadata
を渡せるようになっています。
また、イベントの再生に必要な実装は以下のように on
を用いて定義します。
class Order include AggregateRoot ... on Domain::Event::OrderSubmitted do |event| @state = :submitted end end
イベントを保存する
イベントの保存には、 AggregateRoot::Repository
を使います。
stream_name = "Order#{id}" repository = AggregateRoot::Repository.new repository.with_aggregate(Domain::Order.new, stream_name) do |order| order.submit end
指定した stream_name
にイベントを紐付けて保存します(複数のストリームにイベントを紐付けることもできますが、今回は割愛します)。
https://railseventstore.org/docs/v2/link/
イベントに対して Subscribeする
イベントに対してSubscribeして、処理を実行する場合は config/initializers/rails_event_store.rb
で次のように行います。
Rails.configuration.event_store.tap do |store| store.subscribe( FooSubscriber.new, to: [OrderPlaced] ) end
rails_event_storeを使ったサンプルアプリの実装
今回は、rails_event_store のドキュメントを参考に、株式の発注をネタにサンプルを実装してみました。
rails_event_storeとしての実装については、すでにある程度説明しているので省略します。
境界づけられたコンテキストに分割してみる
イベントソーシングということで、サンプルアプリケーションではコンテキストごとにモジュラーモノリスとしてパッケージ分割しています。
ActiveRecordオブジェクトに関しては、Repositoryからのみ依存するようにして、それ以外では Domain Objectを使うようにしています。
また、Rubyなのでインターフェースに当たる部分は作らず、RepositoryをControllerからUsecaseに注入する形にしています。
今回は、packages/
以下に各コンテキストごとに分割するようにしました(後述する packwerk の利用を想定しています)。 app/
もそれぞれに追加しています。
app/usecases
: ユースケース(アプリケーションロジック)の実装lib/domain
: ドメインロジックの実装lib/infrastructure
: インフラ層(おもに具象化されたリポジトリ)の実装
各コンテキストに対応するパッケージは config/application.rb
に packages
以下をロードするようにしています。
config.paths.add 'packages/ordering', glob: '{app/*,app/*/concerns,lib}', eager_load: true config.paths.add 'packages/invoicing', glob: '{app/*,app/*/concerns,lib}', eager_load: true
CQRSとして、請求データを確定イベントをSubscribeして保存してみる
以下のようなSubscriberを実装して、取引の確定のイベント( OrderPlaced
)に対して、請求データ( Invoice
)の保存を行います。
# config/initializers/rails_event_store.rb store.subscribe( Invoicing::Subscribers::InvoiceCreated.new(invoice_repository: Invoicing::Infrastructure::InvoiceRepository.new), to: [Ordering::Domain::Event::OrderPlaced] ) # packages/invoicing/app/models/invoicing/subscribers/invoice_created.rb module Invoicing module Subscribers class InvoiceCreated def initialize(invoice_repository: ) @invoice_repository = invoice_repository end def call(event) @invoice_repository.create!(order_id: event.data.fetch(:order_id)) end end end end # packages/invoicing/lib/invoicing/infrastructure/invoice_repository.rb module Invoicing module Infrastructure class InvoiceRepository def create!(order_id:) ::Invoice.create!(order_id: order_id) end def all records = ::Invoice.all records.map { |record| Domain::Invoice.new(order_id: record.order_id) } end end end end
これにより、請求データは別のドメインモデルとして扱うことができます。
packwerkを導入して、パッケージに分割する
rails_event_store ではありませんが、ここまでに出てきたモジュラーモノリスとしてのコンテキストの分割で便利なgemについて紹介しておきます。
適切にパッケージ間の依存関係を守れているか静的チェックできるgemとしてpackwerkというgemがあります。
まとめ
今回は、Railsでイベントソーシングを実装してみました。
なかなか稼働中のアプリケーションで導入する際にはハードルがあると思いますが、思ったよりは簡単に導入できました。
一方で、実装の変更(モジュール階層の変更や命名の変更)を行う際にデータストアのイベント情報が適切に戻せなくなるなどもあるため、そのあたりの難しさも感じました。
今回の記事がみなさんの参考になれば幸いです。