December 2016

Volume 31 Number 13

Cutting Edge - イベントと CQRS による CRUD システムの再作成

Dino Esposito

Dino Esposito作成 (Create)、読み取り (Read)、更新 (Update)、削除 (Delete) から成り立つ CRUD システムは、至るところで使用されています。このシステムは、リレーショナル データベースを中心に構築され、ビジネス ロジックの集合体が埋め込まれます。時には、ストアド プロシージャに収容されたり、ブラック ボックス コンポーネントに含められることもあります。このようなブラック ボックスの中核となるのが、新しいエンティティの作成、読み取り、更新、削除の 4 つを行う CRUD 操作です。どのようなシステムでも抽象化のレベルを突き詰めていけばすべて (ある程度は) CRUD に落ち着きます。時折、エンティティが極めて複雑になることがありますが、その場合は集計の形を取るのが一般的です。

ドメイン駆動型設計 (DDD) での集計では、エンティティをビジネス関連の集合体とし、ルート オブジェクトを用意します。そのため、1 つエンティティの作成、更新、さらに削除は、いくつか複雑なビジネス ルールが課せられる可能性があります。集計の状態を読み取るだけでも問題が起きるのが普通です。その理由の大半は UX です。システム状態を変更するモデルと、ユーザーにデータを表示するモデルは、すべてのユースケースで同じにする必要はありません。

CRUD の抽象化を最高レベルまで引き上げていくと、システム状態を変更するモデルは、システムの 1 つまたは複数のビューを単純に返すモデルから分離されていきます。これが、コマンド クエリ責務分離 (CQRS: Command Query Responsibility Segregation) の本質に他なりません。すなわち、コマンドとクエリの責務が見事に分離されます。

ただし、ソフトウェア アーキテクトや開発者が考慮しなければならないことはそれだけではありません。システムの状態はコマンド スタックで変更します。具体的には、まず集計をコマンド スタックで作成します。そして、後から同じ集計をコマンド スタックで更新、削除します。まさに、ここが考え直すポイントです。

ほぼすべてのソフトウェア システムにとって履歴を保持することは重要です。ソフトウェアは現在行っているビジネスをサポートするために作成します。そのため、過去から学ぶことは次の 2 つの理由で重要です。1 つは起きている事柄を 1 つも見失わないためです。もう 1 つは顧客や従業員へのサービスを改善するためです。

2016 年 5 月 (msdn.com/magazine/mt703431) と 2016 年 6 月 (msdn.com/magazine/mt707524) のコラムでは、従来の CRUD を履歴 CRUD に拡張する方法を取り上げました。2016 年 8 月 (msdn.com/magazine/mt767692) と 2016 年 10 月 (msdn.com/magazine/mt742866) のコラムでは、イベント - コマンド-サガ (ECS) パターンと MementoFX フレームワーク (bit.ly/2dt6PVD) を、日常業務のニーズを満たすビジネス ロジックの新しい表現方法のビルディング ブロックとして紹介しました。

今回と次回のコラムでは、サンプルの予約アプリケーション (5 月 と 6 月のコラムで使用したもの) を、CQRS とイベント ソーシングを使って作り直し、前述のシステムで履歴を保存する 2 つのメリットを生かします。

全体像

サンプル アプリケーションは、社内の会議室予約システムです。主なユース ケースは、予定表をスクロールして、特定の会議室の空き時間帯を 1 つ以上予約する利用者を記録することです。システムでは、Room、RoomConfiguration、Booking などのエンティティを管理します。アプリケーション全体が行うのは、概念的には、会議室と構成 (つまり、会議室が空いているかどうかと、1 つの時間帯の長さ) を追加、編集することと、予約の追加、更新、キャンセルを行うことです。図 1 は、このシステムのユーザーが実行できる操作と、こうした操作を ECS パターンに沿って CQRS システムで設計する方法を示しています。

ユーザーの操作とシステムの設計概要
図 1 ユーザーの操作とシステムの設計概要

ユーザーは、新規予約の入力、予約の変更やキャンセルが可能です。予約された会議室が実際に使用されていることをシステムが把握するために、ユーザーは会議室にチェックインすることもできます。各操作を背後でサポートするワークフローは、サガで処理します。サガはコマンド スタックで定義するクラスです。サガ クラスは、複数のハンドラー メソッドから構成し、このメソッドで各コマンドやイベントを処理します。予約すること (または既存の予約を変更すること) は、コマンドをコマンド スタックにプッシュすることにすぎません。一般的に言えば、コマンドをプッシュすることは、対応するサガ メソッドを直接呼び出すことにすぎません。あるいは、バスのサービスを利用することにすぎません。

履歴を保存するには、少なくとも、処理した任意のコマンドのビジネス効果をすべて追跡する必要があります。場合によっては、本来のコマンドを追跡することも必要です。コマンドとは、入力データを搬送するデータ転送オブジェクトです。サガを通じてコマンドを実行することで得られるビジネス効果がイベントです。イベントとは、そのイベントを完全に表すデータを搬送するデータ転送オブジェクトです。イベントは、特定のデータ ストアに保存します。イベントに使用するストレージ テクノロジには、厳密な制約はありません。プレーンなリレーショナル データベース管理システム (RDBMS) でも NoSQL データ ストアでもかまいません (MementoFX と RavenDB のセットアップとバス に関する 10 月のコラムを参照してください)。

コマンドとクエリの調整

では、ユーザーがある会議室を特定の時間帯に予約するために、コマンドを実行するとしましょう。ASP.NET MVC シナリオでは、コントローラーがポストされたデータを取得して、コマンドをバスに配置します。バスは、少数のサガを認識するように構成します。その各サガは、処理対象のコマンド (またはイベント) を宣言します。したがって、バスはサガにメッセージをディスパッチすることになります。サガの入力は、ユーザーが UI フォームに入力したデータそのものです。サガのハンドラーの役割は、ビジネス ロジックと調和する集計のインスタンスに、受け取ったデータを引き渡すことです。

ここでユーザーがクリックして予約するとします (図 2 参照)。ボタンによってトリガーされるコントローラー メソッドは、会議室の ID、日時、およびユーザー名を受け取ります。サガ ハンドラーは、このデータを独自の Booking 集計に変換して、想定されるビジネス ロジックに対処する必要があります。ビジネス ロジックでは、アクセス許可、優先順位、コスト、さらにはプレーンな同時実行についての懸案事項に効率よく対処することになります。ただし、最低限の役割として、このサガ メソッドでは Booking 集計を作成して保存しなければなりません。

サンプル システムでの会議室の予約
図 2 サンプル システムでの会議室の予約

一見すると、図 3 のコード スニペットは、ファクトリと特徴的な Repository プロパティを使用している以外は、プレーンな CRUD と変わりありません。ファクトリとリポジトリを組み合わせて使用する効果は、Booking クラスの実装内でトリガーされるすべてのイベントを構成済みのイベント ストアに書き込むことです。

図 3 サガ クラスの構造

public class ReservationSaga : Saga,
  IAmStartedBy<MakeReservationCommand>,
  IHandleMessages<ChangeReservationCommand>,
  IHandleMessages<CancelReservationCommand>
{
   ...
  public void Handle(MakeReservationCommand msg)
  {
    var slots = CalculateActualNumberOfSlots(msg);
    var booking = Booking.Factory.New(
      msg.FullName, msg.When, msg.Hour, msg.Mins, slots);
    Repository.Save(booking);
  }
}

結局、リポジトリは Booking クラスの現在状態を含むレコードを保存しません。このクラスのプロパティがなんらかの方法で列にマップされています。リポジトリは、ビジネス イベントをストアに保存するだけです。そのため、この段階で正確に認識できるのは、予約に何が行われた (いつ、どのように予約が作成された) かにすぎません。標準的な情報をすべてユーザーに表示する準備は整っていません。つまり、何が起きたかは理解できても、ユーザーに表示できる情報がないという状態です。図 4 に、ファクトリのソース コードを示します。

図 4 ファクトリのソース コード

public static class Factory
{
  public static Booking New(string name, DateTime when,
    int hour, int mins, int length)
  {
    var created = new NewBookingCreatedEvent(
      Guid.NewGuid(), name.Capitalize(), when,
      hour, mins, length);
    // Tell the aggregate to log the "received" event
    var booking = new Booking();
    booking.RaiseEvent(created);
    return booking;
  }
}

ファクトリでは、新しく作成された Booking クラスのインスタンスのどのプロパティにもアクセスしていませんが、イベント クラスを作成し、インスタンスに保存するために、実際のデータを設定しています。設定するデータは、大文字表記の顧客名、システム全体から予約を恒久的に追跡するための一意 ID などです。イベントは MementoFX フレームワークに含まれる RaiseEvent メソッドに渡します。イベントはすべての集計の基本クラスになるためです。RaiseEvent は受け取ったイベントを内部リストに追加します。リポジトリは、集計のインスタンスを「保存」する際にこのリスト全体を調べます。「保存」という言葉を用いたのは、単純に何が行われるかを表現するためです。ただし、従来の CRUD とは異なる種類の操作が行われることを強調するために、鍵カッコで囲んでいます。リポジトリは、特定の日付で作成された予約を表すイベントを保存します。もっと正確に言えば、リポジトリは、ビジネス ワークフロー (サガ ハンドラー イベント) の実行中に集計のインスタンスに記録されるすべてのイベントを保存します (図 5 参照)。

イベントの保存と状態の保存の比較
図 5イベントの保存と状態の保存の比較

しかし、コマンドの結果として生成されるビジネス イベントを追跡するだけでは十分ではありません。

イベントのクエリ スタックへの非標準化

データの履歴を保持するという観点から CRUD を見ると、エンティティの作成や読み取りは履歴に影響しませんが、更新と削除は影響することがわかります。イベント ストアは追加のみのデータ ストアです。更新と削除は、同じ集計に関連する新しいイベントにすぎません。ただし、特定の集計用にイベントのリストを用意すれば、現在状態を除いて、履歴に関するすべてのことを把握できるようになります。ところが、ユーザーに表示する必要があるのは現在状態です。

ここで、その役割を果たすのがデノーマライザーです。デノーマライザーとはイベント ハンドラーのコレクションとして構築されるクラスで、イベントとまったく同様にイベント ストアに保存されます。バスにデノーマライザーを登録します。バスは、イベントを取得するたびにそのイベントをデノーマライザーにディスパッチします。予約の作成済みイベントをリッスンするように作成したデノーマライザーに、イベントがトリガーされるたびにそれに対応するチャンスが与えられることが実質的な効果です。

デノーマライザーはイベントのデータを取得し、必要な操作をすべて実行します。たとえば、クエリが容易なリレーショナル データベースと記録済みのイベントとの同期を保ちます。リレーショナル データベース (または、使いやすく、使うメリットがある場合は、NoSQL ストアやキャッシュ) はクエリ スタックに所属し、その API はイベントの格納リストにアクセスできません。複数のデノーマライザーを用意し、同じイベントに手を加えずにアドホック ビューを作成することもできます (次回のコラムで掘り下げるテーマです)。 図 1 では、ユーザーが時間帯を選ぶ予定表は、プレーンなリレーショナル データベースから設定しています。前述のように、このリレーショナル データベースはデノーマライザーの操作によってイベントとの同期を保ちます。デノーマライザー クラスのコードについては、図 6 を参照してください。

図 6 デノーマライザー クラスの構造

public class BookingDenormalizer :
  IHandleMessages<NewBookingCreatedEvent>,
  IHandleMessages<BookingMovedEvent>,
  IHandleMessages<BookingCanceledEvent>
{
  public void Handle(NewBookingCreatedEvent message)
  {
    var item = new BookingSummary()
    {
      DisplayName = message.FullName,
      BookingId = message.BookingId,
      Day = message.When,
      StartHour = message.Hour,
      StartMins = message.Mins,
      NumberOfSlots = message.Length
    };
    using (var context = new MfxbiDatabase())
    {
      context.BookingSummaries.Add(item);
      context.SaveChanges();
    }  }
  ...
}

図 5 を見てみると、デノーマライザーは読み取り専用のリレーショナル CRUD を提供しています。デノーマライザーの出力は、多くの場合「読み取りモデル」と呼ばれます。 この読み取りモデルのエンティティは UI を必要とすることが多いため、通常、イベントの生成に使われる集計とは一致しません。

更新と削除

ユーザーが、以前に予約した時間帯の変更を希望しているとします。新しい時間帯の全詳細と、特定の予約に対する Moved イベントの書き込みを担当するサガ メソッドを指定して、コマンドを配置します。このサガでは集計を取得する必要があり、その集計の更新後の状態が必要です。デノーマライザーが集計の状態のリレーショナル コピーを作成するだけであれば、(読み取りモデルとドメイン モデルがほぼ一致するため)、そこから更新後の状態を取得できます。そうでない場合は、集計の新しいコピーを作成し、そのコピーに対して記録されているイベントをすべて実行します。再生が終わると、集計はほぼ更新後の状態になります。イベントの再生は、ユーザーが直接実行すべきタスクではありません。MementoFX では、サガ ハンドラー内のコード行を使って、更新後の集計を取得します。

var booking = Repository.GetById<Booking>(message.BookingId);

次に、必要なすべてのビジネス ロジックをそのインスタンスに適用します。ビジネス ロジックは、イベントを生成します。そのイベントは、リポジトリを介して保存します。

booking.Move(id, day, hour, mins);
Repository.Save(booking);

ドメイン モデル パターンを使用し、DDD の原則に従う場合、Move メソッドにすべてのドメイン ロジックとイベントを含めます。それ以外の場合は、任意のビジネス ロジックを含む関数を実行し、バスに対してイベントを直接発生させます。デノーマライザーに別のイベント ハンドラーをバインドすると、読み取りモデルを更新する機会を得られます。

このアプローチは、予約のキャンセルと同じです。予約をキャンセルするイベントは、ビジネス イベントなので、追跡する必要があります。つまり、論理的な削除を実行するために、集計にはブール型のプロパティを用意する必要があります。ただし、読み取りモデルでは、アプリケーションから読み取りモデルにキャンセル済みの予約をクエリする予定があるかどうかによって、削除を物理的に行える場合もあります。興味深い二次効果として、イベントを始めから、または回復ポイントから再生することで、いつでも読み取りモデルを再構築できます。必要なのは、イベントを読み取り、デノーマライザーを直接呼び出すためにイベント ストアの API を使用するアド ホック ツールを作成するだけです。

イベント ストア API の使用

図 2 のドロップダウン リストの選択肢を見てみます。ユーザーは、開始時刻からできる限り長く予約を表示したいと考えます。集計のビジネス ロジックでは、この希望を実現する必要があります。そのためには、その日の開始時刻からの予約リストにアクセスしなければなりません。これは、従来の CRUD でも簡単にできますが、MementoFX でもイベントをクエリするだけです。

var createdEvents = EventStore.Find<NewBookingCreatedEvent>(e =>
  e.ToDateTime() >= date).ToList();

このコード スニペットは、特定の時間からの NewBookingCreated イベントのリストを返します。ただし、作成された予約が有効で、他の時間帯に移動されていないとは保証されません。実際には、集計の更新後の状態を取得する必要があります。アルゴリズムに特に決まりはありません。たとえば、作成済みイベントのリストから有効ではなくなった予約をフィルターで除外し、残りの予約の ID を取得します。最後に、重複を避けながら、表示を広げたい予約に対して、実際の時間帯をチェックします。今回のソース コードでは、コマンド スタックの個別の (ドメイン) サービスに、このロジックをすべてコーディングしています。

まとめ

CQRS とイベント ソーシングの利用は、同時実行性、拡張性、パフォーマンスに対するハイエンドな要件を持つ特定のシステムに限られません。集計とワークフローを操作できるインフラストラクチャを使用すれば、今日のどのような CRUD システムも、多くのメリットをもたらすように作り直すことができます。メリットは次のとおりです。

  • データ履歴の保持
  • 問題発生のリスクを抑え、限られた作業でビジネスの変化を反映させる、より効果的で回復力のあるビジネス タスクの実装とタスクの変更
  • イベントは不変なので、イベントをコピーするのが容易で、読み取りモデルもプログラムから自由に再生成できる

つまり、ECS パターン (別称、CQRS/ES) には、スケーラビリティについて大きな可能性が秘められています。ここでも MementoFX フレームワークが役立ちます。MementoFX フレームワークは一般的なタスクを簡素化し、プログラミングを簡単にするために集計の抽象化を提供します。

MementoFX は DDD 指向のアプローチに効果がありますが、関数型パラダイムなどの他のパラダイムやフレームワークと共に ECS パターンを使用することも可能です。もう 1 つ、おそらく最も重要なメリットがあります。詳しくは、次回のコラムで取り上げます。


Dino Esposito は、『Microsoft .NET: Architecting Applications for the Enterprise』(Microsoft Press、2014 年) および『Modern Web Applications』(Microsoft Press、2016 年) の著者です。JetBrains の .NET および Android プラットフォームのテクニカル エバンジェリストでもあります。世界各国で開催される業界のイベントで頻繁に講演しており、software2cents.wordpress.com (英語) や Twitter (@despos、英語) でソフトウェアに関するビジョンを紹介しています。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Andrea Saltarello に心より感謝いたします。