はじめに
こんにちは、エンジニアのクロ(@kro96_xr)です。
前回の執筆でRustのWebフレームワークについて調査してみました。
今回の記事では、その中からActix-webとORMツールのDieselを使用して、シンプルなToDoアプリのバックエンドAPIを開発してみました。
前回の記事はこちら。
環境構築
環境
今回使用したバージョンは以下になります。
- 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について詳しくはこちら
プロジェクトのセットアップ
まず、新規で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クレートを使ってコネクションプールを実装します。
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関数に渡してアプリケーションのルートデータとして登録します。
#[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になっており、詳細を調べて反映させたかったのですがその辺りは次回としたいと思います。