はじめに
RustでTODOとストップウォッチのサイトを作りました。
なぜかフロントエンドのフレームワークを入れずにやってしまったので、構成など後で直すかもしれません。
成果物
デプロイする余力がなかったため、画面キャプチャです。
TODOアプリ
実践Rustプログラミング入門(Amazonへ飛びます)を参考にしました。
SQLiteから、Selectした結果の表示、Insert, Deleteの実行ができます。
ストップウォッチ
秒単位でカウントアップします。htmlとjsだけで実装しておりRustである必要はないです。
RustのWebサーバでindex.html以外のhtmlを動かしたかったのでこちらを参考に作成しました(リンク先ソースをそのまま使うとバグがあります)。

ソース
あとで更新する可能性があるので執筆時点のソースも貼っておきます。
執筆時点のtag
構成など簡単に
開発環境
Docker(VSCodeのRemote - Container)を利用しました。
構成
本当はwebとdbのコンテナを分けたかったのですが、
今回は同じコンテナ内でSQLiteでtodo.dbを使っています。
web
|- src
|- main.rc
|- static/js
|- stopwatch.js
|- templates
|- index.html
|- stopwatch.html
|- Cargo.toml
|- todo.db
その他、開発に必要なものを全部含めてしまっていますが、サービスとしては以上
main.rc
main.rcの目ぼしいところ抜粋して説明します。
文法含めた学習ノートは、wikiに書くようにしました。
github wikiへ飛びます
deriveマクロは、structやenumへの修飾を可能にするマクロです。
#[derive(MACRO_NAME)]アトリビュートを型の前に記述することで実装を自動的に導出します。
実装されるものは、引数に指定したDeserializeとなり、use serde::Deserialize;で取り込んでいます。
htmlから渡されるPOSTリクエストのパラメータをパース(Deserialize) するために記載されています。
実装を自動的に導出という表現は、日本語が難解ですが、この1行によりこちらのトレイトがコンパイル時に追加実装されるイメージです。
#[derive(Deserialize)]
struct AddParams {
text: String,
}
#[derive(Deserialize)]
struct DeleteParams {
id: u32,
}
データを構造体で定義しておきます。
TODOアプリなので、TODOごとのid、TODOの内容としてtextを定義しています。
struct TodoEntry {
id: u32,
text: String,
}
テンプレートの構造体は、#[derive(Template)]アトリビュートをつけて定義します。
(path = "index.html")で、テンプレートファイルへのパスを設定して対応づけています。
構造体の中のデータentriesは、テンプレート内がスコープとなりますのでテンプレート(index.html)側と一致している必要があります。
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate {
entries: Vec<TodoEntry>,
}
#[post("/add")]もマクロです。ここでは、postで/addが呼ばれたときの挙動を定義しています。
今回は、index.htmlからaddボタンが押されたとき(<form action="/add" method="post">)に呼んでいます。
http://127.0.0.1:8080/addというファイルが存在するわけではありません。
#[post("/add")]
async fn add_todo(
(略)
}
こちらは、http://127.0.0.1:8080/とhttp://127.0.0.1:8080/stopwatchでブラウザからアクセスした時の挙動。
それぞれTODOアプリとストップウォッチアプリになります。
#[get("/")]
(処理)
#[get("/stopwatch")]
(処理)
#[get("/")]では、先ほど#[derive(Template)]アトリビュートをつけた、IndexTemplateを使っているので、もう少し見ていきます。
#[derive(Template)]アトリビュートをつけると、その構造体はrenderという関数を持つようになります。
render()はテンプレート(index.html)にデータ(entries)埋め込んだHTML文字列を生成しResult<String, akama::Error>を返します。
index()関数の戻りがResult<HttpResponse, MyError>となっていますが、エラーはrender()エラー時のakama::Errorを想定しています。
#[get("/")]
async fn index(db: web::Data<Pool<SqliteConnectionManager>>) -> Result<HttpResponse, MyError> {
(略)
let html = IndexTemplate { entries };
let response_body = html.render()?;
Ok(HttpResponse::Ok()
.content_type("text/html")
.body(response_body))
}
actix_web::ResponseErrorを実装しているMyErrorを定義し複数の型のエラーハンドリングを行なっています。
これを実装した構造体は関数の戻り値に使えるようになります。
#[derive(Error, Debug)]
enum MyError {
#[error("Failed to render HTML")]
AskamaError(#[from] askama::Error),
#[error("Failed to get connection")]
ConncectionPoolError(#[from] r2d2::Error),
#[error("Failed SQL execution")]
SQLiteError(#[from] rusqlite::Error),
}
impl ResponseError for MyError {}
webサーバの定義。
.serviceに先ほどasyncで定義した非同期関数をサービスとして追加しています。追加しないと読み込まれません。
fs::Files::newはjs, css, imgなどを格納したフォルダを追加しています。
1つ目の/staticはmount_pathなのでhttp://127.0.0.1:8080/staticに当たります。
2つ目の./staticはserver_fromなのでweb/src/static配下を指定していることになります。
#[actix_web::main]
async fn main() -> Result<(), actix_web::Error> {
(略)
HttpServer::new(move || {
App::new()
.service(index)
.service(stopwatch)
.service(add_todo)
.service(delete_todo)
.service(fs::Files::new("/static", "./static").show_files_listing())
.data(pool.clone())
})
.bind("0.0.0.0:8080")?
.run()
.await?;
Ok(())
}
Cargo.toml
actix-rt = "1.1.1"は、actix-rt = "2.2.0"が最新ですが、最新はactix-web = "3.3.2"と互換性なく動かなかったため。
actix-files = "0.5.0"は、jsのような静的ファイルを扱うため。
askama = "0.10.5"は、テンプレートエンジンを扱うため。
[dependencies]
# 2.2.0 is incompatible versions of actix crates.
# actix-rt = "2.2.0"
actix-rt = "1.1.1"
actix-web = "3.3.2"
actix-files = "0.5.0"
askama = "0.10.5"
rusqlite = { version = "0.25.3", features=["bundled"] }
r2d2 = "0.8.9"
r2d2_sqlite = "0.18.0"
thiserror = "1.0.26"
serde = { version = "1.0.126", features = ["derive"] }
stopwatch.html
main.rcから渡されたデータはentriesですが、{% for entry in entries %}のような形にすることで、render()から参照されます。
なお、実際のソースでは、html/jsのみで動くサービスなのでこの部分はコメントアウトしていますが、render()は走っているのでhtml上に表示されていませんが、値は入っています。
バックエンドからの戻りを必要としないhtmlをRust側でテンプレート定義しているのでこの部分がないと参照なしとして警告が出ます。本来どのhtmlでも画面タイトルなど最低限の参照を作るか、static同様htmlの静的ファイルの取り扱いを決めるなどした方がいいと思いますが、決め切れなかったので暫定対応です。
参照は、index.html(TODOアプリ)で利用していますので、必要に応じてGitHubを参照してください。
<script src="../static/js/stopwatch.js"></script>は、静的ファイルとして追加したサービスを読み込んでいます。
<div>
{% for entry in entries %}
<div>
<div>id: {{ entry.id }}, text: {{ entry.text }}</div>
<form action="/delete" method="post">
<input type="hidden" name="id" value="{{ entry.id }}">
<button>delete</button>
</form>
</div>
{% endfor %}
</div>
<script src="../static/js/stopwatch.js"></script>
おわりに
次は犬を歩かせたいですが、VSCode+dockerの環境も作っていたこともあり、Webで動かすだけで想像以上に時間をとられてしまいました。
あとこの環境はビルドにものすごく時間がかかります。
こちらのサイトを参考にして、VSCode設定の見直しやキャッシュ対応をし、
初回以降はキャッシュを使っていますがそれでも遅いです。
targetをマウントから外すべきかもしれませんが、コンテナを何度も作り直している段階だったので残しました。
とはいえ、修正後のビルドは毎回必要ですし、
犬が歩くだけのサイトに関して言えば、フロントエンドまでRustにする必要がないですね。
Rustでフロントエンドの実装の情報が少なく何もかも時間がかかりました。
疲れてデプロイもできませんでした。次回はせめてフロントエンドのフレームワークを使おうと思います。ありがとうございました。
以下は今後読んでみたいです。
