💊

【Rust】CQRSを実践してみた

2024/11/09に公開

こんにちは。
私はこれまでCQRSを取り入れた実装をしたことがありませんでしたが、
DDDやクリーンアーキテクチャでバックエンドの実装をしていると、参照系の処理でアーキテクチャによる制約や責務の分割によってデータの取得が非効率になっている、と感じるケースにしばしば遭遇していました。
そうした背景から、今回CQRSによる実装をしてみました。

モチベーション

  • CQRSをふわっとだけ知っているけど、実装したことはないのでやってみたい
  • CQRSとは、を解説してくれる記事は度々あるが、実装を紹介した日本語の記事はそんなに多くないから書きたい

CQRSの概要

本記事では、実践した実装内容の紹介に焦点当てるため詳細は割愛しますが、概要を軽くまとめたいと思います。

  • CQRS(コマンドクエリ責任分離)はアーキテクチャの一つで、イベントソーシングやDDDなどのアーキテクチャに組み込むことができる
  • ある複雑なドメインに対して、参照系と更新系の間で異なるモデルを使用することで、その複雑性に適切に対応できるようにする
  • ただし、CQRSが効果的なケースは限定的で、ドメインが単純である場合は導入すべきでない(逆に複雑になる可能性がある)

https://cqrs.wordpress.com/wp-content/uploads/2010/11/cqrs_documents.pdf
https://pages.awscloud.com/rs/112-TZM-766/images/DevAx_connect_jp_season1_day4_CQRS%26EventSourcing.pdf
https://learn.microsoft.com/ja-jp/azure/architecture/patterns/cqrs

実装したものの概要

ざっくりと以下のような構成でCQRSによる実装を行いました。

┌────────────────────────────┐
│      Presentation Layer    │
│                            │
│  ┌───────────────────────┐ │
│  │  RelatedProductAPI    │ │
│  │      Controller       │ │
│  └─────────┬─────────────┘ │
└────────────┼───────────────┘
             │
┌────────────▼───────────────┐
│         Usecase Layer       │
│                             │
│  ┌────────────────────────┐ │
│  │     ProductUsecase     │ │
│  └─────────┬──────────────┘ │
└────────────┼────────────────┘
             │
┌────────────▼───────────────┐
│        Query Service Layer  │  <─── CQRSによる専用のクエリサービス
│                             │
│  ┌────────────────────────┐ │
│  │ RelatedProductQuerySvc │ │
│  └─────────┬──────────────┘ │
└────────────┼────────────────┘
             │
┌────────────▼───────────────┐
│     External Services       │
│                             │
│       Shopify API           │
└─────────────────────────────┘

CQRSを取り入れるアプリケーション・機能

  • RustのActix Webを使用して、クリーンアーキテクチャで開発中のAPIのプロジェクト
  • APIの機能として、ECサイトのバックエンドを提供することを想定していて、コアの部分についてはShopifyを使用し、Repository層でGQLのAPIによってやり取りを行う
  • 上記に対して部分的にCQRSを組み込み、指定した商品の関連商品を取得するAPIエンドポイントの実装に活用する

CQRSを導入したい経緯

  • 関連商品の取得については、商品や在庫といったクリーンアーキテクチャにおける複数のエンティティを跨いで、複雑な検索条件から効率よくデータを取得する必要があるため、その課題解決にCQRSを採用
    • 更新系をはじめその他の機能では、各エンティティをビジネスロジックの中核として機能実装をしている

今回実装対象外のもの

  • データソースの分離やイベントソーシングに関する実装
  • CQRSとこれらはセットで語られるが、データソースはShopify単一であり、CQRS=イベントソーシングではないため、今回はあくまでアプリケーション側でqueryとcommandを分けて実装する形に留める

実装したものの詳細

以降では、具体的な実装例を紹介します。

クリーンアーキテクチャにCQRSを組み込んだ構成

ざっと、以下のようなディレクトリ構成で、ポイントは2点です。

  • usecase層配下にquery_serviceを作成し、queryのインターフェースとそのDTOを定義
    • 更新系で利用されるエンティティ(domain配下)とは分割している
  • queryの実装がinfrastructure層配下にRepositoryと並んで定義
.
├── domain
│   ├── inventory_item
│   ├── media
│   └── product
│       ├── category
│       └── variant
│           ├── barcode
│           └── sku
├── infrastructure
│   ├── ec
│   │   └── shopify
│   │       ├── query_service
│   │       │   ├── product
│   │       │   │   └── product_impl.rs
│   │       │   └── schema
│   │       │       └── product.rs
│   │       └── repository
│   │           ├── inventory_item
│   │           ├── media
│   │           ├── product
│   │           └── schema
├── interface
│   ├── controller
│   └── presenter
└── usecase
    ├── interactor
    │   ├── inventory
    │   ├── media
    │   └── product
    ├── query_service
    │   ├── dto
    │   │   └── product.rs
    │   └── product_query_service_interface.rs
    └── repository

クリーンアーキテクチャによって各層でロジックの責務を分割していることもあり、以降では本記事のポイントとなるusecase層とinfrastructure層のみ実装例を取り上げます。

usecaseの実装

usecase配下の、product_query_service_interface.rsには今回の対象機能となる関連商品取得の処理を抽象化したインターフェースを定義します。

  • 引数は、interactorから渡させる、関連商品を取得する対象の商品のidcategory_idを受け取りたいため、RelatedProductFilterという構造体を定義
    • 検索条件が複雑になり、引数として必要な情報が増えた時を見越してこの形に実装している
  • 戻り値は、query_service用の商品情報のDTOとしてProductDTOの配列を返す形
    • エラーについては、interface層のPresenterでレスポンスを組み立てる際に、エンティティとして定義されたDomainErrorによって処理を共通化しているため、通常時同様にDomainErrorを返している
product_query_service_interface.rs
pub struct RelatedProductFilter {
    pub id: ProductId,
    pub category_id: CategoryId,
}

#[async_trait]
pub trait ProductQueryService: Send + Sync {
    /// Obtains a list of related products for a specified product.
    async fn search_related_products(
        &self,
        filter: &RelatedProductFilter,
    ) -> Result<Vec<ProductDTO>, DomainError>;
}

DTOの実装は以下です。

  • DTOのフィールドとしては、関連商品一覧をフロントエンドで表示する際に必要なものだけ定義
    • エンティティで定義している更新系処理のみで利用するフィールドは必要ない
  • 今回は手っ取り早く、このDTOをそのままレスポンスモデル(型)としても利用するため、Serializeを実装
    • レスポンスモデルを別途定義したい場合については、これは不要になるかも
dto/product.rs
#[derive(Debug, Serialize, Deserialize)]
pub struct ProductDTO {
    pub id: String,
    pub name: String,
    pub handle: String,
    pub vendor: String,
    pub price: f64,
    pub featured_media_url: Option<String>,
}

infrastructureの実装

infrastructureにおけるquery_service配下には、先ほどのインターフェースの実装を配置し、Shopifyから指定した商品の関連商品取得を行うGQLのクエリやロジックを実装します。

記載しているように、ドメインの集約単位で操作を行うRepositoryではできないような、複数の集約(今回では商品と在庫)に跨った検索条件を指定して、複雑な検索処理を一度に実行しています。

product/product_impl.rs
/// Query service for products for Shopify.
pub struct ProductQueryServiceImpl<C: ECClient> {
    client: C,
}

impl<C: ECClient> ProductQueryServiceImpl<C> {
    pub fn new(client: C) -> Self {
        Self { client }
    }
}

#[async_trait]
impl<C: ECClient + Send + Sync> ProductQueryService for ProductQueryServiceImpl<C> {
    async fn search_related_products(
        &self,
        filter: &RelatedProductFilter,
    ) -> Result<Vec<ProductDTO>, DomainError> {
        let first_query = ShopifyGQLHelper::first_query();
        let page_info = ShopifyGQLHelper::page_info();
        let id = &filter.id;
        let category_id = &filter.category_id;

        let query = format!(
            "query {{
                    products(
                        {first_query},
                        sortKey: UPDATED_AT,
                        query: \"
                            (NOT id:{id}) AND
                            category_id:{category_id} AND
                            inventory_total:>0 AND
                            product_publication_status:published AND
                            gift_card:false
                            \"
                    ) {{
                        edges {{
                            node {{
                                id
                                title
                                handle
                                vendor
                                priceRangeV2 {{
                                    maxVariantPrice {{
                                        amount
                                    }}
                                }}
                                featuredMedia {{
                                    preview {{
                                        image {{
                                            url
                                        }}
                                    }}
                                }}
                            }}
                        }}
                        {page_info}
                    }}
                }}"
        );

        let response: GraphQLResponse<RelatedProductsData> = self.client.query(&query).await?;
        if let Some(errors) = response.errors {
            log::error!(
                "Error returned in Products response. Response: {:?}",
                errors
            );
            return Err(DomainError::QueryError);
        }

        Ok(response
            .data
            .ok_or(DomainError::QueryError)?
            .products
            .edges
            .into_iter()
            .map(|node| node.node.into())
            .collect())
    }
}

ShopifyのレスポンスからProductDTOへの変換処理は、query_service/schema配下にまとめているため、念の為そちらの実装も紹介します。
中身としては、Fromトレイトを実装して処理を共通化しています。

schema/product.rs
impl From<ProductNode> for ProductDTO {
    fn from(node: ProductNode) -> Self {
        Self {
            id: ShopifyGQLHelper::remove_gid_prefix(&node.id),
            name: node.title,
            handle: node.handle,
            vendor: node.vendor,
            price: node
                .price_range_v2
                .max_variant_price
                .amount
                .parse()
                .unwrap_or(0.0),
            featured_media_url: node.featured_media.and_then(|media| {
                media
                    .preview
                    .and_then(|preview| preview.image.map(|image| image.url))
            }),
        }
    }
}

#[derive(Debug, Deserialize)]
pub struct RelatedProductsData {
    pub products: Edges<ProductNode>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductNode {
    pub id: String,
    pub title: String,
    pub handle: String,
    pub vendor: String,
    pub price_range_v2: PriceRangeV2Node,
    pub featured_media: Option<MediaNode>,
}

// ... その他のShopifyのレスポンスモデルの定義は割愛

実装してみて

  • 特にECサイトのフロントエンドのような、更新より取得が大半を占め、大量のトラフィックに耐える必要がある場合において、CQRSによるアプローチは有効になりそう
  • 部分的に取り入れるとメリットはあるが、やはり使用箇所を選んでいかないと、DDDやクリーンアーキテクチャのプロジェクトにおいては、ビジネスロジックの分散や複雑化が起こりそう

おわりに

今回は手探りではありますが、CQRSによる実装を行ったことで、その概念やメリット、きをつけるポイントが少し掴めたかなと思います。
まだまだ理解が浅い部分や認識違いをしている部分もあると思いますので、気になる点があればぜひコメントいただきたいです。

https://github.com/penysho/ec-extension

Discussion