Rust/Actix-webでToDoアプリのAPIを実装してみる

はじめに

こんにちは、エンジニアのクロ(@kro96_xr)です。

前回の執筆でRustのWebフレームワークについて調査してみました。
今回の記事では、その中からActix-webとORMツールのDieselを使用して、シンプルなToDoアプリのバックエンドAPIを開発してみました。

前回の記事はこちら。

synamon.hatenablog.com

環境構築

環境

今回使用したバージョンは以下になります。

  • cargo 1.76.0
  • rustc 1.76.0
  • actix-web 4.5.1
  • diesel 1.4.8
    • dieselは2.1系が最新ですがバージョンを上げたら動かなかったため一旦古いバージョンのままです。
  • dotenv 0.15
    • dotenvは2020年のリリースで止まっておりunmaintained状態なので注意が必要かもしれません。
  • postgres 16.2

dotenvについて詳しくはこちら

2022年5月のRustにおけるdotenv事情

プロジェクトのセットアップ

まず、新規でRustプロジェクトを作成します。

cargo new todo_api --bin
cd todo_api

次に'Catgo.toml'ファイルに依存関係を追加します。

[dependencies]
actix-web = "4"
actix-rt = "2"
tokio = { version = "1", features = ["full"] }
diesel = { version = "1.4", features = ["postgres", "r2d2", "chrono", "table"] }
dotenv = "0.15"
serde = { version = "1.0", features = ["derive"] }
log = "0.4.14"

次にプロジェクトのルートに'docker-compose.yml'ファイルを作成して、アプリケーションサービスとPostgreSQLのサービスを定義します。
postgresの環境変数は適宜直してください。

今回は開発用ということでsrcをvolumeとすることで開発中のソースコードの変更がコンテナに即時反映されるようにしています。

version: '3'
services:
  todo_api:
    build: ./todo_api
    volumes:
      - ./todo_api/src:/usr/src/todo_api/src
      - ./todo_api/Cargo.toml:/usr/src/todo_api/Cargo.toml
      - ./todo_api/Cargo.lock:/usr/src/todo_api/Cargo.lock
    ports:
      - "8000:8000"
    depends_on:
      - db
    environment:
      - DATABASE_URL=postgres://user:password@db/todo_db
  db:
    image: postgres:latest
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: todo_db
    ports:
      - "5432:5432"
    volumes:
      - ./db_data:/var/lib/postgresql/data

volumes:
  db_data:

Dockerfileは以下の通りです。今回は開発用ということで

FROM rust:latest

WORKDIR /usr/src/todo_api
COPY Cargo.toml Cargo.lock ./

# ビルドのキャッシュレイヤーを作成するためにダミーのソースファイルを作成
RUN mkdir src && \
    echo "fn main() {println!(\"if you see this, the build broke\")}" > src/main.rs

# 依存関係だけを先にビルド
RUN cargo build --release

# 実際のソースコードをコピー
COPY . .

# 再度ビルドを実行し、変更を反映
RUN touch src/main.rs && \
    cargo build --release

# コンテナ起動時にアプリケーションを実行
CMD ["cargo", "run", "--release"]

Dieselのセットアップ

docker-composeを使ってデータベースサービスを立ち上げます。

docker-compose up -d db

次にDiesel CLIを使用してデータベースのセットアップとマイグレーションを行います。

echo DATABASE_URL=postgres://user:password@localhost:5432/todo_db > .env
diesel setup
diesel migration generate create_todos

'up.sql'と'down.sql'を編集してテーブルを作成します。 今回はシンプルにタイトルと完了フラグだけにします。

up.sql

CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR NOT NULL,
    completed BOOLEAN NOT NULL DEFAULT FALSE,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

down.sql

DROP TABLE todos;

マイグレーションを実行します。

diesel migration run

API実装

次に、実際にactix-webを使用してエンドポイントを実装していきます。

DBコネクションの実装

まず、DBコネクション周りの実装を行います。 今回はr2d2クレートを使ってコネクションプールを実装します。

diesel::r2d2 - Rust

db.rsを作成し以下のように実装します。
後ほどmain.rsからestablish_connection_pool関数を呼び出し、コネクションプールを作成します。

use diesel::prelude::*;
use diesel::r2d2::{self, ConnectionManager};

pub type DbPool = r2d2::Pool<ConnectionManager<PgConnection>>;

pub fn establish_connection_pool(database_url: &str) -> DbPool {
    let manager = ConnectionManager::<PgConnection>::new(database_url);
    r2d2::Pool::builder()
        .build(manager)
        .expect("Failed to create pool.")
}

メイン関数の実装

main.rsを実装します。
環境変数からデータベースURLを読み込み、establish_connection_pool関数を呼んでコネクションプールを作成。
作成したコネクションプールをapp_data関数に渡してアプリケーションのルートデータとして登録します。

App in actix_web - Rust

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = db::establish_connection_pool(&database_url);
    // 略
}

ルーティングを設定し、APIエンドポイントとして公開しておきます。 ハンドラーはとりあえずダミーで実装しておきます。全体は以下。

#[macro_use]
extern crate diesel;

use std::env;
use actix_web::{web, App, HttpServer, Responder, HttpResponse};

async fn get_todo_handler() -> impl Responder {
    HttpResponse::Ok().body("Get todo")
}

async fn create_todo_handler() -> impl Responder {
    HttpResponse::Ok().body("Create todo")
}

async fn update_todo_handler() -> impl Responder {
    HttpResponse::Ok().body("Update todo")
}

async fn delete_todo_handler() -> impl Responder {
    HttpResponse::Ok().body("Delete todo")
}


#[actix_web::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let pool = db::establish_connection_pool(&database_url);

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .route("/todos", web::get().to(get_todo_handler))
            .route("/todos", web::post().to(create_todo_handler))
            .route("/todos/{id}", web::put().to(update_todo))
            .route("/todos/{id}", web::delete().to(delete_todo))
    })
    .bind("0.0.0.0:8000")?
    .run()
    .await
}

モデルとスキーマの定義

models.rsにデータモデルを定義します。Todo構造体はデータベースのレコードを表しています。
NewTodoとUpdateTodoの違いはOptionになっているかどうかです。

use serde::{Deserialize, Serialize};
use crate::schema::todos;

#[derive(Debug, Serialize, Deserialize, Queryable)]
pub struct Todo {
    pub id: i32,
    pub title: String,
    pub completed: bool,
    pub created_at: chrono::NaiveDateTime,
    pub updated_at: chrono::NaiveDateTime,
}

#[derive(Insertable, Deserialize)]
#[table_name="todos"]
pub struct NewTodo {
    pub title: String,
    pub completed: bool,
}

#[derive(Deserialize, Serialize, AsChangeset)]
#[table_name = "todos"]
pub struct UpdateTodo {
    pub title: Option<String>,
    pub completed: Option<bool>,
}

schema.rsにはDieselによって自動生成されるテーブルのスキーマが含まれます。
このスキーマを使用してDieselのクエリビルダからデータベース操作が可能になります。

// @generated automatically by Diesel CLI.

diesel::table! {
    todos (id) {
        id -> Int4,
        title -> Varchar,
        completed -> Bool,
        created_at -> Timestamp,
        updated_at -> Timestamp,
    }
}

CRUD操作の実装

todo.rsにToDoテーブルに関連したデータベース操作を行う関数を実装します。

use chrono::Utc;
use crate::models::{Todo, NewTodo, UpdateTodo};
use crate::schema::todos::dsl::*;
use diesel::prelude::*;
use actix_web::{web, error, Error};
use crate::db::DbPool;

pub async fn get_todos(pool: web::Data<DbPool>) -> Result<Vec<Todo>, Error> {
    let conn = pool.get().map_err(|_| error::ErrorInternalServerError("Failed to get db connection from pool"))?;
    web::block(move || todos.load::<Todo>(&conn))
        .await
        .map_err(|_| error::ErrorInternalServerError("Error loading todos"))
        .and_then(|res| res.map_err(|_| error::ErrorInternalServerError("Error loading todos")))
}

pub async fn create_todo(
    pool: web::Data<DbPool>,
    new_todo: web::Json<NewTodo>,
) -> Result<Todo, Error> {
    let conn = pool.get().map_err(|_| error::ErrorInternalServerError("Failed to get db connection from pool"))?;

    web::block(move ||
        diesel::insert_into(todos)
            .values(&*new_todo)
            .get_result::<Todo>(&conn)
    )
    .await
    .map_err(|_| error::ErrorInternalServerError("Error creating todo"))
    .and_then(|res| res.map_err(|_| error::ErrorInternalServerError("Error loading todos")))
}

pub async fn update_todo(
    pool: web::Data<DbPool>,
    todo_id: web::Path<i32>,
    update_todo: web::Json<UpdateTodo>,
) -> Result<Todo, Error> {
    let conn = pool.get().map_err(|_| error::ErrorInternalServerError("Failed to get db connection from pool"))?;

    web::block(move ||
        diesel::update(todos.find(*todo_id))
            .set((
                update_todo.into_inner(),
                updated_at.eq(Utc::now().naive_utc()), // `updated_at`を現在のタイムスタンプに設定
            ))
            .get_result::<Todo>(&conn)
    )
    .await
    .map_err(|_| error::ErrorInternalServerError("Error update todo"))
    .and_then(|res| res.map_err(|_| error::ErrorInternalServerError("Error loading todos")))
}

pub async fn delete_todo(
    pool: web::Data<DbPool>,
    todo_id: web::Path<i32>,
) -> Result<Todo, Error> {
    let conn = pool.get().map_err(|_| error::ErrorInternalServerError("Failed to get db connection from pool"))?;

    web::block(move ||
        diesel::delete(todos.find(*todo_id))
            .get_result::<Todo>(&conn)
    )
    .await
    .map_err(|_| error::ErrorInternalServerError("Error update todo"))
    .and_then(|res| res.map_err(|_| error::ErrorInternalServerError("Error loading todos")))
}

ハンドラの更新

main.rsを修正し、各CRUD操作に対応するハンドラ関数を実装します。 これらのハンドラ関数がエンドポイントが呼び出された時に実行されます。

async fn get_todo_handler(pool: web::Data<DbPool>) -> impl Responder {
    match todo::get_todos(pool).await {
        Ok(todos) => HttpResponse::Ok().json(todos),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

async fn create_todo_handler(pool: web::Data<DbPool>, new_todo: web::Json<models::NewTodo>) -> impl Responder {
    match todo::create_todo(pool, new_todo).await {
        Ok(todo) => HttpResponse::Ok().json(todo),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

async fn update_todo_handler(pool: web::Data<DbPool>, todo_id: web::Path<i32> ,update_todo: web::Json<models::UpdateTodo>) -> impl Responder {
    match todo::update_todo(pool, todo_id, update_todo).await {
        Ok(todo) => HttpResponse::Ok().json(todo),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

async fn delete_todo_handler(pool: web::Data<DbPool>, todo_id: web::Path<i32> ) -> impl Responder {
    match todo::delete_todo(pool, todo_id).await {
        Ok(todo) => HttpResponse::Ok().json(todo),
        Err(_) => HttpResponse::InternalServerError().finish(),
    }
}

以上、実装完了です。

実装内容は以下のリポジトリにまとめてあります。

GitHub - krocks96/rust-backend-playground

おわりに

ブログ執筆や実装に割ける時間が少なくChatGPTに相談しつつ実装したのですが、その影響で一部クレートのバージョンが一部低い部分がありました。
特にDieselのメジャーバージョンが1から2になっており、詳細を調べて反映させたかったのですがその辺りは次回としたいと思います。