フルスタックRustなWebアプリを作るメモ(Actix-Web + Tera(→Yew)+ SeaORM)
DB:PostgreSQL(14)
ORM:SeaORM(0.7→0.9)
Backend:Actix-Web(4)
テンプレート:Tera(1.15.0)→Yewで書き換え
Frontend:Yew
海っぽいデザイン、爽やかでいいですね。
フロントエンドはReact等を使おうかと思いましたが、exampleでテンプレートエンジンのTeraが使われていたので、これも使ってみることにしました。
TeraではHTMLテンプレートにバックエンドの変数を埋め込んでクライアントに返すことが出来ます
scriptタグ内のJavaScriptの処理にも変数を埋め込めるので、簡単なアプリはこれで十分書けそうです。
2022/05/04
以下のレポジトリで進めています。
ゴールデンウイーク中にある程度の形まで仕上げたいです。
2022/09
ORM:SeaORM(0.9)にバージョンを上げました。
0.7系の使用感をちゃんと覚えていないけれど、0.7と比べて使いやすくなった印象です。
2022/10/30
サーバーサイドでTeraでhtmlを作っていたのを、フロントエンドを分離してYewで書くことにしました。
Yewの0.19系を使っていますが、0.18から破壊的な変更もあり、同じバージョンのサンプルコードを見つけるのに苦労しながら進めています。
導入後詰まる事が多すぎて挫折しかけましたがゆるゆるとモチベーションがあるときに書き続けて、今はほとんどをYewで書き直すことが出来ています。
SeaORM編
ドキュメントを見て手を動かしていきます。
DB・テーブルは新規で作るので、migrationとentityの両方を書きます。
-
migration
https://www.sea-ql.org/SeaORM/docs/migration/setting-up-migration/ -
entity
https://www.sea-ql.org/SeaORM/docs/generate-entity/sea-orm-cli/
既にDB・テーブルがある場合はcliでentityを自動的に出力してくれるようです。
これは便利ですね。
$ sea-orm-cli generate entity \
-u sql://sea:sea@localhost/bakery \
-o src/entity
CLIを使えるようにする。
cargo install sea-orm-cli
entityディレクトリでModelを書いてmigration init
して
sea-orm-cli migrate init
migrationディレクトリに出来たmigrationファイルでcreate_tableの処理を書きます。
なんやかんやでドキュメントとにらめっこしていました。
ちょっと書き方に迷いましたが、pkey指定やindex、foreignkey指定、not_null制約やデフォルト値設定など、必要なものは設定できました。
// TODO サンプルでいい感じのコードをはる
use entity::item;
use entity::maker;
use entity::user;
use sea_schema::migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220101_000001_create_item_table"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let _ = manager
.create_table(
sea_query::Table::create()
.table(item::Entity)
.if_not_exists()
.col(
ColumnDef::new(item::Column::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(item::Column::Title).string().not_null())
.col(ColumnDef::new(item::Column::Name).string().not_null())
.col(ColumnDef::new(item::Column::ProductCode).string())
.col(ColumnDef::new(item::Column::ReleaseDate).timestamp_with_time_zone())
.col(ColumnDef::new(item::Column::ArrivalDate).timestamp_with_time_zone())
.col(
ColumnDef::new(item::Column::ReservationStartDate)
.timestamp_with_time_zone(),
)
.col(
ColumnDef::new(item::Column::ReservationDeadline)
.timestamp_with_time_zone(),
)
.col(ColumnDef::new(item::Column::OrderDate).timestamp_with_time_zone())
.col(ColumnDef::new(item::Column::Sku).integer())
.col(
ColumnDef::new(item::Column::IllustStatus)
.string()
.default("未着手")
.not_null(),
)
.col(
ColumnDef::new(item::Column::DesignStatus)
.string()
.default("未着手")
.not_null(),
)
.col(
ColumnDef::new(item::Column::LastUpdated)
.timestamp_with_time_zone()
.not_null(),
)
.col(ColumnDef::new(item::Column::RetailPrice).integer())
.col(
ColumnDef::new(item::Column::CatalogStatus)
.string()
.default("未着手")
.not_null(),
)
.col(
ColumnDef::new(item::Column::AnnouncementStatus)
.string()
.default("未着手")
.not_null(),
)
.col(ColumnDef::new(item::Column::Remarks).string())
// 外部キー
.col(ColumnDef::new(item::Column::MakerId).integer())
.col(ColumnDef::new(item::Column::PicId).integer())
.col(ColumnDef::new(item::Column::DoubleCheckPersonId).integer())
.to_owned(),
)
.await;
let _foreign_key_maker_id = manager
.create_foreign_key(
sea_query::ForeignKey::create()
.from(item::Entity, item::Column::MakerId)
.to(maker::Entity, maker::Column::Id)
.to_owned(),
)
.await;
let _foreign_key_pic_id = manager
.create_foreign_key(
sea_query::ForeignKey::create()
.from(item::Entity, item::Column::PicId)
.to(maker::Entity, user::Column::Id)
.to_owned(),
)
.await;
let _foreign_key_double_check_person_id = manager
.create_index(
sea_query::Index::create()
.name("idx-item-title")
.table(item::Entity)
.col(item::Column::Title)
.to_owned(),
)
.await;
let _ = manager
.create_foreign_key(
sea_query::ForeignKey::create()
.from(item::Entity, item::Column::DoubleCheckPersonId)
.to(maker::Entity, user::Column::Id)
.to_owned(),
)
.await;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(sea_query::Table::drop().table(item::Entity).to_owned())
.await
}
}
SeaORMでつまったこと
同じテーブルから2回JOINしたいクエリの書き方が分からずでした。
この部分は生SQLを書いて対応しました。
公式ドキュメントは以下のあたり参照。
また、公式exampleも参考になりました。
↓のようなことをやろうとしました。
2回JOINするusersテーブルを区別するためににaliasをつけたかったのですが、やり方が分からず。
let paginator = Item::find()
.order_by_asc(item::Column::Id)
.inner_join(maker::Entity)
.inner_join(user::Entity)
.join_rev(
JoinType::InnerJoin,
user::Entity::belongs_to(item::Entity)
.from(user::Column::Id)
.to(item::Column::DoubleCheckPersonId)
.into(),
)
.column_as(maker::Column::CodeName, "maker_code")
.column_as(user::Column::Name, "pic")
.into_model::<SelectResult>()
.paginate(conn, items_per_page);
生SQLで以下のように対応しました。
pagination+RawSQLだとSELECT句は書かない・・・?ようです。
let paginator = SelectResult::find_by_statement(Statement::from_sql_and_values(
DbBackend::Postgres,
r#"
"items"."id",
"items"."title",
"items"."name",
"items"."product_code",
"items"."release_date",
"items"."arrival_date",
"items"."reservation_start_date",
"items"."reservation_deadline",
"items"."order_date",
"items"."sku",
"items"."illust_status",
"items"."design_status",
"items"."last_updated",
"items"."retail_price",
"items"."catalog_status",
"items"."announcement_status",
"items"."remarks",
"makers"."code_name" AS "maker_code",
"pics"."name" AS "pic",
"users"."name" AS "double_check_person"
FROM
"items"
LEFT JOIN "makers" ON "items"."maker_id" = "makers"."id"
LEFT JOIN "users" AS "pics" ON "items"."pic_id" = "pics"."id"
LEFT JOIN "users" ON "items"."double_check_person_id" = "users"."id"
ORDER BY
"items"."id" ASC
"#,
vec![],
))
.paginate(conn, items_per_page);
Tera編
actix-webのhttp responseでTera templateのhtmlを返すサンプル。
tera::Context
use actix_web::{get, web, Error, HttpRequest, HttpResponse, Result};
use sea_orm::DatabaseConnection;
use tera::Tera;
#[derive(Debug, Clone)]
struct AppState {
templates: Tera,
conn: DatabaseConnection,
}
#[get("/path")]
async fn sample(req: HttpRequest, data: web::Data<AppState>) -> Result<HttpResponse, Error> {
let conn = &data.conn;
let template = &data.templates;
// DB接続してやりたい処理
// ......
let mut ctx = tera::Context::new();
let data = hoge; // DBから取得したデータ等
let string = "foo";
ctx.insert("data", &data);
ctx.insert("string", &string);
let body = template
.render("template.html", &ctx)
.map_err(|_| error::ErrorInternalServerError("Template error"))?;
Ok(HttpResponse::Ok().content_type("text/html").body(body))
}
forでindexが欲しいときはloop.indexで取得できるらしい。
1始まりだった。
Vecを渡すとJavaScriptの配列ライクに使えた。
enumは列挙子がそのまま表示されるみたい。
formからPOSTするのが辛い(JSONに加工してPOSTしたい)ので、JavaScriptのfetch APIを使った。
テンプレートにscriptタグを埋め込むことが出来るので、JavaScriptを書くことが出来る。
new Date()した値をPOSTすると、serdeクレートがいい感じに変換してくれた。
ログイン機能を作りたい
actixのexampleに参考になりそうな実装がある。
ログインしていなかったら/loginにリダイレクトさせる、まではこれをそのまま使えばよさそう!
Tera:Template Errorが出たときの原因箇所探しが面倒
エラーメッセージが「Template Error」だけなので、どこが問題なのか分かりにくい(ログレベル変えれば情報量増えるのかな)
新しい変数をTemplateに埋め込もうとしているときのタイポや、HTMLのフォーマットの結果、{{ variable }}
の括弧が{ { variable } }
みたいに半角スペースをいれられてしまって(scriptタグ内の{{}}に反応しているので、設定を弄ればなんとかなりそうではある)正しくパース出来ないときに経験している(要するに自分が悪いのだけれど)。
let mut ctx = tera::Context::new();
let new = hoge();
ctx.insert("new", &new);
一回表示できたページであとからTemplate Errorが起こる、という事は今のところ経験していないので、これはRustで書いているおかげなのかなと思っている。Pythonとかだと(意図せず)変数のデータ構造が動的に変わって、ある時はTemplate Errorを引き起こす、みたいなコードを書いてしまう事があり得そう(?)
Sea-ORMの感動(?)した機能
既存のDBがあればそこからEntityを自動生成してくれるやつ
ORMの導入や乗り換え時には便利そう
(ORMはあまり使ったことが無いので、こういう機能は多くのORMで標準的にあるものなのかは分かっていない)
Sea-ORMで既存テーブルにカラム追加するときは?
makersテーブルがあって、データ削除を論理削除にするために後からフラグを用意したくなった。
sea-ormでクエリを書く方法が分からなかったので、Raw SQLで対応した。
参考: https://www.sea-ql.org/SeaORM/docs/migration/writing-migration
コードは↓のような感じ。
カラム追加、nullデータにデフォルト値セット、カラムにデフォルト制約追加をした。
use entity::sea_orm::{ConnectionTrait, Statement};
use sea_schema::migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20220512_233538_add_deleted_column_to_makers_table"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = "ALTER TABLE makers ADD COLUMN deleted boolean;";
let stmt = Statement::from_string(manager.get_database_backend(), sql.to_owned());
manager.get_connection().execute(stmt).await.map(|_| ())?;
let sql = "UPDATE makers SET deleted='false' where deleted IS NULL;";
let stmt = Statement::from_string(manager.get_database_backend(), sql.to_owned());
manager.get_connection().execute(stmt).await.map(|_| ())?;
let sql = "ALTER TABLE makers ALTER COLUMN deleted SET DEFAULT false;";
let stmt = Statement::from_string(manager.get_database_backend(), sql.to_owned());
manager.get_connection().execute(stmt).await.map(|_| ())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = "ALTER TABLE makers DROP COLUMN deleted;";
let stmt = Statement::from_string(manager.get_database_backend(), sql.to_owned());
manager.get_connection().execute(stmt).await.map(|_| ())
}
}
変更の全量はPull Requestを見るといい。
sea-orm-cliでいい感じに生成してくれると嬉しいなぁと思います(これくらいの機能はいつか対応されるだろうと思って待つのか、頑張ってSeaOrmにcommitしてみるのもありなのかな)。
Teraが辛くなってきた(?)のとmoldの導入
アプリの改修を続けていたら、もっとJavaScriptを書きたいという気持ちが起こった(?)
一部回収が辛く感じる画面だけはバックエンドとフロントエンドを分離して、Reactをいれるかも。。。
例えば、ページングの度にhtmlを全てロードしなおすとか、ラジオボタンの選択状態とリクエストパラメータの状態を一致させるとか、につらみを感じ始めている。
部分再レンダリングをJavaScriptで頑張って書くのもしんどいし。
今更ながら、moldを入れた。コンパイル時間が短くなって、すごくいい!!!
C++のbuild環境が無くて(?)README.md記載のコマンドではインストール出来なかったので、README.mdに記載のあるClang 12.0.0を入れてbuildした。sudo apt install clang-12 --install-suggests
git clone https://github.com/rui314/mold.git
cd mold
git checkout v1.2.1
make -j$(nproc) CXX=clang++-12 #ここを変えた
sudo make install
追記
依存関係パッケージのインストールscriptが用意されているので、それを使うのが確実そうです。
C++自体は別途インストールする必要がありました。
Yewを入れてみる
フロントエンドフレームワークとして、せっかくなのでRustで書こうと思いYewを試してみる。
今はReactで主流になったfunctional componentが出て(去年からあったかも・・・?)おり、これなら書ける気がする!
Yewのfunctional componentだと、以前の記法より記述量が減る。
未リリース(mainブランチにはありそう?)だけれど、SSRやSuspenseの機能もドキュメントに載っているのでリリースされたら使いたい。
functional componentのupdate処理、どうすればいいんだかexampleを求めている...
Structを定義してimpl Componentして、create、update、viewを書くというのは、functional componentでもやるのだろうか???
→Reactと同じようにHooksがあるので、useState使うらしい。
設計が悪いのだと思うけれど、似たような型を都度都度書いておりとても非効率な書き方をしている感じがする。
SeaORMのバージョンアップ
テーブル設計に大きなミスがあるので設計を見直そうと思ってSeaORMを再度触ろうとしたところ、開発当初は0.7系だったが0.9系がリリースされていた。
migration cli周りが変わっていそうで、0.7系ではsea-schemaというものを使っており、migration/Cargo.toml
が下記だったのが、
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
sea-schema = { version = "^0.7.0", default-features = false, features = [ "migration", "debug-print" ] }
entity = { path = "../entity" }
0.9系ではsea-orm-migration
というものを使う(改名されたか、作り直されたかは追っていない)。
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "^1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "^0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
# e.g.
"runtime-actix-native-tls", # `ASYNC_RUNTIME` feature
"sqlx-postgres", # `DATABASE_DRIVER` feature
]
cliで以下のようなコマンドを打つと、migration/src/lib.rs
への定義追加と
migration/src/m20221002_224810_create_table.rs
ファイルを自動生成してくれる。
公式ドキュメント
sea-orm-cli migrate generate create_table
以前(0.7系)は
- 手動でタイムスタンプ部分を入力したmigrationファイルを作成する
-
MigrationName
トレイトのfn name
を実装するpub struct Migration; impl MigrationName for Migration { fn name(&self) -> &str { "m20220425_120000_create_table" } }
等を必要があった覚えがあり(0.7系でも#[derive(DeriveMigrationName)]
をつけるだけででOKだったかも)、少し煩わしいと思っていたのが改善されている。
TeraテンプレートをYewで書き直し
状態を更新したら表示している要素も更新されてほしい
カードがn枚あって、ADDボタンを押すとデフォルト値が入ったカードが一枚増える、みたいな処理をやるのを素のJavaScriptでやっていたが、Reactみたいに宣言的にやりたいというモチベーションでYewの導入をしていた(実際はYew触ってみたいだけのモチベーションだったかもしれない)。
こういうイメージ。
Teraテンプレートで書いていたhtmlテンプレート達をYewで書きつつ、(比較的)リッチなUXにしてやろうとしている。
何をするにしても.clone()
しまくっているのが、正しい所作なのか判断がつかないままcloneをしてなんとか書いている。
JavaScriptオブジェクトみたいなゆるいやつはRustに無い
JavaScriptオブジェクトの特定のキーのアクセスで
const object = {
prop1: 'value1',
prop2: 'value2',
prop3: 'value3',
};
const keyName = 'prop2';
object[keyName]; // 'value2'
のようなことを出来るのだけれど、Rustの構造体のフィールドのアクセスで同様な事をやりたくなったが書き方が分からず。
structのイテレーターを自前で定義してやれば出来そうな気がしてきた。
複数のinputタグのonchangeイベントをいい感じにまとめたい
inputタグのname属性と更新するstateのpropertyを紐づけする処理を書いた。
これが上手いやり方かというと自信が無い。
- stateのpropertyを列挙したenumを定義して、name属性の&strをmatch文で定義したenumのどれかに対応させる
- 定義したenumでmatch文を書いてsetStateする
数が少ないならinputタグ1個に対して1個のonchangeイベントを書けばいいかなと思ったが、数が多いとちょっと煩わしいのでまとめて書きたいと思った。
例えばこういう画面だと編集する項目が多く、↑のGitHubのコードにあるonchangeイベントがそれぞれに付与されている。
Date型を扱うのが煩わしい
RustもYewも関係ないか?
JavaScriptのnew Date()に対応するような処理をどうこうしていて詰まった。
DOM系はweb-sys、JavaScriptで書きたい処理はjs-sysに大体はあるので、js-sysから必要なものを呼べば何とかなった。
httpclientは何を使えばいい?
サーバーサイドやscriptを書くときはreqwestを使う事が多い。
Yewの0.18はドキュメントがあるが、0.19ではこれが無いように思われる。
redditに同じ疑問を持った人のコメントがあり、
今はreqwasmを使うことにして進めている。
ボタンをクリックしたらサーバーにデータを送信するような処理を書きたいとき、clickイベントの中でasyncな処理をするためにはwasm_bindgen_futures
というのを使えば出来るらしく、以下のような感じでclickイベントを書いている。
強いPC
Yewを導入したフロントエンドのcompileが中々時間がかかるので、PCを強くしました。
今までWindows WSL2のUbuntu上で開発していましたが、新PCにはそのままUbuntuをいれました。
コア数が多いのでmoldのおかげもありcompile時間が短くなり、開発がとても快適になりました。
グラボはいつか画像処理とかで遊ぶときにきっと働くことでしょう。
今までのメインWindowsからVScodeのRemoteSSHの拡張機能を使って開発しているのですが、ポートフォワーディングも簡単に設定出来てリモート(新PC自体はすぐ近くにありますが)の開発でも快適です。
deploy
-
Yew
buildするとdist以下にhtml/css/js/wasmが生成されるので、これをそのまま各種ホスティングサービスにあげればいい。
Cloudflare Pagesをつかった。 -
actix-web
zennにHerokuにdeployする話がよく見つかるので参考にさせていただきながらdocker imageをdeployした。 -
PostgreSQL
こちらもHerokuを利用した。
Herokuの無料枠が2022/11/28で提供終了になるのが悔やまれる...。
Backend serverは月$7?
PostgreSQLは月$9で、AWSやGCP、AzureのPostgreSQLのマネージドサービスよりは安そう。
一度動くのが見れて満足したのでもし動かし続けるなら別のサービスを探すかこれくらいなら課金するかする。
2023/09/25
ちょっとアプリを動かすためにインフラを少し変えた。
-
Yew
Cloudflare Pagesを継続(無料) -
actix-web
AWS EC2の無料枠のスペック
サーバーにSSHして手打ちでリリース -
PostgreSQL
Heroku(5$/month)