🦀

元PythonエンジニアがRustのRocket+SQLxでWebAPI書いてみた

2024/12/06に公開

この記事は「レバテック開発部 Advent Calendar 2024」の 6 日目の記事です!
昨日の記事は、gorilla_swe さんの「【入社エントリ】レバテックで過ごした4ヶ月を振り返る」でした。

1. はじめに

自己紹介

はじめまして!レバテック開発部の高瀬と申します!
今年の10月からレバレジーズに参画し、現在はレバテック開発部で社内SFAの開発に携わっています。
前職では主にPythonを使ったWeb APIの開発を担当していました。

そんな私が今回、レバテック開発部のアドベントカレンダーに参加するにあたり、テーマとして以前から興味があったRustを使った簡単なWeb API構築 に挑戦します!

なぜRustなのか?

Pythonに限らずですが、最近は色々な言語のツールチェイン周りでRust製のツールが多く登場している印象がありました。
私もruffやuvを使ってみたところ確かに実行時間がとても短く「Rustすごそう!」と興味を持っていました。

レバテック開発部内でも「Rustを評価する会」のような興味深いイベントがあったりと、
部内でも関心が高まっているのを感じます。

私自身もRustの勉強を始めたばかりですが、今回はその学習の一環として、 簡単なCRUD操作が可能なWeb API を構築してみたいと思います。

Rocketについて

Rustの主要なWebフレームワークは下記の3つのようです。

  • actix-web
  • axum
  • Rocket

https://rocket.rs/

今回はgithubのstar数が一番多く、一番開発が容易そうだと感じたRocketを使ってみます。
特にJSONやフォームデータの自動パース機能が充実しているので直感的にAPIを書けそうです。

リクエストボディを自動的にRustの構造体に変換する例
#[derive(serde::Deserialize)]
struct User {
    name: String,
    age: u8,
}

#[post("/users", data = "<user>")]
fn create_user(user: Json<User>) -> String {
    format!("Created user: {} ({} years old)", user.name, user.age)
}

SQLxについて

Rustの主要なデータベースライブラリは下記の3つのようです。

  • Diesel
  • SQLx
  • SeaORM

https://github.com/launchbadge/sqlx

SQLxが結構Rust界隈では人気な印象を受けたのでこちらを採用してみました。

SQLxがRust界隈で人気の理由として、非ORMながら型安全性をコンパイル時に確保できる点と、Rustの非同期処理に対応している点が魅力みたいですね。
特に、実行前にSQLとデータベーススキーマの整合性をチェックしてくれるため、安心して生SQLを記述できるみたいです。

2. 準備

前提

  • Rustの基本的な構文や所有権などは雰囲気で理解してるつもり(公式ドキュメントや書籍を読んだ程度)
  • Dockerが使える環境であること

用いる技術

  • Rust: 1.82.0
  • Rocket:0.5.1
  • SQLx:1.0
  • MySQL: 8.0

作るもの

ユーザー名とメールアドレスを登録できるユーザー管理APIを作ります。
実装するエントリーポイントは以下。

  • ユーザー作成
  • ユーザー一覧
  • ユーザー取得
  • ユーザー更新
  • ユーザー削除

環境

Dockerfile
Dockerfile
# Rustの公式イメージを使用
# 不要なツールやライブラリを省いたslimイメージを利用
FROM rust:1.82.0-slim

# 必要なパッケージをインストール
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    pkg-config \
    cmake \
    libmariadb-dev \
    curl \
    libssl-dev \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# SQLx CLI をインストール(MySQL用機能を有効化)
RUN cargo install sqlx-cli --no-default-features --features mysql

# Rustfmtをインストール
RUN rustup component add rustfmt

# プロジェクトディレクトリの設定
WORKDIR /app

# プロジェクトのファイルをコピー
COPY . .

# デフォルトコマンドをRustアプリケーションの実行に設定
# 全てのコードを書き終えたらここをコメントアウトすればコンテナ起動時にAPIが起動できるようになるはず
# CMD ["cargo", "run", "--release"]
docker-compose.yml
docker-compose.yml
version: '3.8'

services:
  rust_app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: rust_app
    volumes:
      - .:/app
    working_dir: /app
    ports:
      - "8000:8000" # Rocketのデフォルトポート
    environment:
      - DATABASE_URL=mysql://rust_user:password@rust_mysql:3306/rust_db
    depends_on:
      - mysql
    networks:
      - rust_network
    tty: true

  mysql:
    image: mysql:8.0
    container_name: rust_mysql
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: rust_db  # 初期に生成されるデータベース名
      MYSQL_USER: rust_user
      MYSQL_PASSWORD: password
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - rust_network

volumes:
  db_data:

networks:
  rust_network:

環境構築

dockerコンテナの起動と確認

まずはdocker composeコマンドでコンテナ起動

% docker compose up -d

起動確認

% docker ps -a
CONTAINER ID   IMAGE                            COMMAND                   CREATED              STATUS         PORTS                                                  NAMES
c60811ade993   adv-lv-rocket-web-api-rust_app   "bash"                    About a minute ago   Up 5 seconds   0.0.0.0:8000->8000/tcp, :::8000->8000/tcp              rust_app
eacccceee1ee   mysql:8.0                        "docker-entrypoint.s…"   About a minute ago   Up 6 seconds   0.0.0.0:3306->3306/tcp, :::3306->3306/tcp, 33060/tcp   rust_mysql

rust_mysql内のデータベースが作成されているか確認

% docker exec -it rust_mysql mysql -urust_user -p
Enter password: // passwordを入力
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 9
Server version: 8.0.40 MySQL Community Server - GPL

Copyright (c) 2000, 2024, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| performance_schema |
| rust_db            |
+--------------------+
3 rows in set (0.01 sec)

Rustプロジェクトの初期化

rust_appコンテナでrustが使える状態か確認

% docker exec -it rust_app rustc -V
rustc 1.82.0 (f6e511eec 2024-10-15)

% docker exec -it rust_app cargo -V
cargo 1.82.0 (8f40fc59f 2024-08-21)

カレントディレクトリでRustプロジェクトを使う用意

% docker exec -it rust_app cargo init
    Creating binary (application) package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

これでRustを使う準備ができました👏

% docker exec -it rust_app cargo run 
   Compiling app v0.1.0 (/app)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
     Running `target/debug/app`
Hello, world!

3. 実装

依存関係

まず、プロジェクトの依存関係を設定します。最終的なCargo.tomlは以下のようになりました。
できるだけ最新バージョンのクレートを利用。

Cargo.toml
[package]
name = "adv-lv-rocket-web-api"
version = "0.1.0"
edition = "2021"

[dependencies]
# Rocket: Rust用のWebフレームワーク
rocket = { version = "0.5.1", features = ["secrets", "json"] }

# Serde: データのシリアライズ/デシリアライズ
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" # SerdeのJSON特化拡張

# Tokio: 非同期処理のランタイム
tokio = {version = "1.30.0", features = ["full"]}

# SQLx: 非同期データベースクライアント
sqlx = { version = "0.8.2", default-features = false, features = ["macros", "chrono", "json", "mysql", "runtime-tokio-native-tls"] }

マイグレーション

データベース操作を行うために、まずマイグレーションファイルを作成します。

migrationファイルの作成

% docker exec -it rust_app sqlx migrate add -r users
Creating migrations/20241127053854_users.up.sql
Creating migrations/20241127053854_users.down.sql

# migrations配下にファイルが生成される
% ls  migrations 
20241127053854_users.down.sql   20241127053854_users.up.sql

それぞれのファイルに今回扱うテーブルの作成、削除のSQLを記載します。

migrations/20241127053854_users.up.sql
-- Add up migration script here
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL
);
migrations/20241127053854_users.down.sql
-- Add down migration script here
DROP TABLE users;

migrationの実行

% docker exec -it rust_app sqlx migrate run
Applied 20241127053854/migrate users (8.616458ms)

これでデータベースの準備が整いました。

Rocketを使ってWebAPIを立ててみる

Rocket.tomlを作成し、ホストアクセス用のアドレスとデータベース接続情報を設定します。

[default]
address = "0.0.0.0"

[default.databases]
rust_db = { url = "mysql://rust_user:password@rust_mysql:3306/rust_db" }

必要なクレートをインポート

use rocket::{delete, get, post, put, routes, serde::json::Json, State};
use sqlx::MySqlPool;

ユーザー一覧取得API

データベースから取得したデータをマッピングするために、User構造体を定義します。

/// ユーザー情報を表す構造体
#[derive(Debug, serde::Serialize)]
struct User {
    /// ユーザーID(主キー)
    id: i32,
    /// ユーザー名
    name: String,
    /// ユーザーのメールアドレス
    email: String,
}

#[derive(...)]

  • Rustのマクロで、自動的にトレイトの実装を生成するためのものです。
  • この場合、Debugserde::Serialize の2つのトレイトがこの構造体に対して自動実装されています。

Debug

  • 構造体のデバッグ表現を提供するトレイト。
  • このトレイトを導入することで、println!("{:?}", user) のように構造体の中身をデバッグ用に表示することができます。

serde::Serialize

  • このトレイトを実装すると、構造体をJSONやYAMLのようなシリアル化形式に変換できます。
  • 例えば、serde_json::to_string(&user) を使うと、構造体をJSON文字列に変換できます。

実装

/// データベースから全てのユーザーを取得するエンドポイント
///
/// # 戻り値
/// 成功時はユーザーのリスト(JSON形式)、失敗時はHTTP 500エラーを返す。
#[get("/users")]
async fn list_users(pool: &State<MySqlPool>) -> Result<Json<Vec<User>>, rocket::http::Status> {
    match sqlx::query_as!(User, "SELECT id, name, email FROM users ORDER BY id DESC")
        .fetch_all(pool.inner())
        .await
    {
        Ok(users) => Ok(Json(users)),
        Err(err) => {
            eprintln!("Database query failed: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

#[get("/users")]

  • この関数はHTTPの GET メソッドで /users にマッピングされるエンドポイントを定義しています。

pool: &State<MySqlPool>

  • 共有リソースの取得
    • State<T> は、Rocketのグローバル状態管理の仕組みを利用して、アプリ全体で共有されるリソースをアクセス可能にする型です。
    • ここでは、MySQL接続プール(MySqlPool 型)を共有リソースとして取り扱っています。
    • pool.inner() を使って実際の接続プールへの参照を取得しています。

sqlx::query_as!

  • SQLxを使ったデータベースクエリ
  • sqlx::query_as! は、SQL文を実行してRustの構造体(ここでは User)にマッピングするためのマクロです。
  • このマクロにより、コンパイル時にSQL文の正しさ(型やフィールドの一致)を検証できるため、安全性が向上します。

.fetch_all(pool.inner()).await

  • クエリの実行
  • fetch_all は、すべての結果を非同期に取得するためのメソッドです。
  • データベース操作は時間がかかるため、.await を付けて非同期処理の完了を待機しています。

match とエラーハンドリング

  • 結果の処理
    • クエリの結果を match 式でパターンマッチングして処理しています。
  • 成功パターン (Ok(users)):
    • データベースクエリが成功した場合、取得した User のリストをJSONとして返します。
    • Json(users):
      • rocket::serde::json::Json 型にデータを包み、JSONレスポンスとして返すために使用しています。
  • エラーパターン (Err(err)):
    • クエリに失敗した場合、エラーメッセージを標準エラー出力 (eprintln!) に表示します。
    • その後、HTTPステータスコード 500 Internal Server Error を返します。

ユーザー作成API

  • ユーザー作成用の構造体を定義します。
/// 新しいユーザーを作成するためのデータ構造
#[derive(Debug, serde::Deserialize)]
struct NewUser {
    /// 新しいユーザーの名前
    name: String,
    /// 新しいユーザーのメールアドレス
    email: String,
}

実装

/// 新しいユーザーをデータベースに追加するエンドポイント
///
/// # パラメータ
/// - `new_user`: 作成するユーザーの名前とメールアドレス(JSON形式)。
///
/// # 戻り値
/// 成功時は作成されたユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[post("/users", format = "json", data = "<new_user>")]
async fn create_user(
    pool: &State<MySqlPool>,
    new_user: Json<NewUser>,
) -> Result<Json<User>, rocket::http::Status> {
    let new_user = new_user.into_inner();
    match sqlx::query!(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        new_user.name,
        new_user.email
    )
    .execute(pool.inner())
    .await
    {
        Ok(result) => {
            let last_insert_id = result.last_insert_id();
            match sqlx::query_as!(
                User,
                "SELECT id, name, email FROM users WHERE id = ?",
                last_insert_id as i32
            )
            .fetch_one(pool.inner())
            .await
            {
                Ok(user) => Ok(Json(user)),
                Err(err) => {
                    eprintln!("Database query failed: {}", err);
                    Err(rocket::http::Status::InternalServerError)
                }
            }
        }
        Err(err) => {
            eprintln!("Failed to insert user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

#[post("/users", format = "json", data = "<new_user>")]:

  • このエンドポイントは、POST メソッドで /users に対応。
  • リクエストボディは JSON フォーマットである必要があり、データは <new_user> にマッピングされます。

new_user: Json<NewUser>:

  • クライアントから送信されたJSONデータが、自動的にNewUser型としてデシリアライズされます。

sqlx::query!

  • query! はデータ操作用(INSERT, UPDATE)、query_as! はデータ取得時に構造体へマッピングする用です。

"INSERT INTO users (name, email) VALUES (?, ?)",

  • パラメータ化クエリ (? プレースホルダ) を使用して、SQLインジェクションを防止します。

.execute()

  • クエリを実行し、影響を受けた行数などのメタデータを取得します。

result.last_insert_id()

  • 新しく挿入されたレコードのIDを取得しています。
  • これを使って次に挿入したユーザーを検索します。

.fetch_one()

  • 結果が1件のみの場合に使用するメソッド。
  • 該当するデータがなかった場合や複数件取得された場合はエラーになります。

match とエラーハンドリング

  • 成功時は、新しく作成された User データを JSON として返します。
  • 失敗時は HTTP ステータス 500 Internal Server Error を返します。

ユーザー取得API

/// データベースから指定されたIDのユーザーを取得するエンドポイント
///
/// # パラメータ
/// - `id`: 取得したいユーザーのID。
///
/// # 戻り値
/// 成功時は該当ユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[get("/users/<id>")]
async fn get_user(pool: &State<MySqlPool>, id: i32) -> Result<Json<User>, rocket::http::Status> {
    match sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = ?", id)
        .fetch_one(pool.inner())
        .await
    {
        Ok(user) => Ok(Json(user)),
        Err(err) => {
            eprintln!("Database query failed: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

特に新しいことしてないので省略します。

ユーザー更新API

ユーザー更新用の構造体の定義

/// 既存のユーザーを更新するためのデータ構造
/// フィールドはオプションになっており、一部の値だけ更新可能
#[derive(Debug, serde::Deserialize)]
struct UpdateUser {
    /// 更新後の名前(任意)
    name: Option<String>,
    /// 更新後のメールアドレス(任意)
    email: Option<String>,
}

更新データは一部だけ指定されることがあるため、各フィールドは Option 型で定義

実装

/// 既存のユーザーを更新するエンドポイント
///
/// # パラメータ
/// - `id`: 更新対象のユーザーID。
/// - `update_user`: 更新後の名前またはメールアドレス(JSON形式)。
///
/// # 戻り値
/// 成功時は更新されたユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[put("/users/<id>", format = "json", data = "<update_user>")]
async fn update_user(
    pool: &State<MySqlPool>,
    id: i32,
    update_user: Json<UpdateUser>,
) -> Result<Json<User>, rocket::http::Status> {
    let update_user = update_user.into_inner();
    match sqlx::query!(
        "UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?",
        update_user.name,
        update_user.email,
        id
    )
    .execute(pool.inner())
    .await
    {
        Ok(_) => match sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = ?", id)
            .fetch_one(pool.inner())
            .await
        {
            Ok(user) => Ok(Json(user)),
            Err(err) => {
                eprintln!("Database query failed: {}", err);
                Err(rocket::http::Status::InternalServerError)
            }
        },
        Err(err) => {
            eprintln!("Failed to update user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

#[put("/users/<id>", format = "json", data = "<update_user>")]:

  • このエンドポイントは PUT メソッドで /users/<id> に対応します。
  • id はURLパスから取得される動的なパラメータです。
  • クライアントから送信されるデータは JSON フォーマットで、<update_user> にマッピングされます。

UPDATE文

  • COALESCE 関数を使用して、送信された値が NULL(未指定)であれば現在の値を保持するようにしています。
  • これは今回実装の時に便利なSQLがないか調べたときに初めて知りました。

エラーハンドリングは作成とほぼ同じです。

ユーザー削除API

/// 指定されたIDのユーザーをデータベースから削除するエンドポイント
///
/// # パラメータ
/// - `id`: 削除対象のユーザーID。
///
/// # 戻り値
/// - 成功時: HTTP 204(No Content)。
/// - 該当ユーザーが存在しない場合: HTTP 404。
/// - エラー時: HTTP 500。
#[delete("/users/<id>")]
async fn delete_user(
    pool: &State<MySqlPool>,
    id: i32,
) -> Result<rocket::http::Status, rocket::http::Status> {
    match sqlx::query!("DELETE FROM users WHERE id = ?", id)
        .execute(pool.inner())
        .await
    {
        Ok(result) => {
            if result.rows_affected() > 0 {
                Ok(rocket::http::Status::NoContent) // 204ステータスを返す
            } else {
                Err(rocket::http::Status::NotFound) // 該当するユーザーが存在しない場合
            }
        }
        Err(err) => {
            eprintln!("Failed to delete user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

result.rows_affected()

  • 削除クエリの影響を受けた行数を返します。
  • 行数 > 0
    • 削除成功。204 No Content を返します。
  • 行数 = 0
    • 指定されたIDがデータベースに存在しない。404 Not Found を返します。

アプリケーションのエントリーポイント定義

/// Rocketアプリケーションのエントリーポイント
///
/// - 環境変数 `DATABASE_URL` を読み取り、データベースに接続します。
/// - ユーザー関連のルートを設定し、サーバーを起動します。
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    // 環境変数の取得
    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
        eprintln!("Error: DATABASE_URL environment variable is not set. Please set it before running the application.");
        std::process::exit(1);
    });

    // データベースプールの作成
    let pool = sqlx::MySqlPool::connect(&database_url)
        .await
        .unwrap_or_else(|err| {
            eprintln!("Failed to connect to the database: {}", err);
            std::process::exit(1);
        });

    // Rocketの起動
    rocket::build()
        .manage(pool)
        .mount(
            "/",
            routes![list_users, get_user, create_user, update_user, delete_user],
        )
        .launch()
        .await?; // エラーを展開

    Ok(()) // 型に一致する () を返す
}

#[rocket::main]

  • Rocketアプリケーションの非同期エントリーポイントを指定。
  • 成功時は Ok(()) を返し、エラー時は rocket::Error を返します。

std::env::var("DATABASE_URL")

  • 環境変数 DATABASE_URL を取得。
  • 環境変数が設定されていない場合は、エラーメッセージを表示し、プログラムを終了します。

sqlx::MySqlPool::connect

  • sqlx を使用して、MySQLデータベースへの非同期接続を確立。

rocket::build()

  • Rocketアプリケーションのインスタンスを構築します。

.manage(pool)

  • アプリケーション全体で共有する状態(ここではデータベース接続プール)を設定。
  • これにより、ハンドラ関数内で pool を簡単に取得可能。

.mount("/")

  • ルート(/)に複数のエンドポイントをマッピング。
  • routes! マクロを使って、エンドポイント関数を登録(例: list_users, get_user, など)。

.launch()

  • Rocketサーバーを起動。

await?

  • 非同期処理を待機し、成功時は結果を返し、失敗時にはエラーをそのまま呼び出し元に伝播します。
  • ここまでmatchでしっかりエラーハンドリングしていますがここでは簡略化のために?演算子でエラーハンドリングを簡略化しています。

全体コード

全体通してみたい人用

全体コード
main.rs
use rocket::{delete, get, post, put, routes, serde::json::Json, State};
use sqlx::MySqlPool;

/// ユーザー情報を表す構造体
#[derive(Debug, serde::Serialize)]
struct User {
    /// ユーザーID(主キー)
    id: i32,
    /// ユーザー名
    name: String,
    /// ユーザーのメールアドレス
    email: String,
}

/// 新しいユーザーを作成するためのデータ構造
#[derive(Debug, serde::Deserialize)]
struct NewUser {
    /// 新しいユーザーの名前
    name: String,
    /// 新しいユーザーのメールアドレス
    email: String,
}

/// 既存のユーザーを更新するためのデータ構造
/// フィールドはオプションになっており、一部の値だけ更新可能
#[derive(Debug, serde::Deserialize)]
struct UpdateUser {
    /// 更新後の名前(任意)
    name: Option<String>,
    /// 更新後のメールアドレス(任意)
    email: Option<String>,
}

/// データベースから全てのユーザーを取得するエンドポイント
///
/// # 戻り値
/// 成功時はユーザーのリスト(JSON形式)、失敗時はHTTP 500エラーを返す。
#[get("/users")]
async fn list_users(pool: &State<MySqlPool>) -> Result<Json<Vec<User>>, rocket::http::Status> {
    match sqlx::query_as!(User, "SELECT id, name, email FROM users ORDER BY id DESC")
        .fetch_all(pool.inner())
        .await
    {
        Ok(users) => Ok(Json(users)),
        Err(err) => {
            eprintln!("Database query failed: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

/// データベースから指定されたIDのユーザーを取得するエンドポイント
///
/// # パラメータ
/// - `id`: 取得したいユーザーのID。
///
/// # 戻り値
/// 成功時は該当ユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[get("/users/<id>")]
async fn get_user(pool: &State<MySqlPool>, id: i32) -> Result<Json<User>, rocket::http::Status> {
    match sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = ?", id)
        .fetch_one(pool.inner())
        .await
    {
        Ok(user) => Ok(Json(user)),
        Err(err) => {
            eprintln!("Database query failed: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

/// 新しいユーザーをデータベースに追加するエンドポイント
///
/// # パラメータ
/// - `new_user`: 作成するユーザーの名前とメールアドレス(JSON形式)。
///
/// # 戻り値
/// 成功時は作成されたユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[post("/users", format = "json", data = "<new_user>")]
async fn create_user(
    pool: &State<MySqlPool>,
    new_user: Json<NewUser>,
) -> Result<Json<User>, rocket::http::Status> {
    let new_user = new_user.into_inner();
    match sqlx::query!(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        new_user.name,
        new_user.email
    )
    .execute(pool.inner())
    .await
    {
        Ok(result) => {
            let last_insert_id = result.last_insert_id();
            match sqlx::query_as!(
                User,
                "SELECT id, name, email FROM users WHERE id = ?",
                last_insert_id as i32
            )
            .fetch_one(pool.inner())
            .await
            {
                Ok(user) => Ok(Json(user)),
                Err(err) => {
                    eprintln!("Database query failed: {}", err);
                    Err(rocket::http::Status::InternalServerError)
                }
            }
        }
        Err(err) => {
            eprintln!("Failed to insert user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

/// 既存のユーザーを更新するエンドポイント
///
/// # パラメータ
/// - `id`: 更新対象のユーザーID。
/// - `update_user`: 更新後の名前またはメールアドレス(JSON形式)。
///
/// # 戻り値
/// 成功時は更新されたユーザーのデータ(JSON形式)、失敗時はHTTP 500エラーを返す。
#[put("/users/<id>", format = "json", data = "<update_user>")]
async fn update_user(
    pool: &State<MySqlPool>,
    id: i32,
    update_user: Json<UpdateUser>,
) -> Result<Json<User>, rocket::http::Status> {
    let update_user = update_user.into_inner();
    match sqlx::query!(
        "UPDATE users SET name = COALESCE(?, name), email = COALESCE(?, email) WHERE id = ?",
        update_user.name,
        update_user.email,
        id
    )
    .execute(pool.inner())
    .await
    {
        Ok(_) => match sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = ?", id)
            .fetch_one(pool.inner())
            .await
        {
            Ok(user) => Ok(Json(user)),
            Err(err) => {
                eprintln!("Database query failed: {}", err);
                Err(rocket::http::Status::InternalServerError)
            }
        },
        Err(err) => {
            eprintln!("Failed to update user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

/// 指定されたIDのユーザーをデータベースから削除するエンドポイント
///
/// # パラメータ
/// - `id`: 削除対象のユーザーID。
///
/// # 戻り値
/// - 成功時: HTTP 204(No Content)。
/// - 該当ユーザーが存在しない場合: HTTP 404。
/// - エラー時: HTTP 500。
#[delete("/users/<id>")]
async fn delete_user(
    pool: &State<MySqlPool>,
    id: i32,
) -> Result<rocket::http::Status, rocket::http::Status> {
    match sqlx::query!("DELETE FROM users WHERE id = ?", id)
        .execute(pool.inner())
        .await
    {
        Ok(result) => {
            if result.rows_affected() > 0 {
                Ok(rocket::http::Status::NoContent) // 204ステータスを返す
            } else {
                Err(rocket::http::Status::NotFound) // 該当するユーザーが存在しない場合
            }
        }
        Err(err) => {
            eprintln!("Failed to delete user: {}", err);
            Err(rocket::http::Status::InternalServerError)
        }
    }
}

/// Rocketアプリケーションのエントリーポイント
///
/// - 環境変数 `DATABASE_URL` を読み取り、データベースに接続します。
/// - ユーザー関連のルートを設定し、サーバーを起動します。
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    // 環境変数の取得
    let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| {
        eprintln!("Error: DATABASE_URL environment variable is not set. Please set it before running the application.");
        std::process::exit(1);
    });

    // データベースプールの作成
    let pool = sqlx::MySqlPool::connect(&database_url)
        .await
        .unwrap_or_else(|err| {
            eprintln!("Failed to connect to the database: {}", err);
            std::process::exit(1);
        });

    // Rocketの起動
    rocket::build()
        .manage(pool)
        .mount(
            "/",
            routes![list_users, get_user, create_user, update_user, delete_user],
        )
        .launch()
        .await?; // エラーを展開

    Ok(()) // 型に一致する () を返す
}

4. 動作確認

起動

$ docker exec -it rust_app cargo fmt // フォーマッターで整形
$ docker exec -it rust_app cargo run
   Compiling adv-lv-rocket-web-api v0.1.0 (/app)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.20s
     Running `target/debug/adv-lv-rocket-web-api`
🔧 Configured for debug.
   >> address: 0.0.0.0
   >> port: 8000
   >> workers: 2
   >> max blocking threads: 512
   >> ident: Rocket
   >> IP header: X-Real-IP
   >> limits: bytes = 8KiB, data-form = 2MiB, file = 1MiB, form = 32KiB, json = 1MiB, msgpack = 1MiB, string = 8KiB
   >> temp dir: /tmp
   >> http/2: true
   >> keep-alive: 5s
   >> tls: disabled
   >> shutdown: ctrlc = true, force = true, signals = [SIGTERM], grace = 2s, mercy = 3s
   >> log level: normal
   >> cli colors: true
   >> secret key: [generated]
Warning: secrets enabled without a stable `secret_key`
   >> disable `secrets` feature or configure a `secret_key`
   >> this becomes an error in non-debug profiles
📬 Routes:
   >> (list_users) GET /users
   >> (create_user) POST /users application/json
   >> (get_user) GET /users/<id>
   >> (update_user) PUT /users/<id> application/json
   >> (delete_user) DELETE /users/<id>
📡 Fairings:
   >> Shield (liftoff, response, singleton)
🛡️ Shield:
   >> X-Content-Type-Options: nosniff
   >> X-Frame-Options: SAMEORIGIN
   >> Permissions-Policy: interest-cohort=()
🚀 Rocket has launched from http://0.0.0.0:8000

無事起動できました🚀

実行結果の確認

ローカル環境のターミナルからcurlで各APIを実行してみます。

  • ユーザー作成
% curl -X POST 'localhost:8000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "takase-lv",
    "email": "takase@test.com"
}'
{
    "id": 1,
    "name": "takase-lv",
    "email": "takase@test.com"
}
  • ユーザー一覧
% curl -X GET 'localhost:8000/users' \
--header 'Content-Type: application/json'
[
    {
        "id": 1,
        "name":"takase-lv",
        "email":"takase@test.com"
    }
]
  • ユーザー取得
% curl -X GET 'localhost:8000/users/1' \
--header 'Content-Type: application/json'
{
    "id": 1,
     "name": "takase-lv",
     "email": "takase@test.com"
}
  • ユーザー更新
%  curl -X PUT 'localhost:8000/users/1' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "takase-lv2"
}'
{
    "id": 1,
    "name": "takase-lv2",
    "email": "takase@test.com"
}
  • ユーザー削除
% curl -X DELETE 'localhost:8000/users/1' \
--header 'Content-Type: application/json'
% curl -X GET 'localhost:8000/users' \
--header 'Content-Type: application/json'
[]

一通りのCRUD操作が出来ました👏
バリデーションやカスタムエラーなどは現状全く入ってないので次はそこらへんに挑戦したいですね。

5. 所感

Rocketでわかりやすく、Rustで堅牢なAPIの構築が可能

  • Rocketでの基本的なコードの記述感は、PythonのFastAPIに近く、直感的で非常にわかりやすいと感じました。
  • Rustでは例外処理の代わりにすべてのエラーを明示的にハンドリングする必要がありますが、この設計が型安全性と分かりやすさを両立させています。
  • 慣れることで、高速かつ堅牢なAPIを効率的に構築できるポテンシャルを持ったフレームワークだと感じました。

コンパイラが親切で、エラーメッセージがわかりやすい

  • Pythonもかなりエラー内容が分かりやすいほうでしたが、Rustは修正案まで教えてくれます。
  • ここら辺は新しい言語なんだなと感じました。
  • 例えば下記のように不変な変数を変更しようとするとわかりやすい修正例を示してくれます。
fn main() {
    let x = 5; // デフォルトで不変
    x = 10;    // 変更しようとしている
}
error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:3:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     x = 10;
  |     ^^^^^^ cannot assign twice to immutable variable
help: make this binding mutable
  |
2 |     let mut x = 5;
  |         ++++       

素敵

Cargo君が優秀

  • パッケージマネージャ兼ビルドシステムのCargo君がとても使い心地がよかったです!
  • これがあればRustのプロジェクト作成から依存関係の管理、ビルド、テスト、ドキュメント生成、公開までを一元的に管理できるのでツールチェインの選定に悩むことが少なくなりそうですね!

ドキュメント生成コマンド

% docker exec -it rust_app cargo doc
...
 Documenting adv-lv-rocket-web-api v0.1.0 (/app)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 2m 07s
   Generated /app/target/doc/adv_lv_rocket_web_api/index.html


なんかそれっぽいドキュメントができる🙌

雰囲気で書くとコンパイルが通らない

  • これは動的型付き言語ばかり使ってきたので、あ、ここの型も定義しないとダメなのね...みたいなことが多かったですね。
  • スコープ内で利用する変数の型は意識できるものの、関数の返り値の型をしっかり理解して記述しないとコンパイルが通らないことが多かったです。
  • ただ、Rust-analyzerなどのツールを活用すると型情報が明確に表示され、静的型付け言語のメリットを活かしてコードを書く効率が上がりました。

クレートのアップデートにより、書籍やネットの記事のコードが動かないことが多い

  • 書籍やネットの記事、さらには生成AIが提供するコードをそのまま実行しようとしても、依存するクレートのバージョンが更新されていたり、APIが変更されている影響で動かないことがよくありました(これはRustに限らないかもしれませんが)。
  • 実際には、記事やコード例に対応する Cargo.lock も取得しておくべきだったと、あとになって気づきました。
  • また、Rustエコシステムでは、まだ 0.x 系のクレートが多い印象で、安定性の観点からも発展途上と感じます。
  • Rust本体は6週間ごとに安定版がリリースされるサイクルで進化しているため、最新バージョンを利用する際は公式ドキュメントやサンプルコードを確認するのが最も確実だと感じました。

6. まとめ

  • Rust自体は最初は所有権やライフタイムなど難しいと感じる部分も多いですが、生成AIや書籍、記事など学習できる方法はたくさあるのでやる気さえあればある程度の習得は可能だと感じました。
  • Webフレームワークを用いることで難しい部分を省略してRustの型安全や高速な動作の恩恵を受けることもできそうです。
  • ツール周りは非常に洗練されていて開発者体験が良さそうだな思いました。

勉強してる時はcopilot切ったほうがいいですね😅

明日は yujik さんが投稿します~
レバテック開発部 Advent Calendar 2024」をぜひご購読ください!

7. 参考文献

https://rocket.rs/
https://dev.classmethod.jp/articles/rust-rocket/
https://qiita.com/h008/items/71ae7509edf2aaaeeb3b
https://zenn.dev/web3ten0/articles/ed02d8ba14277b

レバテック開発部

Discussion