avatar
screenshot
2024年1月〜

evodo-axum

axumをつかったノードベースUIのTodoリストです。サブタスクやタスクのブロックなどを実装しています。

概要

RustのaxumとノードベースUIを試すために作ったTodoリスト(?)です。 バックエンドではOpenAPIを使用しています。

Googleアカウントでログインすることができ、初回ログインではユーザー登録を行います。 クライアントサイドでのバリデーションも実装しています。 ノードベースUIらしく、それぞれのカードはドラッグ&ドロップで移動することができます。 動かせるようにした理由はないです。

ログインページ ユーザー登録ページ

一つのタスクを複数のタスクに分割するサブタスク機能を実装しています。 サブタスクをすべて完了状態にすると、自動的にメインタスクも完了状態になります。 メインタスクを完了状態にすると、サブタスクがすべて完了状態になります。 サブタスクは、複数のメインタスクを持つことはできません。

サブタスク

あるタスクが完了するまで完了状態にできないブロックタスク機能も実装しています。 こちらは、複数のタスクにブロックされているタスクを作ることができます。

ブロックタスク

サブタスク、ブロックタスクは線を繋ぎ変えることで変更することができます。 これらの機能を組み合わせることはできますが、タスク同士を循環させることはできません。 例えば、子孫タスクのサブタスクになることはできませんし、ブロックしているタスクのブロックタスクになることはできません。

使用した技術

  • Rust
  • axum
  • sqlx
  • TypeScript
  • Remix
  • React Flow

データベース

バックエンドはRustのaxumを使用しており、ORMではなくsqlxでSQLを書いています。 無効なSQLを書くとエラーが出るので便利なのですが、 Rustのコンパイル自体がそこまで早くないのに加えて、sqlxで更に遅くなってしまうので、補完が重たいのが少し気になりました。 また、動的にIN句を作るSQLはクエリビルダで書く必要があり、静的に解析されないので怖かったです。 グラフ構造のデータを扱っているので、子孫や祖先を取得することが多く、再帰クエリを使用しています。

エラーハンドリング

エラーハンドリングでは、エラーをenumで定義して、個別にハンドリングしたいものは分けて、 個別にハンドリングしないものはanyhowで一つの列挙子にまとめています。 言語として例外がないのですが、エラーをそのまま返す演算子があるのでそこまで不便には感じませんでした。 発生するエラーがインターフェースに現れるメリットのほうが大きいと思います。

内部で発生したエラーをHTTPレスポンスに変換する実装も行っています。 エラーが発生したときに、エラーを識別できる文字列をBodyに含めることで、フロントエンド側で個別にハンドリングできるようにしています。 そのために、axumのIntoResponseトレイトを実装したAppError構造体を作っています。 このAppErrorは、HTTPのStatusCodeと、HTTPレスポンスにBodyとして含めるシリアライズ可能な構造体、 バックエンド側で表示するための内部エラーを持っていて、 これをIntoResponse::into_responseResponseに変換できるようにしています。 また、利便性のためにanyhow::ErrorからAppErrorに変換できるようにFromトレイトも実装しています。

rust
struct AppError { inner: anyhow::Error, code: StatusCode, body: Option<serde_json::Value> } impl AppError { // APpErrorを作成するメソッド pub fn new(code: StatusCode, msg: Option<&str>) -> Self { // ... } // jsonからAppErrorを作成するメソッド pub fn with_json(code: StatusCode, json: Serialize) -> Self { // ... } } impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { if let Some(json) = self.body { (self.code, json.to_string()).into_response() } else { (self.code, self.code.canonical_reason().unwrap_or("Unknown")).into_response() } } } impl<E> From<E> for AppError where E: Into<anyhow::Error>, { fn from(err: E) -> Self { let err: anyhow::Error = err.into(); Self { code: StatusCode::INTERNAL_SERVER_ERROR, inner: err, body: None, } } } impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.inner) } }

テスト

バックエンドはテストも書いています。 単体テストはほとんど無く、ルートハンドラのテストを書いています。 honoのように、ルートハンドラ単体でテストする機能が無いので、axumのRouter構造体をそのまま使ってテストを行っています。 セッション管理をライブラリに任せており、外からセッションを作ることができなかったので、 テスト用に指定されたユーザーをログイン状態にするルートハンドラを作りました。 Rustでは#[cfg(test)]を使用することで、テストのときのみコンパイルするコードを書けるので、 本番環境にこのエンドポイントが漏れることを心配する必要はありませんでした。

フロントエンド

フロントエンドではノードベースUIをReact Flowを使用して実装しています。 hookが提供されていたりと柔軟にカスタマイズができるのでとても使いやすかったです。 React Flowが提供しているhookで特定の操作が非同期で実行されてしまい、チラつきが生じていた箇所があったので、 その部分だけ自前でhookを実装していました。

プロジェクトから学んだこと

このプロジェクトはRustでのWebバックエンド開発を試してみるために始めました。 Rustは初心者で、ライフタイム周りが特に複雑だと感じているのですが、 Webバックエンドを書くときにライフタイムを意識することがほとんど無く、 想像よりも難しくはありませんでした。 TypeScriptと比較してエラーハンドリングが行いやすかったり、式志向言語のためにイミュータブルに書きやすかったりと、 書いていて楽しかったです。

ただ、TypeScriptで言うところのオプショナルなプロパティと言うものが存在しないので、 少し冗長な書き方をする必要はありました。 ほしいなあと思ったのはテストを書いているときで、ダミーの値を一部だけ書き換えるときに使いたかったです。 ただ、Defaultトレイトを実装すると、..Default::default()でデフォルトの値を埋めてくれるので、 慣れればなんとも思わなくなるのかもしれません。

あとは、コンパイルが遅いのが特に気になりました。 実行するときに時間がかかるのはまだ良いのですが、コンパイルが遅いため、コードの補完に時間がかかってしまいます。 数秒待たされることがざらにあったので、TypeScriptで補完に頼りきりで書いている僕にとっては辛かったです。 ハイスペックなPCじゃないとストレスがかかる気がします・・・。

これまで、バックエンドを開発する際にはテストを書くことを心がけていました。 特に認証があるようなものは、他人のデータを操作できないかのチェックは最低限行っていたと思うのですが、あまりテストの恩恵を感じたことはありませんでした。 このプロジェクトは、「タスクが循環してはいけない」や「ブロックされているタスクは状態を変更できない」 などといった制約が多く、ロジックが複雑なので、コードの一部の変更がどこに影響を及ぼすのを把握するのが困難です。 こういった複雑なロジックのテストを書いていると、テストケースが増えれば増えるほど、安心して新しい機能の開発や変更を行えることに気づきました。

これまでテストに恩恵を感じづらかった理由としては、機能を実装してテストを書いても、結局手動でテストしていたからだと思います。 一つの機能だけれあれば、実装直後は手動でテストを行うので、ミスに気づくことができますし、 シンプルなWebアプリだと、各機能が独立しているため、実装した機能が正しく動いているかという側面しか認識できない気がします。 しかし、それぞれの機能が独立していないような複雑なWebアプリだと、以前動いていた部分が動かなくなっているということが頻繁におきます。 そうなってくると、すべての機能を手動でテストするのは時間がかかるので、自動テストがあると恩恵を感じやすいのだと考えました。