RustのaxumでバックエンドAPI開発を試す!


 

こんにちは。Tomoyuki(@tomoyuki65)です。

まだまだ普及はしていませんが、一部の間で愛されているプログラミング言語に「Rust」があります。

そんなRustはメモリ安全性を重視したプログラミング言語であり、CやC++言語並のパフォーマンスを発揮しながら、ガベージコレクション(不要になったメモリ領域を自動的に解放する仕組み)なしでメモリ管理の安全性を実現(メモリ関連のバグを未然に防ぎやすい)しているのが特徴です。

どちらかというとC言語に近いため、ツール開発等に向いているような言語ですが、Web系のバックエンドAPI開発も普通に可能なため、将来的に普及していく可能性は高いと思われます。

ということで、そんなRustを使ってバックエンドAPI開発について試してみましたので、この記事では得られた知見をまとめます。

 



RustのaxumでバックエンドAPI開発を試す!

今回はaxumというフレームワークを用いてバックエンドAPI開発を試してみますが、開発環境の構築にはDockerを使いますので、もしまだ使えないという方はDocker Desktopなどをインストールして事前に使えるようにして下さい。

ではまず以下のコマンドを実行し、各種ファイルを作成します。

$ mkdir rust-sample && cd rust-sample
$ mkdir -p docker/local/rust && cd docker/local/rust
$ touch Dockerfile && cd ../../..
$ touch .env compose.yml

 

次に作成した各種ファイルをそれぞれ以下のように記述します。

・「docker/local/rust/Dockerfile」

FROM rust:1.86

WORKDIR /app

COPY . .

# ホットリロード用のライブラリをインストール
RUN cargo install cargo-watch

# Rust用のリンターをインストール
RUN rustup component add clippy

 

・「.env」

ENV=local
PORT=8080

 

・「compose.yml」

services:
  api:
    container_name: rust-api
    build:
      context: .
      dockerfile: ./docker/local/rust/Dockerfile
    command: cargo watch -x run
    volumes:
      - .:/app
    ports:
      - "8080:8080"
    env_file:
      - ./.env
    tty: true
    stdin_open: true

 

次に以下のコマンドを実行し、コンテナをビルドします。

$ docker compose build --no-cache

 

次に以下のコマンドを実行し、Rustのプロジェクト作成用の初期化を行います。

$ docker compose run --rm api cargo init --name rust_api

 

コマンド実行後、下図のように「src/main.rs」、「.gitignore」、「Cargo.toml」が作成されればOKです。

 

次に以下のコマンドを実行し、「src/main.rs」をコンパイルして実行してみます。

$ docker compose run --rm api cargo run

 

コマンド実行後、下図のように文字列「Hello, world!」が出力されればOKです。

 



axumというフレームワークでAPIを作る

次に以下のコマンドを実行し、APIで必要になる各種クレートを追加します。

$ docker compose run --rm api cargo add axum
$ docker compose run --rm api cargo add tokio --features full
$ docker compose run --rm api cargo add serde --features derive
$ docker compose run --rm api cargo add envy
$ docker compose run --rm api cargo add thiserror
$ docker compose run --rm api cargo add serde_json

 

次に以下のコマンドを実行し、API用の各種ファイルを作成します。

$ mkdir -p src/api && cd src/api
$ touch mod.rs router.rs

$ mkdir configs && cd configs
$ touch mod.rs config.rs && cd ..

$ mkdir errors && cd errors
$ touch mod.rs error.rs && cd ..

$ mkdir repositories && cd repositories && touch mod.rs
$ mkdir sample && cd sample
$ touch mod.rs sample_repository.rs && cd ../..

$ mkdir services && cd services && touch mod.rs
$ mkdir sample && cd sample
$ touch mod.rs sample_service.rs && cd ../..

$ mkdir handlers && cd handlers && touch mod.rs
$ mkdir sample && cd sample
$ touch mod.rs sample_handler.rs && cd ../..

$ mkdir usecases && cd usecases && touch mod.rs
$ mkdir sample && cd sample
$ touch mod.rs sample_usecase.rs && cd ../../../..

※クリーンアーキテクチャを参考に、ハンドラー層、ユースケース層、サービス層、リポジトリー層に分ける形でファイルを分割しています。

 

次に作成した各種ファイルをそれぞれ以下のように記述します。

・「src/api/mod.rs」

pub mod configs;
pub mod errors;
pub mod handlers;
pub mod repositories;
pub mod router;
pub mod services;
pub mod usecases;

※Rustではディレクトリ内にmod.rsを作成し、それによってモジュールの公開設定を行います。対象のモジュールを公開すると、別のモジュールファイルからアクセスできるようになります。尚、対象のモジュールの特定の処理だけ公開するとかも可能です。

 

・「src/api/router.rs」

// axum
use axum::{
    Router,
    routing::{get, post},
};

// ハンドラー用のモジュール
use super::handlers::sample::sample_handler;

pub fn router() -> Router {
    // APIのグループ「v1」
    let v1 = Router::new()
        .route("/sample/get", get(sample_handler::sample_get))
        .route(
            "/sample/get/{id}",
            get(sample_handler::sample_get_path_query),
        )
        .route("/sample/post", post(sample_handler::sample_post));

    // ルーティング
    Router::new()
        .nest("/api/v1", v1)
}

 

・「src/api/configs/mod.rs」

pub mod config;

 

・「src/api/configs/config.rs」

use envy;
use serde::{Deserialize};

// 環境変数のデフォルト値を返す関数
fn default_env() -> String {
    "local".to_string()
}

fn default_port() -> u16 {
    8080
}

// 環境変数の構造体
#[derive(Deserialize, Debug)]
pub struct Config {
    #[serde(default = "default_env")]
    pub env: String,
    #[serde(default = "default_port")]
    pub port: u16,
}

// 環境変数を返す関数
pub fn get_config() -> Config {
    match envy::from_env::<Config>() {
        Ok(config) => config,
        Err(err) => {
            println!("環境変数の初期化エラー: {}", err);

            // 環境変数にデフォルト値を設定して返す
            Config {
                env: default_env(),
                port: default_port(),
            }
        }
    }
}

※コンフィグファイルで環境変数を取得できるようにしています。「envy::from_env::<Config>()」戻り値は「Result<T, E>」型になっているため、matchで「Ok」または「Err」で条件判定させています。

 

・「src/api/errors/mod.rs」

pub mod error;

 

・「src/api/errors/error.rs」

use thiserror::Error;

#[derive(Error, Debug)]
pub enum CommonError {
    #[error("Internal Server Error")]
    InternalServerError,
}

※エラーファイルで、共通のエラー型を定義できるようにしています。

 

・「src/api/repositories/mod.rs」

pub mod sample;

 

・「src/api/repositories/sample/mod.rs」

pub mod sample_repository;

 

・「src/api/repositories/sample/sample_repository.rs」

// 共通エラー用モジュール
use crate::api::errors::error::CommonError;

// 文字列「Sample Hello !!」を返す関数
pub async fn sample_hello() -> Result<String, CommonError> {
    let text = "Sample Hello !!".to_string();

    if text.is_empty() {
        return Err(CommonError::InternalServerError);
    }

    Ok(text)
}

※リポジトリーファイルではDB操作や外部APIの実行などを記述する想定ですが、今回の例では文字列を返すだけの簡単な関数の処理にしています。

 

・「src/api/services/mod.rs」

pub mod sample;

 

・「src/api/services/sample/mod.rs」

pub mod sample_service;

 

・「src/api/services/sample/sample_service.rs」

// 共通エラー用モジュール
use crate::api::errors::error::CommonError;

// リポジトリ用のモジュール
use crate::api::repositories::sample::sample_repository;

// サンプルテキストを取得するサービス
pub async fn sample_get_text_hello() -> Result<String, CommonError> {
    let text = match sample_repository::sample_hello().await {
        Ok(text) => text,
        Err(err) => return Err(err),
    };

    Ok(text)
}

 

・「src/api/handlers/mod.rs」

pub mod sample;

 

・「src/api/handlers/sample/mod.rs」

pub mod sample_handler;

 

・「src/api/handlers/sample/sample_handler.rs」

// axum
use axum::{
    extract::{Path, Query},
    response::{Json, Response},
};

// 変換用のクレート
use serde::Deserialize;

// サービス用のモジュール
use crate::api::usecases::sample::sample_usecase;

// クエリパラメータ用の構造体
#[derive(Deserialize, Debug)]
pub struct QueryParams {
    pub item: Option<String>,
}

// リクエストボディの構造体
#[derive(Deserialize, Debug)]
pub struct RequestBody {
    pub name: String,
}

// GETメソッド用のAPIサンプル
pub async fn sample_get() -> Response {
    sample_usecase::sample_get_usecase().await
}

// GETメソッドかつパスパラメータとクエリパラメータ有りのAPIサンプル
pub async fn sample_get_path_query(
    Path(id): Path<String>,
    Query(params): Query<QueryParams>,
) -> Response {
    sample_usecase::sample_get_path_query_usecase(id, params).await
}

// POSTメソッド用のAPIサンプル
pub async fn sample_post(Json(body): Json<RequestBody>) -> Response {
    sample_usecase::sample_post_usecase(body).await
}

 

・「src/api/usecases/mod.rs」

pub mod sample;

 

・「src/api/usecases/sample/mod.rs」

pub mod sample_usecase;

 

・「src/api/usecases/sample/sample_usecase.rs」

// axum
use axum::{
    http::{StatusCode},
    response::{IntoResponse, Json, Response},
};

// json変換用マクロ
use serde_json::json;

// サービス用のモジュール
use crate::api::services::sample::sample_service;

// クエリパラメータ用の構造体
use crate::api::handlers::sample::sample_handler::QueryParams;

// リクエストボディ用の構造体
use crate::api::handlers::sample::sample_handler::RequestBody;

// GETメソッド用APIのサンプルユースケース
pub async fn sample_get_usecase() -> Response {
    // サンプルテキストを取得するサービスを実行
    let text = match sample_service::sample_get_text_hello().await {
        Ok(text) => text,
        Err(err) => {
            // json形式のメッセージを設定
            let msg = Json(json!({ "message": err.to_string()}));

            // レスポンス結果の設定
            let res = (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();

            // 戻り値としてレスポンス結果を返す
            return res;
        }
    };

    // json形式のメッセージを設定
    let msg = Json(json!({ "message": text}));

    // レスポンス結果を設定して戻り値として返す
    (StatusCode::OK, msg).into_response()
}

// GETメソッドかつパスパラメータとクエリパラメータ有りのサンプルユースケース
pub async fn sample_get_path_query_usecase(id: String, params: QueryParams) -> Response {
    let text = format!(
        "id: {}, item: {}",
        id,
        params.item.unwrap_or("".to_string())
    );

    // json形式のメッセージを設定
    let msg = Json(json!({ "message": text}));

    // レスポンス結果を設定して戻り値として返す
    (StatusCode::OK, msg).into_response()
}

// POSTメソッドのサンプルユースケース
pub async fn sample_post_usecase(body: RequestBody) -> Response {
    let text = format!("name: {}", body.name);

    // json形式のメッセージを設定
    let msg = Json(json!({ "message": text}));

    // レスポンス結果を設定して戻り値として返す
    (StatusCode::OK, msg).into_response()
}

 

次にファイル「src/main.rs」を以下のように修正します。

// axum
use axum::{
    Router,
    serve,
};

// apiモジュール
mod api;

// routerモジュール
use api::router::router;

// configsモジュール
use api::configs::config;

#[tokio::main]
async fn main() {
    // 環境変数取得
    let config = config::get_config();

    // サーバー起動のログ出力
    println!("Start rust_api (ENV:{}) !!", config.env);

    // サーバー起動
    let app = Router::new().merge(router());
    let addr = format!("0.0.0.0:{}", config.port);
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    serve(listener, app).await.unwrap();
}

 

次に以下のコマンドを実行し、コンテナを起動します。

$ docker compose up -d

 

次に作成したAPIを試しますが、APIの実行にはPostmanを使って試すため、もしまだ使ってないという方は事前に使えるようにして下さい。

ではまずGETメソッドのAPI「http://localhost:8080/api/v1/sample/get」を実行し、下図のように想定通りに正常終了すればOKです。

 

次にGETメソッドかつパスパラメータとクエリパラメータ有りのAPI「http://localhost:8080/api/v1/sample/get/11?item=book」を実行し、下図のように想定通りに正常終了すればOKです。

 

次にPOSTメソッドのAPI「http://localhost:8080/api/v1/sample/post」を実行し、下図のように想定通りに正常終了すればOKです。

 



テストコードを考慮してリポジトリー層をモック化しやすいように修正

上記ではクリーンアーキテクチャを参考にハンドラー層、ユースケース層、サービス層、リポジトリー層にコードを分けてAPIを作りましたが、さらにテストコードを書くことを考慮すると、特にリポジトリー層についてはモック化しやすいようにしておく必要があります。

そこでRustのstruct(構造体の定義)、impl(メソッドの定義)、trait(インターフェースの定義)を利用し、リポジトリー層をサービス層へ依存注入(他のオブジェクトを受け取って利用)できるように修正していきます。

まずは以下のコマンドを実行し、traitでasync fn(非同期関数)を扱いやすくする「async_trait」クレートを追加します。

$ docker compose exec api cargo add async_trait

 

次にユースケース層のファイルは1ハンドラー1ユースケースになるようファイル分割して作るため、以下のコマンドを実行してファイル「src/api/usecases/sample/sample_usecase.rs」の削除および、新しいファイルを3つ作成します。

$ rm -f src/api/usecases/sample/sample_usecase.rs
$ touch src/api/usecases/sample/sample_get_usecase.rs.rs
$ touch src/api/usecases/sample/sample_get_path_query_usecase.rs.rs
$ touch src/api/usecases/sample/sample_post_usecase.rs.rs

 

次に以下の各種ファイルについて、それぞれ修正等を行います。

・「src/api/repositories/sample/sample_repository.rs」

// 共通エラー用モジュール
use crate::api::errors::error::CommonError;

// サンプルリポジトリーの構造体
pub struct SampleRepository;

impl SampleRepository {
    // 初期化用メソッド
    pub fn new() -> Self {
        SampleRepository
    }
}

// サンプルリポジトリー用のトレイト(モック化もできるように定義)
#[async_trait::async_trait]
pub trait SampleRepositoryTrait {
    async fn sample_hello(&self) -> Result<String, CommonError>;
}

#[async_trait::async_trait]
impl SampleRepositoryTrait for SampleRepository {
    // 文字列「Sample Hello !!」を返す関数
    async fn sample_hello(&self) -> Result<String, CommonError> {
        let text = "Sample Hello !!".to_string();

        if text.is_empty() {
            return Err(CommonError::InternalServerError);
        }

        Ok(text)
    }
}

 

・「src/api/services/sample/sample_service.rs」

// 共通エラー用モジュール
use crate::api::errors::error::CommonError;

// リポジトリ用のモジュール
use crate::api::repositories::sample::sample_repository::SampleRepositoryTrait;

// 使用するリポジトリーをまとめる構造体
pub struct SampleCommonRepository {
    // Box<T>型で動的にメモリ領域確保
    // Send: オブジェクトが異なるスレッド間で安全に送信できることを保証
    // Sync: オブジェクトが複数のスレッドから同時にアクセスできることを保証
    // 'static: オブジェクトのライフタイムがプログラムが終了するまで破棄されない
    pub sample_repo: Box<dyn SampleRepositoryTrait + Send + Sync + 'static>,
}

// サンプルサービス
pub struct SampleService {
    repo: SampleCommonRepository,
}

impl SampleService {
    pub fn new(repo: SampleCommonRepository) -> Self {
        SampleService { repo }
    }
}

// サンプルサービス用のトレイト(モック化もできるように定義)
#[async_trait::async_trait]
pub trait SampleServiceTrait {
    async fn sample_get_text_hello(&self) -> Result<String, CommonError>;
}

#[async_trait::async_trait]
impl SampleServiceTrait for SampleService {
    async fn sample_get_text_hello(&self) -> Result<String, CommonError> {
        let text = match self.repo.sample_repo.sample_hello().await {
            Ok(text) => text,
            Err(err) => {
                return Err(err);
            }
        };

        Ok(text)
    }
}

※トレイトを依存注入できるようにするためにはBox<T>型を使う必要がありました。

 

・「src/api/usecases/sample/mod.rs」

pub mod sample_get_path_query_usecase;
pub mod sample_get_usecase;
pub mod sample_post_usecase;

 

・「src/api/usecases/sample/sample_get_usecase.rs」

// axum
use axum::{
    http::StatusCode,
    response::{IntoResponse, Json, Response},
};

// json変換用マクロ
use serde_json::json;

// サービスのモジュール
use crate::api::services::sample::sample_service::{SampleService, SampleServiceTrait};

// 使用するサービスをまとめる構造体
pub struct SampleCommonService {
    pub sample_service: SampleService,
}

// 実行するユースケースの構造体
pub struct SampleGetUsecase {
    pub service: SampleCommonService,
}

impl SampleGetUsecase {
    pub async fn exec(&self) -> Response {
        // サンプルテキストを取得するサービスを実行
        let text = match self.service.sample_service.sample_get_text_hello().await {
            Ok(text) => text,
            Err(err) => {
                // json形式のメッセージを設定
                let msg = Json(json!({ "message": err.to_string()}));

                // レスポンス結果の設定
                let res = (StatusCode::INTERNAL_SERVER_ERROR, msg).into_response();

                // 戻り値としてレスポンス結果を返す
                return res;
            }
        };

        // json形式のメッセージを設定
        let msg = Json(json!({ "message": text}));

        // レスポンス結果を設定して戻り値として返す
        (StatusCode::OK, msg).into_response()
    }
}

 

・「src/api/usecases/sample/sample_get_path_query_usecase.rs」

// axum
use axum::{
    http::StatusCode,
    response::{IntoResponse, Json, Response},
};

// json変換用マクロ
use serde_json::json;

// クエリパラメータ用の構造体
use crate::api::handlers::sample::sample_handler::QueryParams;

// 実行するユースケースの構造体
pub struct SampleGetPathQueryUsecase;

impl SampleGetPathQueryUsecase {
    pub async fn exec(&self, id: String, params: QueryParams) -> Response {
        // テキスト設定
        let text = format!(
            "id: {}, item: {}",
            id,
            params.item.unwrap_or("".to_string())
        );

        // json形式のメッセージを設定
        let msg = Json(json!({ "message": text}));

        // レスポンス結果を設定して戻り値として返す
        (StatusCode::OK, msg).into_response()
    }
}

 

・「src/api/usecases/sample/sample_post_usecase.rs」

// axum
use axum::{
    http::StatusCode,
    response::{IntoResponse, Json, Response},
};

// json変換用マクロ
use serde_json::json;

// リクエストボディ用の構造体
use crate::api::handlers::sample::sample_handler::RequestBody;

// 実行するユースケースの構造体
pub struct SamplePostUsecase;

impl SamplePostUsecase {
    pub async fn exec(&self, body: RequestBody) -> Response {
        // テキスト設定
        let text = format!("name: {}", body.name);

        // json形式のメッセージを設定
        let msg = Json(json!({ "message": text}));

        // レスポンス結果を設定して戻り値として返す
        (StatusCode::OK, msg).into_response()
    }
}

 

・「src/api/handlers/sample/sample_handler.rs」

// axum
use axum::{
    extract::{Path, Query},
    response::{Json, Response},
};

// 変換用のクレート
use serde::Deserialize;

// リポジトリーのモジュール
use crate::api::repositories::sample::sample_repository::SampleRepository;

// サービスのモジュール
use crate::api::services::sample::sample_service::{SampleCommonRepository, SampleService};

// ユースケースのモジュール
use crate::api::usecases::sample::sample_get_path_query_usecase::SampleGetPathQueryUsecase;
use crate::api::usecases::sample::sample_get_usecase::{SampleCommonService, SampleGetUsecase};
use crate::api::usecases::sample::sample_post_usecase::SamplePostUsecase;

// クエリパラメータ用の構造体
#[derive(Deserialize, Debug)]
pub struct QueryParams {
    pub item: Option<String>,
}

// リクエストボディの構造体
#[derive(Deserialize, Debug)]
pub struct RequestBody {
    pub name: String,
}

// GETメソッド用のAPIサンプル
pub async fn sample_get() -> Response {
    // サービスのインスタンス化
    let sample_repo = Box::new(SampleRepository::new());
    let sample_common_repo = SampleCommonRepository { sample_repo };
    let sample_service = SampleService::new(sample_common_repo);
    let sample_common_service = SampleCommonService { sample_service };

    // ユースケースを実行
    let sample_get_usecase = SampleGetUsecase {
        service: sample_common_service,
    };
    sample_get_usecase.exec().await
}

// GETメソッドかつパスパラメータとクエリパラメータ有りのAPIサンプル
pub async fn sample_get_path_query(
    Path(id): Path<String>,
    Query(params): Query<QueryParams>,
) -> Response {
    // ユースケースを実行
    let sample_get_path_query_usecase = SampleGetPathQueryUsecase;
    sample_get_path_query_usecase.exec(id, params).await
}

// POSTメソッド用のAPIサンプル
pub async fn sample_post(Json(body): Json<RequestBody>) -> Response {
    // ユースケースを実行
    let sample_post_usecase = SamplePostUsecase;
    sample_post_usecase.exec(body).await
}

 

次に3つのAPIをそれぞれ再度実行してみて下さい。修正前と同様にそれぞれ正常終了すればOKです。

 

尚、この記事ではテストコードについては割愛しますが、「mockall」クレートを追加後に対象のリポジトリーに「#[mockall::automock]」を追加することでモック用の構造体も自動作成され、それを使うとテストコードで対象のリポジトリーのモック化が可能になります。

 



最後に

今回はRustのaxum(フレームワーク)でバックエンドAPI開発を試した知見をまとめました。

Rustに関してはGo言語以上に情報が少なかったり、言語自体の難易度も高めなので、今回ご紹介した部分の基本的なことでさえキャッチアップするのが大変でした。

ただ基本的なところはある程度ご紹介できたと思うので、これからRustでバックエンドAPIを開発しようと検討している方は、ぜひ参考にしてみて下さい!

 

各種SNSなど

各種SNSなど、チャンネル登録やフォローをしていただけると励みになるので、よければぜひお願いします!

 

The following two tabs change content below.

Tomoyuki

SEを5年経験後、全くの未経験ながら思い切ってブロガーに転身し、月間13万PVを達成。その後コロナの影響も受け、以前から興味があったWeb系エンジニアへのキャリアチェンジを決意。現在はWeb系エンジニアとして働きながら、プロゲーマーとしても活躍できるように活動中。








シェアはこちらから


【2025年】おすすめのゲーミングPC

モンハンワイルズの発売日とPC版(Steam版)の推薦スペックが公開されたので、おすすめのゲーミングPCをご紹介!


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です