💊

RustのActix WebでClean Architectureをやってみた

2024/08/22に公開

初めまして。
zenn.devでというか、ブログ自体も初めて書きます。

私はこれまでクリーンアーキテクチャで開発をやったことがなかったため、手探りでやってみた記録を書きます。この話に限らず日本語でRustの情報はまだまだ少ないと思うので、Rustで何か記事を書いてみようというモチベーションです。

本記事で取り上げないこと

  • クリーンアーキテクチャとは何か、前提部分
  • Rustやフレームワークの詳細

ちゃんと説明しろと言われても私もまだまだできません。
有名なクリーンアーキテクチャの本を読んでかみ砕いた結果、実践的に落とし込んだらこうなるんじゃないか、という点に重きを置きたいと思います。
本の他には以下のページをとても参考にさせていただきました。

https://gist.github.com/mpppk/609d592f25cab9312654b39f1b357c60
https://github.com/MSC29/clean-architecture-rust/tree/main

作ったもの

作ったものはActix Webを使用したRESTAPIです。

https://actix.rs/

一応、中身としてはShopifyを大元として、ECの商品絡みのビジネスロジックを実行するエンドポイントになっています。ShopifyのGraphQL Admin APIを内部で実行し、自前のロジックで操作なり加工なりした後にレスポンスしてあげるイメージです。(今回は簡単な商品情報取得の例だけですが)

プロジェクト構成

よくある同心円を参考に、主には4層に分割して構成します。
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

.
├── Cargo.lock
├── Cargo.toml
├── src
│   ├── domain
│   ├── infrastructure
│   ├── interface
│   ├── usecase
│   ├── lib.rs
│   ├── main.rs
└── tests

Entity

こちらがいわゆるEnterprise Business Rulesで、DDDでいうところのドメインモデルです。
クリーンアーキテクチャのプロジェクトでもdomainという名前を使っている例をよく見かけるので、それに従ってモジュールを定義しています。

├── domain
│   ├── error
│   │   └── error.rs
│   ├── product
│   │   └── product.rs

一旦、作ったエンティティは二つだけです。
あまりにも簡単な例ですが、Productエンティティを以下に示します。
getterについては、derive-gettersを用いて定義してもらいます。

product.rs
pub type Id = String;

/// Entity of Products.
#[derive(Debug, Getters)]
pub struct Product {
    id: Id,
    name: impl Into<String>,
    price: u32,
    description: impl Into<String>,
}

impl Product {
    pub const MAX_DESCRIPTION_LENGTH: u32 = 10000;

    pub fn new(
        id: impl Into<String>,
        name: impl Into<String>,
        price: u32,
        description: impl Into<String>,
    ) -> Result<Self, DomainError> {
        let id = id.into();
        if id.is_empty() {
            log::error!("Id cannot be empty");
            return Err(DomainError::ValidationError);
        }
        let name = name.into();
        if name.is_empty() {
            log::error!("Name cannot be empty");
            return Err(DomainError::ValidationError);
        }
        let description = description.into();
        if description.len() as u32 > Self::MAX_DESCRIPTION_LENGTH {
            log::error!(
                "Description cannot be longer than {} characters",
                Self::MAX_DESCRIPTION_LENGTH
            );
            return Err(DomainError::ValidationError);
        }

        Ok(Product {
            id,
            name,
            price,
            description,
        })
    }
}

UseCase

usecaseでは、アプリケーションのビジネスルールを実装します。

├── usecase
│   ├── interactor
│   │   ├── product
│   │   │   └── product_impl.rs
│   │   └── product_interactor_interface.rs
│   ├── repository
│   │   └── product_repository_interface.rs

Interactor

interactorはusecaseの実装です。
その配下にproduct_interactor_interface.rsを置くのはちょっと違うのか?と思いつつ、
明示的にinterfaceというモジュールにしているしまあ良いかということで配下におきました。

product_interactor_interface.rs
#[async_trait]
pub trait ProductInteractor {
    async fn get_product(&self, id: &Id) -> Result<Option<Product>, DomainError>;
    async fn get_products(&self) -> Result<Vec<Product>, DomainError>;
}

product_impl.rsではその名の通り、トレイトを実装します。

product_impl.rs
/// Product Interactor.
pub struct ProductInteractorImpl {
    product_repository: Box<dyn ProductRepository>,
}

impl ProductInteractorImpl {
    pub fn new(product_repository: Box<dyn ProductRepository>) -> Self {
        Self {
            product_repository: product_repository,
        }
    }
}

#[async_trait]
impl ProductInteractor for ProductInteractorImpl {
    /// Get detailed product information.
    async fn get_product(&self, id: &Id) -> Result<Option<Product>, DomainError> {
        self.product_repository.find_product(id).await
    }
    /// Get a list of products.
    async fn get_products(&self) -> Result<Vec<Product>, DomainError> {
        self.product_repository.find_products().await
    }
}

product/product_impl.rsでは、repositoryを利用して商品情報を取得するロジックを実装しています。今回は指定されたIDをもとに商品情報を取得するget_productと、一覧情報を取得するget_productsを定義しました。

Repository

依存関係逆転を実現するため、usecaseからは直接infrastructureのrepositoryを参照せず、product_repository_interface.rsを見る形にしています。

product_repository_interface.rs
#[async_trait]
pub trait ProductRepository: Send + Sync {
    async fn find_product(&self, id: &Id) -> Result<Option<Product>, DomainError>;
    async fn find_products(&self) -> Result<Vec<Product>, DomainError>;
}

repositoryのインターフェースをどこに置けば良いのか、というよく盛り上がる話題がありますが、個人的にはDDDではdomain配下、クリーンアーキテクチャではusecase(application)配下に配置している例をよく見る印象です。今回はusecase配下に置いています。

Interface

interfaceはその名の通り、外部とアプリケーションのビジネスロジックをつなぐ役割を果たします。今回は、その中でもcontroller(入口)、presenter(出口)を表現します。

├── interface
│   ├── controller
│   │   ├── controller.rs
│   │   ├── get_product.rs
│   │   ├── get_products.rs
│   │   └── interact_provider_interface.rs
│   ├── controller.rs
│   ├── presenter
│   │   ├── common
│   │   │   └── exception.rs
│   │   ├── product
│   │   │   ├── product_impl.rs
│   │   │   └── schema.rs
│   │   └── product_presenter_interface.rs

Controller

方針としては、controllerという構造体を作成し、そこにエンドポイントごとにメソッドを定義していきます。actixのRouterで各URLとHTTPメソッドに対してこれらを対応させるイメージです。

controller.rs
use super::interact_provider_interface::InteractProvider;

/// Controller receives data from outside and calls usecase.
pub struct Controller {
    pub interact_provider: Box<dyn InteractProvider>,
}

impl Controller {
    pub fn new(interact_provider: Box<dyn InteractProvider>) -> Self {
        Controller {
            interact_provider: interact_provider,
        }
    }
}

パスパラメータからIDを取得し、対象の商品を取得するメソッドは以下のようになります。

get_product.rs
impl Controller {
    /// Get detailed product information.
    pub async fn get_product(&self, path: Path<(String,)>) -> impl Responder {
        let id = &path.into_inner().0;

        let interactor = self.interact_provider.provide_product_interactor().await;
        let products = interactor.get_product(id).await;

        let presenter = ProductPresenterImpl::new();
        presenter.present_get_product(products).await
    }
}

interacterは、DIされたinteract_providerからを取得しています。
controllerのフィールドにinteractorを持たなかったのは、product_interactorの他にどんどん増えた場合にcontrollerを初期化が複雑になるため、一つのモジュールに集約させたかったからです。
よって、interact_providerにinteractorの生成ロジックを集約させる形を考えました。

Presenter

今回最も苦労した部分がpresenterでした。
本来のpresenterの役割やその操作は以下の認識です。

  • controllerからOutput Boundaryという形で、presenterのインターフェースがinteractorに渡される
  • interactorがそれを利用し、presenterがHTTPレスポンスまでを担当する

しかし、今回はcontrollerでpresenterを初期化した後に呼び出して、レスポンススキーマをを受け取る形で定義してみました。
理由は、Actix Webでは、Responderトレイトに関連したActix Web固有のレスポンス型をrouterに返す必要があり、presenterでHTTPレスポンスまで行うことができなかったからです。(もしかしたらできる、?)
この場合、interactorでpresenterを利用すると、レスポンススキーマがinteractorに返却され、さらにcontrollerに引き渡す方になり、少し冗長な気がしています。

product_presenter_interface.rs
/// Interface to generate response schema for products.
#[async_trait]
pub trait ProductPresenter {
    type GetProductResponse;
    type GetProductResponseError;
    async fn present_get_product(
        &self,
        result: Result<Option<Product>, DomainError>,
    ) -> Result<Self::GetProductResponse, Self::GetProductResponseError>;

    type GetProductsResponse;
    type GetProductsResponseError;
    async fn present_get_products(
        &self,
        result: Result<Vec<Product>, DomainError>,
    ) -> Result<Self::GetProductsResponse, Self::GetProductsResponseError>;
}

続いて、上記トレイトの実装です。

product_impl.rs
/// Generate a response schema for the product
pub struct ProductPresenterImpl;
impl ProductPresenterImpl {
    pub fn new() -> Self {
        ProductPresenterImpl
    }
}

#[async_trait]
impl ProductPresenter for ProductPresenterImpl {
    type GetProductResponse = Json<GetProductResponse>;
    type GetProductResponseError = GetProductResponseError;
    /// Generate a response with detailed product information.
    async fn present_get_product(
        &self,
        result: Result<Option<Product>, DomainError>,
    ) -> Result<Self::GetProductResponse, Self::GetProductResponseError> {
        match result {
            Ok(Some(product)) => Ok(web::Json(GetProductResponse {
                product: ProductSchema::from(product),
            })),
            Ok(None) => Err(GetProductResponseError::ProductNotFound),
            Err(_) => Err(GetProductResponseError::ServiceUnavailable),
        }
    }

    type GetProductsResponse = Json<GetProductsResponse>;
    type GetProductsResponseError = GetProductsResponseError;
    /// Generate a response for the product list.
    async fn present_get_products(
        &self,
        result: Result<Vec<Product>, DomainError>,
    ) -> Result<Self::GetProductsResponse, Self::GetProductsResponseError> {
        match result {
            Ok(products) => {
                let product_schemas: Vec<ProductSchema> = products
                    .into_iter()
                    .map(|product| ProductSchema::from(product))
                    .collect();

                Ok(web::Json(GetProductsResponse {
                    products: product_schemas,
                }))
            }
            Err(_) => Err(GetProductsResponseError::ServiceUnavailable),
        }
    }
}

インターフェースの定義においても、Actix Webの型とアプリケーションの依存関係に悩みました。この点も、interactorにpresenterを渡さなかったことと関連します。

  • 【再掲】Actix Webでは、Responderトレイトに関連したActix Web固有のレスポンス型を返す必要がある
  • 実装はともかく、presenterのインターフェースでは、フレームワークに依存した型によって戻り値を定義したくない

上記を解決するため、関連型を用いて正常レスポンスと異常レスポンスを表現するResult型を戻り値として定義しました。実装では、関連型に型を指定する(指定されるのはActix Webに依存する型)ことで、フレームワークへの依存を抑えました。

そして、レスポンススキーマはproduct_impl.rsと同階層に配置した、schema.rsに定義しています。

shchema.rs
#[derive(Serialize, Deserialize, Debug)]
pub struct ProductSchema {
    pub(super) id: String,
    pub(super) name: String,
    pub(super) price: u32,
    pub(super) description: String,
}

impl From<Product> for ProductSchema {
    fn from(domain: Product) -> Self {
        ProductSchema {
            id: domain.id().to_string(),
            name: domain.name().to_string(),
            price: *(domain.price()),
            description: domain.description().to_string(),
        }
    }
}

Fromトレイトを実装することで、entityからの変換処理を共通化しています。

Infrastructure

infrastructureでは、フレームワークやDBなどの外部に依存した具体的な実装を定義します。

├── infrastructure
│   ├── config
│   │   └── config.rs
│   ├── error.rs
│   ├── module
│   │   └── interact_provider_impl.rs
│   ├── router
│   │   └── actix_router.rs
│   ├── shopify
│   │   ├── client.rs
│   │   ├── repository
│   │   │   ├── common
│   │   │   │   └── schema.rs
│   │   │   ├── product
│   │   │   │   ├── product_impl.rs
│   │   │   │   └── schema.rs

Repository

まず、usecaseで定義したproduct_repository_interface.rsの実装をrepository配下に配置しています。

product_impl.rs
/// Repository for products for Shopify.
pub struct ProductRepositoryImpl {
    client: ShopifyClient,
}

impl ProductRepositoryImpl {
    pub fn new(client: ShopifyClient) -> Self {
        Self { client }
    }
}

#[async_trait]
impl ProductRepository for ProductRepositoryImpl {
    /// Get detailed product information.
    async fn find_product(&self, id: &Id) -> Result<Option<Product>, DomainError> {
        let query = json!({
        "query": format!("query {{ product(id: \"gid://shopify/Product/{id}\") {{ id title handle priceRangeV2 {{ maxVariantPrice {{ amount }} }} description(truncateAt: 500) }} }}")
        });

        let response = self.client.query(&query).await?;
        let graphql_response = response
            .json::<GraphQLResponse<ProductData>>()
            .await
            .map_err(|e| {
                log::error!("Failed to parse GraphQL response. Error= {:?}", e);
                InfrastructureErrorMapper::to_domain(InfrastructureError::NetworkError(e))
            })?;
        if let Some(errors) = graphql_response.errors {
            log::error!("Error returned in GraphQL response. Response= {:?}", errors);
            return Err(DomainError::QueryError);
        }

        let product_schema: Option<ProductSchema> = graphql_response
            .data
            .ok_or(DomainError::QueryError)?
            .product
            .map(ProductSchema::from);

        Ok(product_schema.map(|schema| schema.to_domain()))
    }

    /// Retrieve multiple products.
    async fn find_products(&self) -> Result<Vec<Product>, DomainError> {
        let query = json!({
        "query": "query { products(first: 10, reverse: true) { edges { node { id title handle priceRangeV2 { maxVariantPrice { amount } } description(truncateAt: 500) resourcePublicationOnCurrentPublication { publication { name id } publishDate isPublished } } } } }"
        });

        let response = self.client.query(&query).await?;
        let graphql_response = response
            .json::<GraphQLResponse<ProductsData>>()
            .await
            .map_err(|e| {
                log::error!("Failed to parse GraphQL response. Error= {:?}", e);
                InfrastructureErrorMapper::to_domain(InfrastructureError::NetworkError(e))
            })?;
        if let Some(errors) = graphql_response.errors {
            log::error!("Error returned in GraphQL response. Response= {:?}", errors);
            return Err(DomainError::QueryError);
        }

        let products: Vec<ProductSchema> = graphql_response
            .data
            .ok_or(DomainError::QueryError)?
            .products
            .edges
            .into_iter()
            .map(|node| ProductSchema::from(node.node))
            .collect();

        let product_domains: Vec<Product> = products
            .into_iter()
            .map(|product| product.to_domain())
            .collect();

        Ok(product_domains)
    }
}

ProductRepositoryImplでは、ShopifyClientを保持し、より詳細はShopify Admin APIの実行ロジックはそちらに記述しています。より依存関係を適切にするなら、ECClient的なインターフェースを用意して、ShopifyClientがそれを実装する形が良いと思いますが、今回そこまではしませんでした。

client.rs
/// A client that interacts with GraphQL for Shopify.
pub struct ShopifyClient {
    client: Client,
    config: ShopifyConfig,
}

impl ShopifyClient {
    const SHOPIFY_ACCESS_TOKEN_HEADER: &'static str = "X-Shopify-Access-Token";

    pub fn new(config: ShopifyConfig) -> Self {
        Self {
            client: Client::new(),
            config: config,
        }
    }

    /// Execute a GraphQL query request for Shopify.
    pub async fn query<T>(&self, query: &T) -> Result<Response, DomainError>
    where
        T: Serialize + ?Sized,
    {
        let response = self
            .client
            .post(self.config.store_url())
            .headers(self.build_headers())
            .json(query)
            .send()
            .await
            .map_err(|e| {
                InfrastructureErrorMapper::to_domain(InfrastructureError::NetworkError(e))
            })?;
        Ok(response)
    }

    /// Generate headers to be used in GraphQL requests for Shopify.
    fn build_headers(&self) -> HeaderMap {
        let mut headers = HeaderMap::new();
        headers.insert(
            Self::SHOPIFY_ACCESS_TOKEN_HEADER,
            HeaderValue::from_str(self.config.access_token()).unwrap(),
        );
        headers
    }
}

一応、上記で実行されるShopifyのAPI定義のリンクを貼っておきます。GraphQLとRESTどちらも提供されているのですが、ご覧の通り今回はGraphQLのAPIを利用しています。

https://shopify.dev/docs/api/admin-graphql/unstable/queries/product

Module

controllerに配置したinteract_provider_interface.rsの実装はmodule配下で実装しています。ここだけが、repositoryやclientの実装を初期化し、interactorの作り方を知っている唯一の場所になるイメージです。こちら自体はmain.rsで初期化します。

interact_provider_impl.rs
/// Factory providing Interactor.
pub struct InteractProviderImpl {
    shopify_config: ShopifyConfig,
}

impl InteractProviderImpl {
    pub fn new(shopify_config: ShopifyConfig) -> Self {
        Self { shopify_config }
    }
}

#[async_trait]
impl InteractProvider for InteractProviderImpl {
    /// Provide Interactor for products.
    async fn provide_product_interactor(&self) -> Box<dyn ProductInteractor> {
        Box::new(ProductInteractorImpl::new(Box::new(
            ProductRepositoryImpl::new(ShopifyClient::new(self.shopify_config.clone())),
        )))
    }
}

実装してみて

よかったこと

  • 各層のメソッドの戻り値をResult型で定義して、呼び元ではmatch式を用いてスマートに制御で切るところがいい感じ
  • エンティティと各層で定義したスキーマについて、Fromトレイトを実装することで、明確にここで変換をやるんだということがわかるし、処理を共通化できるのでいい感じ

課題

  • entityをビジネスロジックとか必要なフィールドとか定義してもっと実践的に考えるべきところはある
  • メソッドでトレイトオブジェクトを扱う際に全て動的ディスパッチを使っているが、静的ディスパッチを使った実装も考えたい
  • RustのDIのライブラリも触ってみたい

おわりに

初めての投稿だったので、もっと簡潔に書けるお題にすればよかったと途中で後悔しましたが、なんとか書ききれました、、ありがとうございました。

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

Discussion