Rustの素振りでTODOサービスのREST APIを実装してみました。
書き始める前に読んでいた「実践Rustプログラミング入門」にTODOサービスの実装が紹介されていたので参考にしましたがそのままでは面白くないので書籍の内容に加えて
- クリーンアーキテクチャっぽいフォルダ構成で実装してみる
- HTTPリクエストの処理部分(controller)とデータ操作(repository)にテストを実装してみる
- ホットリロードの仕組みを入れる
- dockerで開発環境を構築する
というタスクを追加して実装してみました。
https://www.shuwasystem.co.jp/book/9784798061702.htmlwww.shuwasystem.co.jp
できたもの
ソースコード
書籍を参考にHTTPフレームワークはactix-web、データベースはsqliteを使用しています。 ただ書籍ではテンプレートエンジンでWebフロントエンドを実装していましたがそこは省略してバックエンドのAPI部分のみ実装しています。
フォルダ構成
前にQiitaに書いたクリーンアーキテクチャの構成とほぼ同じです。ただusecase層は端折っても綺麗に作れそうだったので今回は略。 qiita.com
├── src ├── domain ├── entity.rs // ドメインロジックの記述 ├── mod.rs ├── adapter ├── controller // HTTPリクエストの処理 ├── todo_controller.rs ├── mod.rs ├── gateway // データベースへのアクセス ├── todo_repository.rs ├── mod.rs ├── infrastructure // sqliteのドライバ ├── sqlite.rs ├── mod.rs ├── custom_error.rs // 独自定義エラー ├── main.rs Cargo.lock Cargo.toml docker-compose.yml Dockerfile
domain→adapter→infrastructureとクリーンアーキテクチャのレイヤーでいう内側から外側に向けて解説します。
domain
ドメインロジックを記述するレイヤーです。今回は単純なTODOアプリなのでTODOの構造体のみ定義しています。
use serde::{Deserialize, Serialize};
の部分はserdeという異なる形式のデータをシリアライズおよびデシリアライズできるクレート(ライブラリ)を使ってJSONでデータを扱えるよう宣言する書き方です。
use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] pub struct Todo { pub id: Option<i64>, pub title: String, pub contents: String, }
adapter
repository
TODO作成部分のみ抜粋。
trait(他の言語でいうinterface)を定義してテストコードでmock化できるようにしています。
親フォルダのインポートは当初use super::super::super::domain::entity::Todo
と書くこともできますがuse crate::domain::entity::Todo;
と略せるみたいです。
use super::super::super::domain::entity::Todo; use super::super::super::util::error::CustomError; use super::super::super::infrastructure::sqlite::init_db; use rusqlite::{params}; pub trait TodoRepository { fn insert_todo(&self, todo: &Todo) -> Result<(), CustomError>; } pub struct TodoRepositoryImpl; impl TodoRepository for TodoRepositoryImpl { fn insert_todo(&self, todo: &Todo) -> Result<(), CustomError> { let mut conn = init_db()?; let transaction = conn.transaction()?; transaction .execute( "INSERT INTO todos (title, contents) VALUES (?1, ?2)", params![&todo.title, &todo.contents], ) .map_err(|err| CustomError { message: format!("Failed to insert todo into the database: {}", err), })?; transaction.commit()?; Ok(()) } }
controller
上記のrepositoryを使ってHTTPリクエストを元にTODOのデータを作成します。
単純にcontrollerでrepositoryを使うことができず。Data<Arc<dyn TodoRepository + Send + Sync>>
と宣言する必要があります。
use std::sync::Arc; use actix_web::{get, post, delete, web, test, App, HttpResponse, web::{Data, Path}}; use futures::StreamExt; use crate::util::error::CustomError; use crate::domain::entity::Todo; use super::super::gateway::todo_repository::TodoRepository; #[post("/todos")] pub async fn create_todo( mut payload: web::Payload, todo_repo: Data<Arc<dyn TodoRepository + Send + Sync>>, ) -> Result<HttpResponse, CustomError> { let mut body = web::BytesMut::new(); while let Some(chunk) = payload.next().await { let chunk = match chunk { Ok(chunk) => chunk, Err(err) => return Err(err.into()), }; body.extend_from_slice(&chunk); } let todo = serde_json::from_slice::<Todo>(&body) .map_err(|err| CustomError { message: format!("{}", err), })?; match todo_repo.insert_todo(&todo) { Ok(_) => Ok(HttpResponse::Ok().json(todo)), Err(err) => Err(err), } }
Data<Arc>を掘り下げる
「 + Send + Sync」はトレイト境界と呼ばれる制約を満たすための宣言で「Send」はスレッド間の所有権の転送を許可するトレイト、Syncは複数のスレッドからのアクセスを許可するトレイトです。
Arcというスマートポインタでこの「TodoRepository + Send + Sync」部分をArcを通じてスレッド間で共有できるようにするために必要になります。
Dataはactix-webが提供する型でHttpServer::new内のマルチスレッドで共有するデータを管理する際に必要なようです。
というのでリクエストをマルチスレッドで処理するための宣言としてエラーメッセージに対応していったらこの形になりました。
参考
infrastructure
sqliteのドライバを実装しています。CREATE TABLEはmigrationとして別ファイルに切り出したいですが今回はテーブル1つしかないしRustの練習がしたかっただけなのでここに記述しています。
use std::path::Path; use anyhow::{anyhow, Context, Result as AnyhowResult}; use rusqlite::{Connection}; pub fn init_db() -> AnyhowResult<Connection> { let db_path = Path::new("todo.db"); let conn = Connection::open(db_path).with_context(|| anyhow!("Failed to initialize the database."))?; conn.execute( "CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, contents TEXT NOT NULL )", [], ) .with_context(|| anyhow!("Failed to create todos table."))?; Ok(conn) }
custom_error.rs
異なるエラー型をマスクし単一のエラー型としています。
use actix_web::HttpResponse; use std::fmt; #[derive(Debug)] pub struct CustomError { pub message: String, } impl actix_web::error::ResponseError for CustomError { fn error_response(&self) -> HttpResponse { HttpResponse::InternalServerError().body(self.message.clone()) } } impl fmt::Display for CustomError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.message) } } impl From<actix_web::error::PayloadError> for CustomError { fn from(_: actix_web::error::PayloadError) -> Self { CustomError { message: "Payload error".to_string(), } } } impl From<anyhow::Error> for CustomError { fn from(error: anyhow::Error) -> Self { CustomError { message: error.to_string(), } } } impl From<rusqlite::Error> for CustomError { fn from(err: rusqlite::Error) -> Self { CustomError { message: format!("Database error: {}", err), } } }
Docker関連
Dockerfile
ローカル開発のみに対応したDockerfileですが実際にデプロイするのであればマルチステージビルドに対応させた方が良さそうです。
開発時に変更をホットリロードするためのcargo-watchとリンタ(rustfmt)をinstallしてます。
FROM rust:latest WORKDIR /app COPY . . RUN cargo install cargo-watch RUN rustup component add rustfmt RUN apt-get update \ && apt-get upgrade -y \ && apt-get install -y sqlite3
docker-compose.yml
version: '3.8' services: actix-web-todo: build: context: . working_dir: /app ports: - 3456:8080 volumes: - .:/app - cargo-cache:/usr/local/cargo/registry - target-cache:/app/target command: /bin/sh -c "cargo watch -x run" volumes: cargo-cache: target-cache:
以上です