What is it, naokirin?

Railsとrails_event_store で始めるイベントソーシング

こんにちは。

今回は、Railsrails_event_storeを用いたCQRSとイベントソーシングの実装について試してみたので、イベントソーシングとその実装方法について紹介してみます。

Railsでのイベントソーシングについて検討する際の参考になれば幸いです。

なお、今回のサンプルアプリケーションの実装は以下のGitHubにあります。

github.com

イベントソーシングとは

イベントソーシングについて話す前に、従来のデータ永続化の方式であるCRUDモデルについて話します。

これにより、CRUDモデルでの課題に対してイベントソーシングがどのように対処しようとしているか理解しやすくなります。

CRUDモデル

従来のアプリケーションでは、データストアに保存するデータを「現在の最新の状態」に限定し、「作成(Create)」「読み取り(Read)」「更新(Update)」「削除(Delete)」の操作を行います。

この方法は「最新の状態の取得が簡単なこと」「データ量が抑えられること」がメリットと言えます。

一方、以下のようなデメリットも存在します。

  • データ更新をデータストアに対して直接行うため、トランザクションロック等の操作によりパフォーマンス・スケーラビリティに影響する
  • データ更新に対して競合が発生する可能性がある
  • 個別にログ等を保持する機能がない限り、変更履歴が残らない
  • 現在の状態を保存時に適切に計算するために複雑な処理が発生することがある

イベントソーシング

イベントソーシングでは、最新の状態ではなく、発生した(ドメイン)イベントをデータストアに書き込みます。 最新の状態を取得するためには、イベントを再生して再現します。

これにより、CRUDモデルでの問題に対して、以下のように対応ができます。

  • データストアへの「更新」「削除」がないため、トランザクションロック等によるパフォーマンス・スケーラビリティへの影響を減らすことができる
  • イベントを記録するだけのため、保存の操作を簡素化できる
  • 変更履歴をイベントとして残すことができる

このような特性から、CQRS(コマンドクエリ責務分離)で、使われるパターンとなっています。 CQRSは、データストアの読み取りと更新を分離する手法です。これは、読み取りと更新の様々な非対称性による問題を解決するものです。

ただし、イベントソーシングを利用することで以下のようなデメリットも存在します。

  • 最新の状態を取得するためにイベントを再生する必要がある
  • イベントが多くなると、最新の状態の取得に必要な時間が増加するため、定期的なスナップショットの作成等の工夫が必要になる
  • 最終的な集計データ等は、イベントに対して別途データストアに個別に保存する

イベントソーシングでは、上記のような最新の状態の取得に対する複雑性があります。そのため、CRUDモデルでは難しい問題を解決するために導入するのが望ましいです。

rails_event_storeとは?

Rails上でイベント駆動型アーキテクチャを実現するためのgemです。

具体的にはイベントの Publish / Subscribe、データストアへの保存・取得、保存されたイベントから最新の状態の再現をサポートしています。

rails_event_storeの使い方

github.com

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 のドキュメントを参考に、株式の発注をネタにサンプルを実装してみました。

github.com

rails_event_storeとしての実装については、すでにある程度説明しているので省略します。

境界づけられたコンテキストに分割してみる

イベントソーシングということで、サンプルアプリケーションではコンテキストごとにモジュラーモノリスとしてパッケージ分割しています。

ActiveRecordオブジェクトに関しては、Repositoryからのみ依存するようにして、それ以外では Domain Objectを使うようにしています。

また、Rubyなのでインターフェースに当たる部分は作らず、RepositoryをControllerからUsecaseに注入する形にしています。

今回は、packages/ 以下に各コンテキストごとに分割するようにしました(後述する packwerk の利用を想定しています)。 app/ もそれぞれに追加しています。

通常のRailsでは存在しない主なディレクトリは以下です。

  • app/usecases : ユースケース(アプリケーションロジック)の実装
  • lib/domain : ドメインロジックの実装
  • lib/infrastructure : インフラ層(おもに具象化されたリポジトリ)の実装

各コンテキストに対応するパッケージは config/application.rbpackages 以下をロードするようにしています。

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があります。

github.com

まとめ

今回は、Railsでイベントソーシングを実装してみました。

なかなか稼働中のアプリケーションで導入する際にはハードルがあると思いますが、思ったよりは簡単に導入できました。

一方で、実装の変更(モジュール階層の変更や命名の変更)を行う際にデータストアのイベント情報が適切に戻せなくなるなどもあるため、そのあたりの難しさも感じました。

今回の記事がみなさんの参考になれば幸いです。