0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust(axum, sqlx)とReactでWebアプリ開発 - 備忘録3 Dioxus編

Last updated at Posted at 2024-09-25

はじめに

前々回の記事
前回の記事

こちらの続き。
今回はDioxusでの実装の備忘録。

本記事は筆者の備忘録的な側面が多いため、読みにくい部分があります。
また、必ずしも正しい情報ではなかったり、実装における最適解ではない可能性があります。

※もしより正確な情報だったり詳細な内容をご存じの方はコメントで教えていただけると幸いです。

Dioxusについて

まずDioxusとは何か、から軽く紹介する。
公式サイトは以下なので、詳しい説明はそちらで。

紹介すると書いたが、他の方がすでに日本語の解説記事を書かれているのでそちらを参照されることをおすすめする。

かなり雑に要約すると、Rustでフロントエンドアプリケーションを書くためのクレートで、Reactっぽい書き方ができるのが特徴。

参考記事

こちらの記事がDioxusとAxumで実装されていており、私が作ったアプリケーションと同じ構成であったので参考になった。

Todo,Labelの表示など外部APIにリクエストする際には非同期で実装する必要があり、以下の記事が参考になった。

テンプレート作成に関してはこちらの記事も参考にした。

コードサンプル

Cargo.toml

Cargo.toml
[package]
name = "front-rust"
version = "0.1.0"
authors = ["user email"]
edition = "2021"

[dependencies]
serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0"
dioxus = { version = "0.5", features = ["fullstack", "router"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"

# Debug
dioxus-logger = "0.5.1"
reqwest = { version = "0.12.7", features = ["json"] }

[features]
default = []
server = ["dioxus/axum"]
web = ["dioxus/web"]

main.rs

モジュール化はしていないのでmain.rsのみ

main.rs
#![allow(non_snake_case)]

use dioxus::prelude::*;
use dioxus_logger::tracing;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;

#[derive(Clone, Routable, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
enum Route {
    #[route("/")]
    Home {},
}

fn main() {
    // Init logger
    dioxus_logger::init(tracing::Level::INFO).expect("failed to init logger");
    tracing::info!("starting app");
    launch(App);
}

fn App() -> Element {
    rsx! {
        Router::<Route> {}
    }
}

#[component]
fn Home() -> Element {
    let todo_data = use_server_future(get_todo_data)?.value().unwrap(); // SSR
    let label_data = use_server_future(get_label_data)?.value().unwrap(); // SSR
    tracing::info!("label_data: {:?}", type_of(&label_data));

    let mut selected_labels = use_signal(Vec::<i32>::new);

    rsx! {
        h2 { "Label Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_name = event.values().get("name").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_label_data(input_name.clone()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "name" }
            input { r#type: "submit", value: "ADD LABEL" }
        }
        // h2 { "Label Delete" }
        // p { "↓input label id" }
        // form { onsubmit: move |event| {
        //         tracing::info!("Submitted! {event:?}");
        //         let delete_id = event.values().get("id").unwrap().as_value();
        //         wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
        //             if let Err(err) = delete_data("labels".to_string(), delete_id.parse().unwrap()).await {
        //                 tracing::error!("Failed to post data: {:?}", err);
        //             }
        //         });
        //     },
        //     input { name: "id" }
        //     input { r#type: "submit", value: "DELETE LABEL" }
        // }
        h2 { "Todo Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_text = event.values().get("text").unwrap().as_value();

                // Labelの初期選択状態を反映する
                // 画面上のインデックスをVec型に変換
                let selected_ids: Vec<i32> = selected_labels.read().clone();
                // DBから取得したデータのインデックスと画面上のインデックスを対応させる
                let selected_labels_data: Vec<Label> = selected_ids.iter()
                    .filter_map(|&index| label_data.as_ref().ok()?.get(index as usize).cloned())
                    .collect();
                tracing::info!("selected_labels_data: {:?}", selected_labels_data);

                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_todo_data(input_text.clone(), selected_labels_data).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "text" }
            // Generate checkboxes for labels
            if let Ok(ref data) = label_data {
                for (i, label) in data.iter().enumerate() {
                    div {
                        input {
                            r#type: "checkbox",
                            value: "{label.id}",
                            onchange: move |event| {
                                let bool = bool::from_str(&event.value()).unwrap();
                                if bool {
                                    // trueの場合、selected_labelsにlabel.idを追加
                                    selected_labels.write().push(i as i32); // 本当は画面表示上のインデックスではなくてlabel.idを入れたい
                                } else {
                                    // falseの場合、selected_labelsからlabel.idを削除
                                    selected_labels.write().retain(|&id| id != i as i32); // 本当は画面表示上のインデックスではなくてlabel.idを入れたい
                                }
                                tracing::info!("checked: {:?}", event.value());
                                tracing::info!("selected_labels: {:?}", selected_labels);
                            }
                        }
                        label { "{label.name}" }  // Display label name
                    }
                }
            }
            input { r#type: "submit", value: "ADD TODO" }
        }
        h2 { "Todo Delete" }
        p { "↓input todo id" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let delete_id = event.values().get("id").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = delete_data("todos".to_string(), delete_id.parse().unwrap()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "id" }
            input { r#type: "submit", value: "DELETE TODO" }
        }
        div {
            div {
                h2 { "Label" }
                if let Ok(ref data) = label_data {
                    for label in data.iter() {
                        div { "===============================" }
                        div {
                            p { "Label ID: {label.id}" }
                            p { "Label Name: {label.name}" }
                        }
                    }
                    div { "===============================" }
                } else {
                    div { "Labels Data get error" }
                }
            }
            div {
                h2 { "Todo" }
                if let Ok(ref data) = todo_data {
                    for todo in data.iter() {
                        div { "===============================" }
                        div {
                            p { "Todo ID: {todo.id}" }
                            p { "Todo Text: {todo.text}" }
                            p { "Todo Completed: {todo.completed}" }
                            for todo_label in todo.labels.iter() {
                                p { "Todo Labels ID: {todo_label.id}" }
                                p { "Todo Labels Name: {todo_label.name}" }
                            }
                        }
                    }
                    div { "===============================" }
                } else {
                    div { "Todo Data get error" }
                }
            }
        }
    }
}

// --------------
// struct
// --------------
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Label {
    pub id: i32,
    pub name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TodoEntity {
    pub id: i32,
    pub text: String,
    pub completed: bool,
    pub labels: Vec<Label>,
}

// --------------
// todo function
// --------------
#[server(GetTodoData)]
async fn get_todo_data() -> Result<Vec<TodoEntity>, ServerFnError> {
    let todo = reqwest::get("リクエストURL/todos")
        .await
        .unwrap()
        .json::<Vec<TodoEntity>>()
        .await?;
    tracing::info!("todo: {:?}", todo);

    Ok(todo)
}

async fn post_todo_data(text: String, labels: Vec<Label>) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}, labels: {:?}", text, labels);

    let body = json!({
        "text": text,
        "labels": labels.iter().map(|label| {
            json!(label.id)
        }).collect::<Vec<_>>(), // 各ラベルをJSON形式に変換してPOST
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL/todos")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

async fn delete_data(kind: String, id: i32) -> Result<(), ServerFnError> {
    tracing::info!("delete_todo_data: {:?}", id);

    let client = reqwest::Client::new();
    let res = client
        .delete(format!("リクエストURL/{}/{}", kind, id))
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

// --------------
// label function
// --------------
#[server(GetLabelData)]
async fn get_label_data() -> Result<Vec<Label>, ServerFnError> {
    let label = reqwest::get("リクエストURL/labels")
        .await
        .unwrap()
        .json::<Vec<Label>>()
        .await?;
    tracing::info!("label: {:?}", label);

    Ok(label)
}

async fn post_label_data(name: String) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}", name);

    let body = json!({
        "name": name,
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL/labels")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

実装メモ

前回の記事でReactで実装した画面をもとに、Dioxusで再構築していく。

が、最終的にこの記事執筆時点ではおよそ4割ぐらいの機能を実装したところでまだ途中。
Edit機能やLabelの削除機能、Todo追加時の画面更新処理、デザイン面などは今後実装して更新していくかもしれない。

一旦現時点までのアプリケーションのデモ画面を掲載する。

image.png

...見ての通り、cssは一切入れていないし、未デプロイ。
そもそもDioxusで作ったアプリケーションのデプロイ方法がわからない。WebAssemblyのデプロイ記事を読んでみたがまだ実践には至っていない。

しかし、一応Todo、Labelの表示、登録、Todoの削除はできるところまでは頑張って実装した。

get処理

// 一部抜粋

#[component]
fn Home() -> Element {
    let todo_data = use_server_future(get_todo_data)?.value().unwrap(); // SSR

    rsx! {
        h2 { "Todo" }
        if let Ok(ref data) = todo_data {
            for todo in data.iter() {
                div {
                    p { "Todo ID: {todo.id}" }
                    p { "Todo Text: {todo.text}" }
                    p { "Todo Completed: {todo.completed}" }
                    for todo_label in todo.labels.iter() {
                        p { "Todo Labels ID: {todo_label.id}" }
                        p { "Todo Labels Name: {todo_label.name}" }
                    }
                }
            }
        } else {
            div { "Todo Data get error" }
        }
    }
}

// Label、TodoEntityはバックエンド側の実装をそのまま持ってきた
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Label {
    pub id: i32,
    pub name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TodoEntity {
    pub id: i32,
    pub text: String,
    pub completed: bool,
    pub labels: Vec<Label>,
}

// Todo get関数
#[server(GetTodoData)]
async fn get_todo_data() -> Result<Vec<TodoEntity>, ServerFnError> {
    let todo = reqwest::get("リクエストURL")
        .await
        .unwrap()
        .json::<Vec<TodoEntity>>()
        .await?;
    tracing::info!("todo: {:?}", todo);

    Ok(todo)
}

Vec型でTodoEntityの配列を受け取り、for分の中でイテレータのループで一つずつ取り出している。

post処理

postの関数は以下のような実装となった。

// Todo post関数
async fn post_todo_data(text: String, labels: Vec<Label>) -> Result<(), ServerFnError> {
    tracing::info!("post: {:?}, labels: {:?}", text, labels);

    let body = json!({
        "text": text,
        "labels": labels.iter().map(|label| {
            json!(label.id)
        }).collect::<Vec<_>>(), // 各ラベルをJSON形式に変換してPOST
    });

    let client = reqwest::Client::new();
    let res = client
        .post("リクエストURL")
        .json(&body)
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

getとちょっと違い、引数に入力した文字列と選択したLabelのVec型を渡す実装になる。
以下の記事が参考になった。

post処理のコンポーネント

次はUIを実装するのだが、登録済みのLabelを紐づけるコンポーネント部分で苦労した。
React側の画面では、SELECT LABELSを押すとモーダルが開き、登録済みのLabel一覧が表示され、チェックを入れるとTodo投稿時に最初からLabelが紐づく仕組み。

React画面
image.png

image.png

これをDioxus側で再現したいのだが、いったんモーダル実装は置いておき、単純に最初からチェックボックスを置く実装とした。

見た目はこんな感じ。

Dioxus画面
image.png

ここの
チェックボックスを選択したLabelの内部IDをTodo投稿時のリクエスト先に渡す
という処理に苦労した。

最終的にこんな感じのコンポーネントになった。

#[component]
fn Home() -> Element {
    let label_data = use_server_future(get_label_data)?.value().unwrap(); // SSR

    // 選択したLabelの画面表示上のインデックスを格納する変数(ReactのuseStateのようなもの)
    let mut selected_labels = use_signal(Vec::<i32>::new);

    rsx! {
        h2 { "Todo Set" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let input_text = event.values().get("text").unwrap().as_value();

                // Labelの初期選択状態を反映する
                // 画面上のインデックスをVec型に変換
                let selected_ids: Vec<i32> = selected_labels.read().clone();
                // DBから取得したデータのインデックスと画面上のインデックスを対応させる
                let selected_labels_data: Vec<Label> = selected_ids.iter()
                    .filter_map(|&index| label_data.as_ref().ok()?.get(index as usize).cloned())
                    .collect();
                tracing::info!("selected_labels_data: {:?}", selected_labels_data);

                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = post_todo_data(input_text.clone(), selected_labels_data).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "text" }
            // Generate checkboxes for labels
            if let Ok(ref data) = label_data {
                for (i, label) in data.iter().enumerate() {
                    div {
                        input {
                            r#type: "checkbox",
                            value: "{label.id}",
                            // 画面表示上のLabelのインデックスを格納
                            onchange: move |event| {
                                let bool = bool::from_str(&event.value()).unwrap();
                                if bool {
                                    // trueの場合、selected_labelsにlabel.idを追加
                                    selected_labels.write().push(i as i32); // 本当はlabel.idを入れたい
                                } else {
                                    // falseの場合、selected_labelsからlabel.idを削除
                                    selected_labels.write().retain(|&id| id != i as i32); // 本当はlabel.idを入れたい
                                }
                                tracing::info!("checked: {:?}", event.value());
                                tracing::info!("selected_labels: {:?}", selected_labels);
                            }
                        }
                        label { "{label.name}" }  // Display label name
                    }
                }
            }
            input { r#type: "submit", value: "ADD TODO" }
        }
    }
}

チェックボックスを作る部分で、上記のように実装しているとどうしてもevent.value()truefalseのどちらかの値しか取れない。
理想はlabel.idselected_labelsに入れたいのだが、どうやら所有権周りでとれなさそう。
おそらくクロージャを使っている関係だと思うが、勉強不足で解決できなかった。

代替案として、チェックボックスを押下した際は画面表示上のインデックスselected_labelsに格納し、postする前処理で画面表示上のインデックスと実際のLabal IDと紐づけてpost関数に渡すようにした。

delete処理

これもReact側のように、Todoの横にDELETEボタンを置き、対応するTodoを削除するようにしたかったのだが、postと同じくevent.value()truefalseのどちらかの値しか取れないことに苦戦した結果、
削除したいTodo IDを直接打ち込む実装に変更した。

React画面

image.png

Dioxus画面

image.png

#[component]
fn Home() -> Element {
    rsx! {
        h2 { "Todo Delete" }
        p { "↓input todo id" }
        form { onsubmit: move |event| {
                tracing::info!("Submitted! {event:?}");
                let delete_id = event.values().get("id").unwrap().as_value();
                wasm_bindgen_futures::spawn_local(async move { // Use `spawn_local` for async tasks in WASM
                    if let Err(err) = delete_data("todos".to_string(), delete_id.parse().unwrap()).await {
                        tracing::error!("Failed to post data: {:?}", err);
                    }
                });
            },
            input { name: "id" }
            input { r#type: "submit", value: "DELETE TODO" }
        }
    }
}

// delete関数
async fn delete_data(kind: String, id: i32) -> Result<(), ServerFnError> {
    tracing::info!("delete_todo_data: {:?}", id);

    let client = reqwest::Client::new();
    let res = client
        .delete(format!("リクエストURL/{}/{}", kind, id))
        .send()
        .await;

    match res {
        Ok(response) => tracing::info!("POST successful: {:?}", response),
        Err(err) => tracing::error!("POST failed: {:?}", err),
    }

    Ok(())
}

ここでひとつ、kindという引数にStringを渡していることにお気づきだろうか。
そう、TodoとLabelの削除処理はリクエスト先が違うだけなので、共通化しようとした形跡である。

しかし、Labelのほうはlabel_dataを更新しないと削除したラベルがTodo投稿時に選択できるままになってしまう。ここは未実装のため、今後挑戦するかもしれない。

Labelのget,post処理

Todoとほとんど同じなので省略。

まとめ

ということで、かなり中途半端だがRustでDioxusを用いてフロントエンドを実装してみた。
まだまだ勉強不足、研究不足なところはあるが、バックエンド、フロントエンドの両方をすべてRustで書くことは可能であることが分かった。

しかし、他の方も書かれているようにRust × Web フロントエンドはまだまだ未成熟な分野であり、現状だとReactやNext.jsなどの代替になるには少しハードルが高そうなため、今後の発展に期待したい。

派生記事リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?