Rustで一から実装するコネクションのDI_HTTP-Client編

Rustで一から実装するコネクションのDI_HTTP-Client編

キャディ株式会社でBackendやDevOpsのエンジニアをやっている山下です。

今回の記事ではRustでコネクションのDIを実装する方法を実装例を交えながら書いていきます。

DI自体は、このニッチな記事に巡り合った皆様なら長々とした説明不要だと思います。
そのため、今回は簡単な説明や必要な理由を話した上で、
RustでどうやったらコネクションのDIが出来るのかをチュートリアル形式で書いていきます。

コネクションのDIとは

弊社では、Rustで社内の基幹システムにあたるSCM(Supply Chain Mangement)システムを開発しています。
基幹システムというと随分と大仰ですが、要はWebアプリケーションの1つであることは間違いありません。

そうなりますと、様々なミドルウェアと接続する必要があります。
加えて、サービスも分割されて管理されているので、その他サービスとも通信する必要があります。

具体的に接続するのが下記のような要素です。
– DB(Postgres)
– キューイングシステム(RabbitMQ)
– 分散検索エンジン(ElasticSearch)
– 外部API

ただ、これらのシステムを全て結合した状態でテストを行うのは非常に大変です。

そうした問題の解決のために、実際のサービスと接続するのとMockに接続するのを柔軟に切り替えたくなります。
それを解決するのがコネクションのDIです。

DIを実装すれば、ローカルやCI時にはMockを使用し実際に動かすときはProduction用のコードに切り替える、ということが可能になります。
加えて、接続先にミドルウェアを別のものにしたい時も容易に換装可能になり、保守性がグッと高まります。

具体的な実装

実装例の中身

上記の例を全て実装すると大変なので、今回は外部APIを叩いてそのデータをDBに格納するまでをやってみましょう。
今回は、感覚を掴むために、まず実装外部API連携を使って実装例を書いていきたいと思います。
(次の記事でDB編をあげたいと思います)

内容はシンプルで、公開されている外部APIを叩くだけの内容です
これをMockとProductionに分けて実装し、簡単なテストも書いていきます。

使用する外部APIは、ISS(国際宇宙ステーション)の場所を教えてくれる、こちらのAPIです。

使用するライブラリー

使用しているのは、以下のライブラリーです。

  • Runtimeとしてtokio
  • HTTPリクエストを行うためにreqwest
  • 環境変数の切り替えのためにenvy
  • Resultの処理を簡素化するためにanyhow

Cargo.toml

[dependencies]
anyhow = "1.0.28"
tokio = { version = "0.2", features = ["macros"] }
reqwest = "0.10.4"
envy = "0.4.1"

チュートリアル解説

まずは愚直に書いてみる

URLをベタ書きでreqwestを使ってAPIをCallしてみます。

まず、projectを作成します。

$ cargo new rust-connection-di

src/main.rs

use anyhow::Result;
use std::process::exit;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let iss_api_url = "http://api.open-notify.org/iss-now.json".to_string();

    let mut runtime = tokio::runtime::Runtime::new()?;
    runtime.block_on(async move {
        let body = match get_iss_now(iss_api_url).await {
            Ok(val) => val,
            Err(_) => {
                exit(1);
            }
        };

        println!("body = {:?}", body);

        loop {
            tokio::time::delay_for(std::time::Duration::from_millis(1)).await;
        }
    })
}

async fn get_iss_now(path: String) -> Result<String> {
    let body = reqwest::get(&path).await?.text().await?;
    Ok(body)
}

早速かけたので、実行してみましょう。

rust-connection-di (master) $ cargo run
   Compiling rust-connection-di v0.1.0 
    Finished dev [unoptimized + debuginfo] target(s) in 1.98s
     Running `target/debug/rust-connection-di`
body = "{\"iss_position\": {\"longitude\": \"52.1244\", \"latitude\": \"23.3608\"}, \"message\": \"success\", \"timestamp\": 1587892299}"

動いた! めでたしめでたし。

DIを実装してみる

だと、話が終わってしまいます。
早速、今回の目的である、DIを実装してみます。

まずは現状のものを載せ替えるために、Mockは実装せず、Productionから実装していきます。

Production用の定義を実装

Traitを定義する(Mock, Productinon共通)

APIのtraitとそのClientを注入するためのTraitを実装します。

trait.rs

pub trait ExternalApiClient {
    fn url(&self) -> String;
}

pub trait ProvideExternalApiClient {
    type Config: ExternalApiClient;

    fn provide(&self) -> &Self::Config;
}

Production用のStructを定義する

具体的なClientの設定をStructとして実装します。この際、Applicationも実装します。

現状では、Applicationの内容がProductionExternalApiClientと同一のため、実装されているのが冗長に思えます。
ですが、今後、Postgresの設定など他に注入したい設定が出た際に合わせて設定を行うためです。

src/app/production_application.rs

pub struct ProductionExternalApiClient {
    url: String,
}

pub struct Application {
    external_api_client: ProductionExternalApiClient,
    // 以下に、pg_poolなど他に必要な設定が出たタイミングで設定します
}

Structを実装する

Structで定義したClientの設定を作成できるように実装します。

src/app/production_application.rs

impl ProductionExternalApiClient {
    pub fn new() -> Self {
        Self {
            url: "http://api.open-notify.org/iss-now.json".to_string(),
        }
    }
}

impl Application {
    pub fn new(external_api_client: ProductionExternalApiClient) -> Self {
        Self {
            external_api_client,
        }
    }
}

StructにTraitを実装する

最初に定義したTraitをStructに実装します。

そのために、まずインポートを行う必要があります。

src/app/production_application.rs

use crate::r#trait::{ExternalApiClient, ProvideExternalApiClient};

インポートしたTraitを実装していきます。

src/app/production_application.rs

impl ExternalApiClient for ProductionExternalApiClient {
    fn url(&self) -> String {
        self.url.clone()
    }
}

impl ProvideExternalApiClient for Application {
    type Config = ProductionExternalApiClient;

    fn provide(&self) -> &Self::Config {
        &self.external_api_client
    }
}

上記の設定をmain.rsにインポートする

src/app.rs

mod production_application;

pub use production_application::*;

src/main.rs

pub mod app;
pub mod r#trait;

use app::{Application, ProductionExternalApiClient};
use r#trait::{ExternalApiClient, ProvideExternalApiClient};

Funcionに依存性を注入できるように変更する

genericsで依存を表し、trait境界を定義することで依存性を後から注入できるようにします。
ここでのポイントは、この依存性の注入によって、接続先がProductionであろうとMockであろうと同じロジックを維持できることです。

これにより、ドメインの関心毎とインフラレイヤーにあたる外部API連携とを切り離して実装・管理できます。

src/main.rs

async fn get_iss_now<T>(ctx: &T) -> Result<String>
where
    T: ProvideExternalApiClient,
{
    let url = ctx.provide().url();
    let body = reqwest::get(&url).await?.text().await?;
    Ok(body)
}

以前は、引数として渡ってきていたパスをそのまま使う = 呼び出し元に依存していました。
ですが、依存性を後から注入できるようにしたことで、依存性が逆転しています。

呼び出し先も忘れずに変更しておきましょう。

src/main.rs

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = ProductionExternalApiClient::new();
    let app = Application::new(config);

    let mut runtime = tokio::runtime::Runtime::new()?;
    runtime.block_on(async move {
        let body = match get_iss_now(&app).await {
            Ok(val) => val,
            Err(_) => {
                exit(1);
            }
        };

        println!("body = {:?}", body);

        loop {
            tokio::time::delay_for(std::time::Duration::from_millis(1)).await;
        }
    })
}

動かしてみる

rust-connection-di (master) $ cargo run
   Compiling rust-connection-di v0.1.0 
    Finished dev [unoptimized + debuginfo] target(s) in 2.76s
     Running `target/debug/rust-connection-di`
body = "{\"iss_position\": {\"longitude\": \"-154.8540\", \"latitude\": \"-13.1714\"}, \"message\": \"success\", \"timestamp\": 1587900869}"

見事動きました!

Mockを実装してみる

ただ、ここで終わると沢山実装したので一時の満足感はありますが
実は単にめんどくさい作業をやっただけ、という状態です。
(私も、そろそろ息切れしてきました)

頑張ってDIのメリットを実感するためにMockの実装に移っていきましょう!

以下、ほぼ一緒じゃん! と思われること請け合いなのですが、
このほぼ似たコードを何回も書かないといけないのは、DIを書く際の宿命的なところではあります。

特に今回は、configの中身がどちらもurlしかないため面倒に感じますが、
Client設定が複雑になってきた場合などに効果を発揮します。

Traitを実装する

これは既に実装しているので、この工程はスキップです。

Mock用のStructを定義する

src/app/mock_application.rs

pub struct MockExternalApiClient {
    url: String,
}

pub struct MockApplication {
    external_api_client: MockExternalApiClient,
    // 以下に、pg_poolなど他に必要な設定が出たタイミングで設定します
}

Structを実装する

src/app/mock_application.rs

impl MockExternalApiClient {
    pub fn new() -> Self {
        Self {
            url: "http://localhost:3000/iss-now".to_string(),
        }
    }
}

impl MockApplication {
    pub fn new(external_api_client: MockExternalApiClient) -> Self {
        Self {
            external_api_client,
        }
    }
}

StructにTraitを実装する

まず忘れずにTraitをインポートします。

src/app/mock_application.rs

use crate::r#trait::{ExternalApiClient, ProvideExternalApiClient};

インポートしたTraitを実装していきます。

src/app/mock_application.rs

impl ExternalApiClient for MockExternalApiClient {
    fn url(&self) -> String {
        self.url.clone()
    }
}

impl ProvideExternalApiClient for MockApplication {
    type Config = MockExternalApiClient;

    fn provide(&self) -> &Self::Config {
        &self.external_api_client
    }
}

上記の設定をインポート可能にする

src/app.rs

mod mock_application;
mod production_application;

pub use mock_application::*;
pub use production_application::*;

Funcionに依存性を注入できるように変更する

Functionは、処理が共通化されているため、変更が不要です。

よって、これでMockの実装は終わりです! お疲れ様でした!

テストを書いてみる

何のために、こんなまどろっこしいことをしているか思い出しください。
そう、テストです(だけではないです)

何より、折角上記で実装したMockをまだ試せていません。

テスト環境を用意します

こちらの記事を参考にさせて頂いて、
Mock用のAPIサーバーをjson-serverとdocker-composeを使って用意していきます。

構成

.
├── src
├── docker-compose.yaml
└── mock-server
    ├── Dockerfile
    └── db.json

docker-compose

docker-compose.yaml

version: "3"
services:
  mock-server:
    build: ./mock-server
    container_name: mock-server
    ports:
      - "3000:3000"
    volumes:
      - ./mock-server/db.json:/data/db.json
    command: json-server --watch db.json --host 0.0.0.0

Dockerfile

mock-server/Dockerfile

FROM node:10.15.0

RUN npm install -g json-server

WORKDIR /data

EXPOSE 3000

db.json

mock-server/db.json

{
  "iss-now": {
    "message": "success",
    "iss_position": { "longitude": "53.0671", "latitude": "-34.6539" },
    "timestamp": 1587904635
  }
}

環境を立ち上げる

$ docker-compose up -d

立ち上がっているかを確認しましょう。

$ curl http://localhost:3000/iss-now

テストコード

本体のテストコードはこちらです。

src/main.rs

#[tokio::test]
async fn test_get_iss_now() {
    let config = app::MockExternalApiClient::new();
    let app = app::MockApplication::new(config);

    let body = get_iss_now(&app).await.unwrap();
    assert!(body.contains("success"));
}

実行するとこんな感じになります。

rust-connection-di (master) $ cargo test
   Compiling rust-connection-di v0.1.0 
    Finished test [unoptimized + debuginfo] target(s) in 2.00s
     Running target/debug/deps/rust_connection_di-b73da58c635b7cb7

running 1 test
test test_get_iss_now ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

遂に、MockでConnectionを差し替えてテストを行えました!
(大分、assertionが適当になっていますが、是非余裕があれば、serdeを使ってresponseをdeserializeした上でテストしてみてください!)

最後に

以上で、HTTP-Client編は終わりです。
今回使用したコードはこちらに格納されています。

DIの実装の雰囲気をお伝えできましたでしょうか?
実装のシンプルさを優先した結果、DIのメリットが分かりにくくなった側面もあるかと思いますので、そこは続編のDB編をご覧頂けましたら幸いです。

【宣伝】
キャディでは、全社でRustを使用して本番のプロダクト開発を行っています。
フロントエンドのエンジニアを除けば、ほぼ全員Rustaceanという少し変わった組織です。

ただ、1,2人を除けば、 Rust経験者はいない状況から始めて本番でのプロダクト開発をガリガリ行うまでになっております。
もし少しでもRustによる開発にご興味をお持ちの方がいらっしゃれば、
Backendエンジニアの募集も行っておりますので、是非お気軽にご連絡ください。

Hiroaki Yamashita
  • Hiroaki Yamashita
  • 前職で、DevOpsエンジニアとしてCI/CDやIaC、自動テストなどの導入を行った後、自動テストツールの企画・開発・導入などを経験
    現在は、GitOpsに則したCI/CDの構築やk8s,Terraformを使ったIaCの実現などを行ったのち、Rustによるバックエンド開発に従事