CQRS パターン

Azure Storage

CQRS はコマンド クエリ責務分離を表し、データ ストアの読み取りと更新の操作を分離するパターンです。 アプリケーション内に CQRS を実装すると、そのパフォーマンス、スケーラビリティ、セキュリティが最大化される場合があります。 CQRS への移行によって生まれる柔軟性により、システムは時間の経過と共にさらに進化し、更新コマンドでドメイン レベルのマージ競合が発生することを防ぐことができます。

コンテキストと問題

従来のアーキテクチャでは、データベースの更新とクエリに同じデータ モデルが使用されます。 このシンプルな方法は、基本的な CRUD 操作に適しています。 ただし、複雑なアプリケーションの場合、このアプローチではうまくいかないことがあります。 たとえば、読み取り側でさまざまなクエリが実行され、形式の異なる複数のデータ転送オブジェクト (DTO) が返される場合もあります。 これにより、オブジェクトのマッピングが複雑になる可能性があります。 また書き込み側のモデルでは、複雑な検証とビジネス ロジックが実装される可能性があります。 その結果、モデルが複雑になりすきる恐れがあります。

読み取りと書き込みのワークロードが不均衡になりやすいため、パフォーマンスやスケールの要件が大きく異なってくる可能性があります。

従来の CRUD アーキテクチャ

  • 読み取りと書き込みのデータ表現が一致しないことがよくあります。具体的には、操作の一部としては必要ないものの、正しく更新しなければならない追加の列やプロパティなどです。

  • 同じデータ セットに対して操作が並列で実行されると、データ競合が発生する可能性があります。

  • 従来のアプローチでは、データ ストアとデータ アクセス層への負荷、および情報を取得するために必要なクエリの複雑さによって、パフォーマンスに悪影響を及ぼす可能性があります。

  • 各エンティティは読み取りと書き込みの両方の操作の対象となるため、セキュリティとアクセス許可の管理が複雑化する可能性があります。これにより、誤ったコンテキストでデータが公開されることがあります。

解決策

CQRS では、データを更新するためのコマンドとデータを読み取るためのクエリを使用し、読み取りと書き込みを別々のモデルに分離します。

  • コマンドは、データ中心ではなく、タスクベースにします。 (「ReservationStatus を Reserved に設定する」などではなく、「ホテルの部屋を予約する」などの形式にします)。 これには、ユーザーによる操作のスタイルに対応する変更が必要な場合があります。 もう 1 つは、これらのコマンドを処理するビジネス ロジックを、より頻繁に成功するように修正することです。 これをサポートする 1 つの手法は、コマンドを送信する前でもクライアント上でいくつかの検証規則を実行し、場合によってはボタンを無効にして、UI 上でその理由を説明することです (「空き部屋がありません」など)。 この方法で、サーバー側のコマンド エラーの原因を競合状態 (最後の部屋を予約しようとしている 2 人のユーザー) に絞り込むことができます。さらにデータとロジック (ゲストを待機リストに入れること) で対処できる場合もあります。
  • コマンドは、同期的に処理するのではなく、非同期処理のキューに配置できます。
  • クエリでは、データベースは変更されません。 クエリでは、ドメイン ナレッジをカプセル化しない DTO が返されます。

絶対条件ではありませんが、次の図に示すようにモデルを分離できます。

基本的な CQRS アーキテクチャ

クエリと更新のモデルを別々にすることで、設計と実装が簡単になります。 ただし、O/RM ツールなどのスキャフォールディング メカニズムを使用してデータベース スキーマから CQRS コードを自動的に生成できないという欠点があります (ただし、生成されたコードの上にカスタマイズをビルドできます)。

分離性を高めるために、読み取りデータと書き込みデータを物理的に分離することもできます。 その場合は、読み取りデータベースでは、クエリ用に最適化された独自のデータ スキーマを使用できます。 たとえば、結合や O/RM マッピングが複雑になるのを回避するために、データの具体化されたビューを格納することもできます。 また、異なる種類のデータ ストアを使用することもできます。 たとえば、書き込みデータベースをリレーショナルにし、読み取りデータベースをドキュメント データベースにすることもできます。

読み取りデータベースと書き込みデータベースを個別に使用する場合は、両者の同期を維持する必要があります。これは通常、データベースの更新時に書き込みモデルでイベントを発行することによって達成されます。 イベントの使用の詳細については、「イベント ドリブン アーキテクチャのスタイル」をご覧ください。 メッセージ ブローカーとデータベースは通常、1 つの分散トランザクションに登録できないため、データベースを更新してイベントを発行する際に、一貫性の保証で問題が発生する可能性があります。 詳細については、べき等メッセージ処理に関するガイダンスを参照してください。

読み取りストアと書き込みストアを分けた CQRS アーキテクチャ

書き込みストアの読み取り専用レプリカを読み取りストアにすることも、読み取りストアと書き込みストアをまったく別の構造にすることもできます。 読み取り専用レプリカを複数使用すると、読み取り専用レプリカがアプリケーション インスタンスの近くに配置されている分散シナリオでは特に、クエリのパフォーマンスが向上します。

読み取りストアと書き込みストアを分離することにより、それぞれの負荷に合わせて適切にスケーリングすることもできます。 たとえば、読み取りストアには通常、書き込みストアよりはるかに高い負荷が発生します。

CQRS の実装では、イベント ソーシング パターンが使用される場合があります。 このパターンを使用すると、アプリケーションの状態が一連のイベントとして格納されます。 各イベントは、データに対する一連の変更を表します。 現在の状態は、これらのイベントを再生することによって構築されます。 CQRS の場合、イベント ソーシングの利点の 1 つは、他のコンポーネントへの通知 (特に、読み取りモデルへの通知) に、同じイベントを使用できることです。 読み取りモデルでは、現在の状態のスナップショットを作成するのにイベントが使用されます (そのほうが、クエリにとってより効率的です)。 ただし、イベント ソーシングを使用すると、設計がより複雑になります。

CQRS の利点は次のとおりです。

  • 独立したスケーリング。 CQRS では、読み取りと書き込みの各ワークロードを個別にスケーリングできるので、ロック競合を減らせる可能性があります。
  • 最適化されたデータ スキーマ。 読み取り側ではクエリ用に最適化されたスキーマを使用し、書き込み側では更新用に最適化されたスキーマを使用できます。
  • セキュリティ。 適切なドメイン エンティティだけがデータへの書き込みを実行している状態を維持しやすくなります。
  • 懸念事項の分離。 読み取り側と書き込み側を分離することで、モデルの保守性と柔軟性を向上できる可能性があります。 複雑なビジネス ロジックの多くは、書き込みモデルになります。 読み取りモデルは、比較的シンプルにすることができます。
  • クエリがよりシンプル。 具体化されたビューを読み取りデータベースに格納することで、クエリ時の複雑な結合を回避できます。

実装に関する問題と注意事項

このパターンの実装には、次のような課題があります。

  • 複雑さ。 CQRS の基本的な考え方はシンプルです。 ただし、アプリケーションの設計は複雑になる可能性があります。このことは、イベント ソーシング パターンが含まれる場合には特に顕著です。

  • メッセージング。 CQRS ではメッセージングは必須ではありませんが、コマンドの発行やイベントの更新を処理するためにメッセージングが使用されることもよくあります。 その場合には、メッセージのエラーや重複を処理する必要が生じます。 優先度の異なるコマンドを処理するための優先キューに関するガイダンスを参照してください。

  • 最終的な一貫性。 読み取りデータベースと書き込みデータベースを分割すると、読み取りデータが古くなる可能性があります。 読み取りモデル ストアは、書き込みモデル ストアへの変更を反映させるために更新する必要がありますが、ユーザーが古い読み取りデータに基づいた要求をいつ発行したのかを検出するのは容易でない場合があります。

CQRS パターンを使用する状況

次のシナリオで CQRS を考えてみましょう。

  • 多くのユーザーが同じデータに並行してアクセスするコラボレーション ドメイン。 CQRS を使用すると、ドメイン レベルでマージの競合を最小化するのに十分な細分性でコマンドを定義でき、発生した競合はコマンドでマージできます。

  • 一連の手順として、または複雑なドメイン モデルを使用して、複雑なプロセスがユーザーにされるタスクベースのユーザー インターフェイス。 書き込みモデルには、ビジネス ロジック、入力検証、およびビジネス検証を含む完全なコマンド処理スタックがあります。 書き込みモデルでは、関連付けられたオブジェクトのセットをデータ変更のための 1 つの単位 (DDD の用語で集計) として処理し、これらのオブジェクトが常に一貫性のある状態になるようにすることができます。 読み取りモデルにはビジネス ロジックや検証スタックはなく、ビュー モデルで使用する DTO を返すだけです。 読み取りモデルは、最終的には書き込みモデルと一致します。

  • データ読み取りのパフォーマンスを、データ書き込みのパフォーマンスとは別に細かく調整する必要があるシナリオ (特に、読み取り回数が書き込み回数より非常に多い場合)。 このシナリオでは、読み取りモデルをスケールアウトするが、書き込みモデルは少数のインスタンスで実行できます。 書き込みモデルのインスタンス数を少なくすることは、マージ競合の発生を最小化するうえでも役立ちます。

  • 1 つの開発者チームが書き込みモデルの一部である複雑なドメイン モデルに注力し、もう 1 つのチームが読み取りモデルとユーザー インターフェイスに注力できるシナリオ。

  • システムが時間の経過に伴って進化し、モデルの複数のバージョンを含むようになることが予測されるシナリオや、ビジネス ルールが定期的に変更されるシナリオ。

  • 他のシステムとの統合 (特にイベント ソーシングとの組み合わせ)。この場合、1 つのサブシステムの一時的なエラーは、他のサブシステムの可用性に影響しません。

このパターンは次の場合は推奨されません。

  • ドメインやビジネス ルールが単純である。

  • 単純な CRUD スタイルのユーザー インターフェイスとデータ アクセス操作で十分である。

システムの最も重要な、限られたセクションに CQRS を適用することを検討してください。

ワークロード設計

設計者は、Azure Well-Architected Framework の柱で説明されている目標と原則に対処するために、ワークロードの設計でCQRSパターンをどのように使用できるかを評価する必要があります。 次に例を示します。

重要な要素 このパターンが柱の目標をサポートする方法
パフォーマンスの効率化は、スケーリング、データ、コードを最適化することによって、ワークロードが効率的にニーズを満たすのに役立ちます。 読み取り/書き込みの高いワークロードで読み取り/書き込み操作を分離することにより、各操作の特定の目的に合わせて最適なパフォーマンスとスケーリングを実現できます。

- PE:05 スケーリングとパーティショニング
- PE:08 データパフォーマンス

設計決定と同様に、このパターンで導入される可能性のある他の柱の目標とのトレードオフを考慮してください。

イベント ソーシングと CQRS パターン

CQRS パターンは、イベント ソーシング パターンと共によく使用されます。 CQRS ベースのシステムは、個別の読み取りデータ モデルと書き込みデータ モデルを使用します。これらはそれぞれ関連するタスクに合わせて調整されており、多くの場合、物理的に分離されたストアに存在します。 イベント ソーシング パターンと共に使用される場合、イベントのストアは書き込みモデルであり、公式の情報ソースです。 CQRS ベースのシステムの読み取りモデルは、データの具体化されたビュー (通常は高度に非正規化されたビュー) を提供します。 これらのビューは、アプリケーションのインターフェイスとディスプレイの要件に合わせて調整されており、ディスプレイとクエリの両方のパフォーマンスを最大化するのに役立ちます。

ある時点での実際のデータではなく、イベントのストリームを書き込みストアとして使用することにより、単一の集計での更新の競合を回避し、パフォーマンスとスケーラビリティを最大化します。 イベントを使用して、読み取りストアへのデータ入力に使用されるデータの具体化されたビューを非同期的に生成できます。

イベント ストアは公式の情報ソースであるため、システムが進化したり読み取りモデルの変更が必要になったりした場合に、具体化されたビューを削除し、過去のすべてのイベントを再生して最新の状態の新しい表現を作成することができます。 具体化されたビューは、実質的にはデータの持続的な読み取り専用キャッシュです。

CQRS とイベント ソーシング パターンを組み合わせて使用する場合、次の点を考慮してください。

  • 書き込みストアと読み取りストアが分離しているすべてのシステムと同様に、このパターンに基づくシステムは、最後の段階にならないと一貫性が確保されません。 イベントの生成とデータ ストアの更新の間には、いくらかの遅延があります。

  • イベントを開始および処理し、クエリや読み取りモデルで必要な適切なビューやオブジェクトをアセンブルまたは更新するようにコードを作成する必要があるため、このパターンでは複雑さが増します。 CQRS パターンをイベント ソーシング パターンと併用すると複雑さが増すため、実装が難しくなる可能性があり、システム設計に別のアプローチが必要になります。 ただし、イベント ソーシングを使用するとドメインのモデル化が容易になります。また、データの変更の目的が保持されるため、ビューの再構築や新規作成も容易になります。

  • 特定のエンティティまたはエンティティのコレクションのイベントを再生または処理することにより、データの読み取りモデルまたはプロジェクションで使用する具体化されたビューを生成すると、大量の処理時間とリソース使用量が必要になる可能性があります。 これは特に、長期にわたる値の合計や解析が必要な場合に当てはまります。関連するすべてのイベントの検証が必要な場合があるためです。 この問題を解決するには、スケジュールされた間隔でデータのスナップショットを実装します。たとえば、発生した特定のアクションの合計数や、エンティティの現在の状態などです。

CQRS パターンの例

次のコードは、読み取りモデルと書き込みモデルに異なる定義を使用する CQRS 実装の例から抽出したものです。 モデル インターフェイスは、基になるデータ ストアの機能に影響しません。また、進化することができ、インターフェイスどうしが分離しているため個別に微調整もできます。

次のコードは、読み取りモデルの定義を示しています。

// Query interface
namespace ReadModel
{
  public interface ProductsDao
  {
    ProductDisplay FindById(int productId);
    ICollection<ProductDisplay> FindByName(string name);
    ICollection<ProductInventory> FindOutOfStockProducts();
    ICollection<ProductDisplay> FindRelatedProducts(int productId);
  }

  public class ProductDisplay
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public decimal UnitPrice { get; set; }
    public bool IsOutOfStock { get; set; }
    public double UserRating { get; set; }
  }

  public class ProductInventory
  {
    public int Id { get; set; }
    public string Name { get; set; }
    public int CurrentStock { get; set; }
  }
}

ユーザーは製品を評価することができます。 そのためには、次のコードに示すように、アプリケーション コードで RateProduct コマンドを使用します。

public interface ICommand
{
  Guid Id { get; }
}

public class RateProduct : ICommand
{
  public RateProduct()
  {
    this.Id = Guid.NewGuid();
  }
  public Guid Id { get; set; }
  public int ProductId { get; set; }
  public int Rating { get; set; }
  public int UserId {get; set; }
}

システムは ProductsCommandHandler クラスを使用して、アプリケーションから送信されたコマンドを処理します。 クライアントは通常、キューなどのメッセージング システムを使用して、ドメインにコマンドを送信します。 コマンド ハンドラーはこれらのコマンドを受け入れ、ドメイン インターフェイスのメソッドを呼び出します。 各コマンドの細分性は、要求の競合が発生する可能性が少なくなるように設計されています。 次のコードは、ProductsCommandHandler クラスのアウトラインを示しています。

public class ProductsCommandHandler :
    ICommandHandler<AddNewProduct>,
    ICommandHandler<RateProduct>,
    ICommandHandler<AddToInventory>,
    ICommandHandler<ConfirmItemShipped>,
    ICommandHandler<UpdateStockFromInventoryRecount>
{
  private readonly IRepository<Product> repository;

  public ProductsCommandHandler (IRepository<Product> repository)
  {
    this.repository = repository;
  }

  void Handle (AddNewProduct command)
  {
    ...
  }

  void Handle (RateProduct command)
  {
    var product = repository.Find(command.ProductId);
    if (product != null)
    {
      product.RateProduct(command.UserId, command.Rating);
      repository.Save(product);
    }
  }

  void Handle (AddToInventory command)
  {
    ...
  }

  void Handle (ConfirmItemsShipped command)
  {
    ...
  }

  void Handle (UpdateStockFromInventoryRecount command)
  {
    ...
  }
}

次のステップ

このパターンを実装する場合、次のパターンとガイダンスが役に立ちます。

Martin Fowler のブログ記事: