Closed12

フルスタックRustなWebアプリを作るメモ(Actix-Web + Tera(→Yew)+ SeaORM)

sh11235sh11235

DB:PostgreSQL(14)
ORM:SeaORM(0.7→0.9)
Backend:Actix-Web(4)
テンプレート:Tera(1.15.0)→Yewで書き換え
Frontend:Yew

https://github.com/SeaQL/sea-orm/tree/master/examples/actix_example
SeaORMのactix-webのexampleを見て、これを流用するだけでCRUD機能を持ったWebアプリを簡単に作れそうだと思い、着手しました。
海っぽいデザイン、爽やかでいいですね。

フロントエンドはReact等を使おうかと思いましたが、exampleでテンプレートエンジンのTeraが使われていたので、これも使ってみることにしました。
TeraではHTMLテンプレートにバックエンドの変数を埋め込んでクライアントに返すことが出来ます
scriptタグ内のJavaScriptの処理にも変数を埋め込めるので、簡単なアプリはこれで十分書けそうです。

2022/05/04
以下のレポジトリで進めています。
ゴールデンウイーク中にある程度の形まで仕上げたいです。
https://github.com/SH11235/suzuya

2022/09
ORM:SeaORM(0.9)にバージョンを上げました。
0.7系の使用感をちゃんと覚えていないけれど、0.7と比べて使いやすくなった印象です。

2022/10/30
サーバーサイドでTeraでhtmlを作っていたのを、フロントエンドを分離してYewで書くことにしました。
Yewの0.19系を使っていますが、0.18から破壊的な変更もあり、同じバージョンのサンプルコードを見つけるのに苦労しながら進めています。
導入後詰まる事が多すぎて挫折しかけましたがゆるゆるとモチベーションがあるときに書き続けて、今はほとんどをYewで書き直すことが出来ています。

sh11235sh11235

SeaORM編

ドキュメントを見て手を動かしていきます。
DB・テーブルは新規で作るので、migrationとentityの両方を書きます。

既に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
    }
}

sh11235sh11235

SeaORMでつまったこと

同じテーブルから2回JOINしたいクエリの書き方が分からずでした。
この部分は生SQLを書いて対応しました。

公式ドキュメントは以下のあたり参照。
https://www.sea-ql.org/SeaORM/docs/advanced-query/custom-joins

また、公式exampleも参考になりました。
https://github.com/SeaQL/sea-orm/blob/master/examples/basic/src/select.rs#L125-L132

↓のようなことをやろうとしました。
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);
sh11235sh11235

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始まりだった。
https://tera.netlify.app/docs/#for

Vecを渡すとJavaScriptの配列ライクに使えた。
enumは列挙子がそのまま表示されるみたい。

formからPOSTするのが辛い(JSONに加工してPOSTしたい)ので、JavaScriptのfetch APIを使った。
テンプレートにscriptタグを埋め込むことが出来るので、JavaScriptを書くことが出来る。
new Date()した値をPOSTすると、serdeクレートがいい感じに変換してくれた。

sh11235sh11235

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を見るといい。
https://github.com/SH11235/suzuya/pull/17/files
migrationファイルを手動で作るのがちょっと面倒でした。
sea-orm-cliでいい感じに生成してくれると嬉しいなぁと思います(これくらいの機能はいつか対応されるだろうと思って待つのか、頑張ってSeaOrmにcommitしてみるのもありなのかな)。

sh11235sh11235

Teraが辛くなってきた(?)のとmoldの導入

アプリの改修を続けていたら、もっとJavaScriptを書きたいという気持ちが起こった(?)
一部回収が辛く感じる画面だけはバックエンドとフロントエンドを分離して、Reactをいれるかも。。。
例えば、ページングの度にhtmlを全てロードしなおすとか、ラジオボタンの選択状態とリクエストパラメータの状態を一致させるとか、につらみを感じ始めている。
部分再レンダリングをJavaScriptで頑張って書くのもしんどいし。

今更ながら、moldを入れた。コンパイル時間が短くなって、すごくいい!!!
https://github.com/rui314/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++自体は別途インストールする必要がありました。

sh11235sh11235

Yewを入れてみる

フロントエンドフレームワークとして、せっかくなのでRustで書こうと思いYewを試してみる。
https://yew.rs/ja/
Reactのようなコンポーネントベースのフレームワーク、と言われるが、去年だったかに触ったときはclass(ではない)コンポーネントっぽい書き方に慣れなかった。
今はReactで主流になったfunctional componentが出て(去年からあったかも・・・?)おり、これなら書ける気がする!
https://yew.rs/ja/docs/concepts/function-components/introduction
Yewのfunctional componentだと、以前の記法より記述量が減る。
未リリース(mainブランチにはありそう?)だけれど、SSRやSuspenseの機能もドキュメントに載っているのでリリースされたら使いたい。
https://yew.rs/ja/docs/next/advanced-topics/server-side-rendering
https://yew.rs/ja/docs/next/concepts/suspense

functional componentのupdate処理、どうすればいいんだかexampleを求めている...
Structを定義してimpl Componentして、create、update、viewを書くというのは、functional componentでもやるのだろうか???
→Reactと同じようにHooksがあるので、useState使うらしい。

設計が悪いのだと思うけれど、似たような型を都度都度書いておりとても非効率な書き方をしている感じがする。

sh11235sh11235

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だったかも)、少し煩わしいと思っていたのが改善されている。

sh11235sh11235

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のイテレーターを自前で定義してやれば出来そうな気がしてきた。
https://users.rust-lang.org/t/how-to-iterate-over-fields-of-struct/53356/5

複数のinputタグのonchangeイベントをいい感じにまとめたい

inputタグのname属性と更新するstateのpropertyを紐づけする処理を書いた。
これが上手いやり方かというと自信が無い。

  • stateのpropertyを列挙したenumを定義して、name属性の&strをmatch文で定義したenumのどれかに対応させる
  • 定義したenumでmatch文を書いてsetStateする

https://github.com/SH11235/suzuya/blob/f8732efc08dfb3536411e8d7d2923d8c96383fd0/client/src/components/item/item_detail.rs#L53-L130

数が少ないなら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ではこれが無いように思われる。
https://yew.rs/ja/docs/0.18.0/concepts/services/fetch

redditに同じ疑問を持った人のコメントがあり、
https://www.reddit.com/r/rust/comments/rfcj5t/comment/hp38hi5/?utm_source=reddit&utm_medium=web2x&context=3
reqwestかreqwasm使っているぞ、というコメントがあった。
今はreqwasmを使うことにして進めている。
ボタンをクリックしたらサーバーにデータを送信するような処理を書きたいとき、clickイベントの中でasyncな処理をするためにはwasm_bindgen_futuresというのを使えば出来るらしく、以下のような感じでclickイベントを書いている。
https://github.com/SH11235/suzuya/blob/fea600f513c25e8720333305a8e8f1c206bc228d/client/src/components/worker/worker_delete_button.rs#L21-L56

sh11235sh11235

強いPC

Yewを導入したフロントエンドのcompileが中々時間がかかるので、PCを強くしました。
今までWindows WSL2のUbuntu上で開発していましたが、新PCにはそのままUbuntuをいれました。
コア数が多いのでmoldのおかげもありcompile時間が短くなり、開発がとても快適になりました。
https://twitter.com/h11235s/status/1574556937047650304?s=20&t=0abAg92riH1kAZly-pdyxA
グラボはいつか画像処理とかで遊ぶときにきっと働くことでしょう。

今までのメインWindowsからVScodeのRemoteSSHの拡張機能を使って開発しているのですが、ポートフォワーディングも簡単に設定出来てリモート(新PC自体はすぐ近くにありますが)の開発でも快適です。

sh11235sh11235

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)

このスクラップは3ヶ月前にクローズされました