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:

以上です

RustのclapクレートでcatみたいなCLI書いてみた

Rust試したくてclapクレート使ってcatみたいなCLIツール書いてみました。Rustではライブラリはクレートと呼ぶらしい。

Rustは「実践Rustプログラミング入門」という本を読んで勉強したんですがclapはそこで紹介されてて知りました。

www.shuwasystem.co.jp

clapについて

「実践Rustプログラミング入門」は2020年8月に出版されてるみたいなんですがその後clapの仕様が大分変わっており書籍のままだと動かんかった。

例えば以前は「App」という構造体でこのように作成するツーつを初期化して作者やバージョン情報記録していったのが今は「Command」に変わってたり。

    let app: App = App::new("rcat")
        .author("Yuki Ikegaya")
        .version("v1.0.0")
        .arg(filepath_arg)
        .arg(number_arg);

    let command: Command = Command::new("rcat")
        .author("Yuki Ikegaya")
        .version("v1.0.0")
        .arg(filepath_arg)
        .arg(number_arg);

ググった感じclapを紹介しているいろんなブログのコードも使えなくなってるパターンが多そう。

Rustはまだまだこれからの言語でOSSも発展途上なのか?アップデート対応は大変そうだけどOSSコントリビュートしたい人にはいいタイミングなのかも。

clapのdocs

公式ドキュメント読めば使い方書いてある。 docs.rs

ただソースコード読みにいっちゃった方が仕様把握するの手っ取り早いかも。 github.com

書いたコード

github.com

use clap::{Command, Arg};
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::Path;

fn main() {
    let filepath_arg: Arg = Arg::new("filepath")
        .required(true);

    let number_arg: Arg = Arg::new("number")
        .short('n')
        .long("number")
        .help("Print line numbers")
        .num_args(0);

    let command: Command = Command::new("rcat")
        .author("Yuki Ikegaya")
        .version("v1.0.0")
        .arg(filepath_arg)
        .arg(number_arg);
    
    match command.try_get_matches() {
        Ok(m) => {
            if let Some(filepath) = m.get_one::<String>("filepath") {
                let print_numbers = m.contains_id("number");
    
                match read_file_contents(filepath, print_numbers) {
                    Ok(contents) => println!("{}", contents),
                    Err(e) => eprintln!("Error reading file: {}", e),
                }
            } else {
                println!("Filepath not provided");
            }
        },
        Err(e) => {
            println!("{}", e);
        }
    }
}

fn read_file_contents(filepath: &str, print_numbers: bool) -> io::Result<String> {
    let path = Path::new(filepath);
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let mut contents = String::new();

    for (i, line) in reader.lines().enumerate() {
        let line = line?;
        if print_numbers {
            contents.push_str(&format!("{:>6}: {}\n", i + 1, line));
        } else {
            contents.push_str(&format!("{}\n", line));
        }
    }

    Ok(contents)
}

「ドメイン駆動設計入門」を読んで「値オブジェクト」、「エンティティ」、「サービス」について整理してみる

最近の個人的な関心ごととしてドメイン駆動を意識した開発業務でビジネスロジックを「値オブジェクト」、「エンティティ」、「サービス」にどう分割していくのが良いのか。というのがあり、この辺り整理するために「ドメイン駆動設計入門」を読んで整理してみました。

www.shoeisha.co.jp

ほぼ自分のためのメモです。

値オブジェクト

値の性質

  • 不変である
  • 交換が可能である
  • 等価性によって比較される

値オブジェクトにする基準

値オブジェクトにする基準として筆者は「ルールがあるか」と「それ単体で取り扱たいか」を挙げている。

例えば氏名には「姓と名で構成される」というルールがある。また本書のサンプルコードでは単体で取り扱っている。 そのためこの基準に照らし合わせると値オブジェクトとして定義される。

「姓や名」で考えるとシステム上の制限はなく姓だけを取り扱ったり名だけを取り扱ったりすることはなさそうなので値オブジェクトとして定義はしない。

ふるまい

値オブジェクトには独自のふるまいを定義できる

お金を値オブジェクトとして定義すると量と通貨単位(円やドル)の属性を持つ。そして加算というふるまいを持たせて通貨単位が異なる場合は例外を発生させることができる。

値オブジェクトを採用するモチベーション

  • 表現力を増す
  • 不正な値を代入させない
  • 謝った代入を防ぐ
  • ロジックの散在を防ぐ

エンティティ

エンティティの性質

エンティティの性質は値とは真逆で - 可変である - 同じ属性であっても区別される - 同一性によって区別される

可変である

例えばユーザオブジェクトであればユーザネームをふるまい(ChangeNameメソッド)によって変更できる。 値オブジェクトは不変なので交換(代入)によって変更を表現するがエンティティは交換によって変更を表現しない。

同じ属性であっても区別される

氏名の値オブジェクトであれば同じ氏名のものは同じオブジェクトとして扱われるが人間を表現するエンティティであれば同じ名前でも違うオブジェクトとして区別される。区別には識別子を使う。

同一性を持つ

ユーザは名前を変えても同一のユーザとして扱われる。

サービス

エンティティや値オブジェクトに記述すると不自然なものはサービスに記述する。

すべてのふるまいはドメインサービスに記述できてしまうがなるべく値オブジェクトやエンティティに記述する方が良い。 ドメインサービスの濫用はデータとふるまいを断絶させロジックの点在を促してしまう。

「Go言語 100Tips」が参考になった

「Go言語 100Tips」を読んでみました。

book.impress.co.jp

入門書ではなくある程度Go言語を触った人向けの本です。現時点で私は業務で半年ほどGoを触っていますがモヤモヤしていたことを解消してくれたり気づきを与えてくれたりとすごく参考になる内容でした。

Goは言語機能は単純と言われるけど単純=簡単というわけではなく書き手の力量が現れる言語だと感じてます。

個人的に役に立ったのは例えば

  • 構造体のフィールドやメソッドのレシーバなど値として宣言することもできるしポインタと宣言することもできる要素をどう選択するか
  • インターフェイスをどう活用するか
  • メモリを意識したスライスの活用
  • ルーンの概念を理解することは重要
  • 平行処理関連のノウハウ
  • テスト実装のノウハウ
  • 本番レベルのサービスでデフォルトのHTTPクライアントとサーバは使うべきでない

などなど

「アルゴリズム実技検定 公式テキスト エントリ ~ 中級編」を読んだ

アルゴリズムとデータ構造にもう少し強くなりたく「アルゴリズム実技検定」という資格の公式テキストを読んでみました。

book.mynavi.jp

競技プログラミングで有名なAtCoderの会社が運営してる資格試験らしいです。資格を取る気は現状ないですが教材としてはよかった。

アルゴリズム実技検定に始まってサンプルコードに使われているPythonの紹介が入りその後、サンプル問題を解きながら競技プログラミングのテクニックを学んでいく構成です。

サンプルコードは個人的には静的型付け言語の方が読みやすいと思うんでPythonだと複雑なデータ構造になった時たまに辛く感じることは正直あったりもしましたが解説ちゃんとしてくれてます。

紹介されているテクニックとしては

など

今回は「エントリ~中級編」を読みましたが「上級~エキスパート編」も購入したので読んでみます。

GoでgRPCのクライアントコードを書いてみた

前回gRPCでサーバ側のコードを書いてみたので続いてそのサーバを呼び出すクライアント側のコードを書いてみた。

ikeyu0806.hatenablog.com

実行環境

  • クライアントのコードもサーバと同じdockerコンテナで実行する
  • コンテナは1つだけどdocker runのコマンドオプション指定が面倒なのでdocker-composeを使用してます

書いたコード

cmd/client/main.go

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    memopb "grpc-memoapp/pkg/grpc"
    "log"
    "os"
)

func main() {
    if len(os.Args) != 2 {
        fmt.Println("Usage: ./main.go <memoID>")
        return
    }

    conn, err := grpc.Dial(
        "grpc-memoapp:8080", // docker-composeのサービス名とポートを指定
        grpc.WithTransportCredentials(insecure.NewCredentials()), // この指定がないとブロックされる
        grpc.WithBlock(),
    )
    if err != nil {
        log.Fatalf("Failed to connect: %v", err)
    }
    defer conn.Close()

    client := memopb.NewMemoAPIClient(conn)

    id := os.Args[1]
    getMemoRequest := &memopb.GetMemoRequest{
        Id: id,
    }
    getMemoResponse, err := client.GetMemo(context.Background(), getMemoRequest)
    if err != nil {
        log.Fatalf("Error calling GetMemo: %v", err)
    }
    fmt.Printf("GetMemo Response: %+v\n", getMemoResponse)
}

上記のコードを実行するとサーバ側で定義したリクエストメソッドを呼び出せます。

docker compose run grpc-memoapp go run cmd/client/main.go 1 GetMemo Response: memo:{title:"updateDemo" description:"updateDemo"}

CreateとかUpdateとか他のメソッドも&memopb.GetMemoRequestの箇所を書き換えれば呼べる。

meta quest3買って遊んでみた感想ざっくり

meta quest3を買って遊んでみたので感想ざっくり過剰書きします。

74800円とまあまあいい値段したけど今のところ満足です。

  • 普通にYoutube見てるだけで楽しい。
    • 360度映像見るのが楽しい。Youtubeに色んな街や観光地の映像が落ちてるので気軽に旅行気分になれる
    • 360度映像でなくても大画面のスクリーンで視聴してるような体験ができるので没入感、迫力がすごく良い
    • ブラウザからYoutube開くとMR機能で視界を保ったまま映像が見れる。これが未来感あって楽しい
  • VRアプリ
    • 評判のいいVRアプリは大体有料だけど今VRアプリとして多分一番メジャーなVRChatは無料で遊べる
  • MRアプリ
    • woooldというMR上に世界地図をジオラマのように表示できるアプリを購入してみたけど結構楽しい
    • 渋谷モディなど都内各所で体験会やっててその時の体験が良かったから買ったんだけどその時に体験できたアプリがまだリリース前だったりする。今後充実してくるといいな
  • 元々持ってたPS VRとの比較
    • VRヘッドセットはPS VRに続いて2台目の購入ですがPR VRよりもアプリが充実してるっぽい
    • PSなど別のハードウェアに接続しなくても単独で動かせるので気軽に使えて良い