yikegaya’s blog

仕事関連(Webエンジニア)と資産運用について書いてます

Rust+クリーンアーキテクチャでTODOアプリのREST API書いてみた

Rustの素振りでTODOサービスのREST APIを実装してみました。

書き始める前に読んでいた「実践Rustプログラミング入門」にTODOサービスの実装が紹介されていたので参考にしましたがそのままでは面白くないので書籍の内容に加えて

  • クリーンアーキテクチャっぽいフォルダ構成で実装してみる
  • HTTPリクエストの処理部分(controller)とデータ操作(repository)にテストを実装してみる
  • ホットリロードの仕組みを入れる
  • dockerで開発環境を構築する

というタスクを追加して実装してみました。

https://www.shuwasystem.co.jp/book/9784798061702.htmlwww.shuwasystem.co.jp

できたもの

ソースコード

github.com

書籍を参考に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内のマルチスレッドで共有するデータを管理する際に必要なようです。

というのでリクエストをマルチスレッドで処理するための宣言としてエラーメッセージに対応していったらこの形になりました。

参考

doc.rust-jp.rs

qiita.com

www.ncaq.net

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

異なるエラー型をマスクし単一のエラー型としています。

doc.rust-jp.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:

以上です