LoginSignup
52
46

More than 3 years have passed since last update.

rustでクリーンアーキテクチャ

Last updated at Posted at 2020-05-27

はじめに

こちらに触発されて、同じようなものをrustで作ってみました。
awsの利用料金をslackに通知するアプリケーションです。
https://github.com/pokotyan/aws_cost_rs

こんな感じでコマンドを実行すると、

$ cargo run -- cost

今月分のaws利用料金が確認できます。
スクリーンショット_2020-05-27_16_56_19.png

ディレクトリ構成

こんな感じのモノレポの構成になっています。
rustのパッケージマネージャであるcargoにはワークスペースという機能があり、モノレポの構成を簡単に作れます。

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── cli
│   ├── Cargo.toml
│   └── src
│       ├── get_cost
│       └── main.rs
├── infra
│   ├── Cargo.toml
│   └── src
│       ├── aws
│       └── lib.rs
├── module
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── slack_module.rs
├── presenter
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── slack_presenter.rs
└── server
    ├── Cargo.toml
    └── src
        ├── main.rs
        └── router.rs

それぞれのパッケージの役割は以下のような感じです。

  • cli
    コマンド実行のエントリーポイント

  • infra
    リポジトリ。awsのsdkを使って料金情報を取ってくる

  • module
    モジュール置き場。webhookを使ってslackへメッセージを送信するモジュールを置いてる。

  • presenter
    sdkから取得した料金情報をslack用の構造体に整形するコードを置いてる。

  • server
    使ってない。今後、APIでも料金情報を取得したいとなったら、ここにコードを書く。

依存関係

依存関係はこんな感じになっています。
エントリーポイントとなるcliのところで各クレートを利用しており、他のinfra, module, presenterはどこの層にも依存しない形になっています。

cli/Cargo.toml
[dependencies]
presenter = {path = "../presenter"}
infra = {path = "../infra"}
module = {path = "../module"}
structopt = "0.3.14"
tokio = { version = "0.2", features = ["full"] }
async-trait = "0.1.31"
infra/Cargo.toml
[dependencies]
tokio = { version = "0.2", features = ["full"] }
dotenv = "0.15.0"
rusoto_core = "0.43.0"
rusoto_credential = "0.43.0"
rusoto_ce = "0.43.0"
futures = "0.3.5"
anyhow = "1.0"
chrono = "0.4"
async-trait = "0.1.31"
serde = "1.0.110"
serde_json = "1.0.53"
module/Cargo.toml
[dependencies]
slack-hook = "0.7"
presenter/Cargo.toml
[dependencies]
anyhow = "1.0"
rusoto_ce = "0.43.0"
slack-hook = "0.7"

DI

cliのところでinfra, module, presenterを利用していますが、infra(AwsRepository)とmodule(SlackModule)についてはDIをしています。

cli/src/get_cost/mod.rs
pub struct GetCost<T, U>
where
    T: AwsRepository,
    U: SlackModule,
{
    aws_repository: T,
    slack: U,
}

#[async_trait]
pub trait UseCase {
    async fn new() -> Self;
    async fn run(&self, start: Option<String>, end: Option<String>, channel: Option<String>);
}

#[async_trait]
impl<T: Sync + Send + AwsRepository, U: Sync + Send + SlackModule> UseCase for GetCost<T, U> {
    async fn new() -> Self {
        let aws_repository = AwsRepository::new().await;
        let slack = SlackModule::new();

        GetCost {
            aws_repository,
            slack,
        }
    }

    async fn run(&self, start: Option<String>, end: Option<String>, channel: Option<String>) {
        // slackへaws料金を送信するコード。。。
    }
}

GetCostという構造体は、
- awsの料金情報を取得するためのAwsRepository
- slackへ情報を送信するためのSlackModule
の二つをプロパティを持っています。どちらとも具象的な処理には依存しておらず、traitに依存するようになっています。
なので、traitさえ満たしていればAwsRepository、SlackModuleは別の実装に切り替えることができる状態です。

そのため、依存関係の向きとしてはCargo.tomlのdependencies的にはこの方向になってますが、

cli → infra
cli → module

DIを行ったことにより、こんな感じになり依存関係が逆転している感じになります。

(cli → trait) ← infra
(cli → trait) ← module

presenterについてはDIをしませんでした。slack用にデータを整形するためのコードとなるので、slackの仕様にどっぷり浸かったコードになってしまいます。そのためどう抽象化すればいいかよくわからず、traitは定義せずそのまま処理を呼ぶようにしました。

実装を入れ替えてみる

では、SlackModuleの実装を slackへ料金情報を送信する から ターミナルに料金情報を標準出力する に切り替えてみます。

まず、現在のslackのモジュールはこうなってます。

use slack_hook::{Attachment, PayloadBuilder, Slack};
use std::env;

pub struct Client {
    slack: Slack,
}

pub trait SlackModule {
    fn new() -> Self;
    fn send(&self, channel: String, user_name: String, text: String, attachments: Vec<Attachment>);
}

impl SlackModule for Client {
    fn new() -> Self {
        let webhook_url = env::var("SLACK_WEBHOOK_URL")
            .unwrap_or_else(|_| panic!("SLACK_WEBHOOK_URL is not found."));

        Client {
            slack: Slack::new(webhook_url.as_str()).unwrap(),
        }
    }

    fn send(&self, channel: String, user_name: String, text: String, attachments: Vec<Attachment>) {
        let p = PayloadBuilder::new()
            .channel(channel)
            .username(user_name)
            .text(text)
            .attachments(attachments)
            .build()
            .unwrap();

        let _ = self.slack.send(&p);
    }
}

traitはこれです。

pub trait SlackModule {
    fn new() -> Self;
    fn send(&self, channel: String, user_name: String, text: String, attachments: Vec<Attachment>);
}

なので、このtraitを満たすようにして ターミナルに料金情報を標準出力する 実装をつくります。

use crate::slack_module::SlackModule;
use slack_hook::Attachment;

pub struct MockClient {}

impl SlackModule for MockClient {
    fn new() -> Self {
        MockClient {}
    }

    fn send(&self, channel: String, user_name: String, text: String, attachments: Vec<Attachment>) {
        println!(
            "slackへメッセージを送信: channel: {}, user_name: {}, text: {}, attachments: {:?}",
            channel, user_name, text, attachments
        );
    }
}

別の実装ができたので、利用する側を切り替えます。

現在のslackへ送信する処理はこんな感じ(抜粋)になっており、通常のClientを利用するようになってます。


use module::slack::Client;

let get_cost: GetCost<AWS, Client> = UseCase::new().await;

get_cost.run(start, end, channel).await;

これをMockのClientを利用するように変更します。


use module::slack::MockClient;

let get_cost: GetCost<AWS, MockClient> = UseCase::new().await;

get_cost.run(start, end, channel).await;

実行してみます。
スクリーンショット_2020-05-29_5_18_17.png

標準出力に料金情報が表示され、実装が切り替わっていることが分かります。

最後に

rustは難しいですがやってて楽しいですね!

52
46
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
46