Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
40
Help us understand the problem. What is going on with this article?
@pokotyan

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

はじめに

こちらに触発されて、同じようなものを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は難しいですがやってて楽しいですね!

40
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
pokotyan
admin-guild
「Webサービスの運営に必要なあらゆる知見」を共有できる場として作られた、運営者のためのコミュニティです。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
40
Help us understand the problem. What is going on with this article?