Romira's develop blog

Rust+Dioxus+AxumでSSRなWebアプリケーションを構築する

Cover image of Rust+Dioxus+AxumでSSRなWebアプリケーションを構築する

本サイトの1本目のブログを投稿してはや数ヶ月.

本体からはようやく2本目です.
今回は,1本目の記事でさらっと触れただけで終わったRustのフロントエンドフレームワークの一つであるDioxusを用いてOGPの設定などもできるようにSSRなWebアプリケーションの構築を行ってみたのでその紹介をします.
ただし結構無理やりです.

本サイトはyewで開発しているのですが,ビルド周りがつらく,将来的にはDioxusに移行したいという気持ちで検証を兼ねています.
しかしながら結論を申し上げるとまだ厳しいところがあるなという状況です.

Dioxus

Dioxus は,デスクトップ,Web,モバイルなどで実行されるアプリを構築するための Rust ライブラリです(ホームページ訳).

つまるところWebに限らず,WindowsやmacOS,Linux上で動くGUIアプリケーションやAndroid,iOS上で動くモバイルアプリに向けてビルド・実行できるクロスプラットフォームなライブラリです.
ただし今回はWebを対象として話を進めていきたいと思います.

Dioxusはyewと同様にReactにインスパイアされてるので構文は非常に似ています.
(HPから抜粋)

//! Simple, familiar, React-like syntax
use dioxus::prelude::*;

fn app() -> Element {
    let mut count = use_signal(|| 0);

    rsx! {
        h1 { "High-Five counter: {count}" }
        button { onclick: move |_| count += 1, "Up high!" }
        button { onclick: move |_| count -= 1, "Down low!" }
    }
}

Dioxus-Fullstack

ブログ執筆時点でのバージョン(0.5.1)ではWeb向けのレンダリング方法として3つの選択肢があります.

  • dioxus-web: ようするにCSR
  • dioxus-liveview: アプリケーションをサーバー上で実行し、WebSocket を使用してクライアント上で HTML にレンダリングする(SSRアプリケーションのように書けるかつCSRのように振る舞える・・?これの用途についてはよくわかっていません).
  • dioxus-ssr: ようするにSSR
  • dioxus-fullstack: SSRの機能を提供しつつ、クライアントに対してHydrationを提供するフルスタックなフレームワーク

https://dioxuslabs.com/learn/0.4/getting_started/choosing_a_web_renderer

今回はSSRでレンダリングを行いたいのでdioxus-fullstackを採用します.

Getting Started

dioxus-fullstackのドキュメントページに従って進めていきたいと思います.

https://dioxuslabs.com/learn/0.4/getting_started/fullstack

RustやDioxusビルド環境はできている前提で進めていきますがまだであれば以下のドキュメントをご参考ください.

Rust
https://www.rust-lang.org/ja/learn/get-started
Dioxus - toolingのインストールのみ
https://dioxuslabs.com/learn/0.4/getting_started/wasm#tooling

まず,新しいcargoプロジェクトを作成します.
dxコマンドを使うことで雛形から簡単に始めることできるようになりました.

> dx new
? 🤷   Which sub-template should be expanded? ›
  Web
  Liveview
❯ Fullstack
  Desktop
  TUI
> Enter

🤷   Project Name: demo
> Enter

? 🤷   Should the application use the Dioxus router? ›
  false
❯ true
> Enter

? 🤷   How do you want to create CSS? ›
  Tailwind
❯ Vanilla
> Enter

生成されたプロジェクトに移動して Cargo.toml を見てみます.

[package]
name = "demo"
version = "0.1.0"
authors = ["your name <your email address>"]
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
serde = { version = "1.0.197", features = ["derive"] }

dioxus = { version = "0.5", features = ["fullstack", "router"] }

# Debug
tracing = "0.1.40"
dioxus-logger = "0.5.0"

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

featuresで2つfeatureが定義されています.
dioxus-fullstackはSSRサーバー用とHydration用のビルドを別途行う必要があり,それによって必要なfeaturesが違うからです.

さっそくビルドしてみましょう

dx serve --platform fullstack

dxコマンドを使うことで必要なビルド作業やデバッグ用のHot Reloadの設定などを勝手にやってくれます.

デフォルトでは http://localhost:8080/ にlistenされるのでアクセスしてみましょう.
以下のような画面であればOKです.

ef9bb3ce-e1b4-43b7-912a-799844485a0c.png

Up high!, Down low!を押すとcounterが上下したり,Get Server Dataを押すとサーバーからレスポンスが来て...の表示が変わることがわかると思います.

Go to blogを押すと/blog/{counter}に移動します.

curl してみるとSSRされていることがわかります.

> curl http://localhost:8080/blog/5
...
</head>
<body>
  <div id="main">
  <a href="/" dioxus-prevent-default="onclick" data-node-hydration="0,click:1">
  <!--node-id1-->Go to counter<!--#-->
  </a><!--node-id2-->Blog post 5<!--#-->
  <meta hidden="true" id="dioxus-storage-data" data-serialized="oWRkYXRhgA==" />
...

いい感じにSSRできつつHydrationもできていることがわかりました.

Server FunctionでサーバーサイドでデータをFetchする

ブログサイトなどではしばしばサーバーサイドで非同期でどこからか(DBやCMS APIなどから)データを取ってきてSSRさせたいケースがあると思います.

Dioxusではuse_server_futureというサーバーサイドのみで非同期関数を実行するHooksがあります.
これを使って外部APIから情報を取ってきてSSRを実行してみます.

Httpリクエストを投げるので以下のCrateを追加します.

cargo add reqwest

main.rsに外部APIからJsonを取得する非同期関数を実装します.

#[server(GetBlogData)]
async fn get_blog_data() -> Result<String, ServerFnError> {
    Ok(reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
        .await
        .unwrap()
        .text()
        .await.unwrap())
}

今回は簡易的にtext()でStringに変換していますが,実際にはレスポンスに合わせたStructなどを定義することになると思います.

次にHomeコンポーネントを以下のように書き換えます

#[component]
fn Home() -> Element {
    let mut count = use_signal(|| 0);
    let mut text = use_signal(|| String::from("..."));
  + let blog_data = use_server_future(get_blog_data)?.value();

    rsx! {
        Link {
            to: Route::Blog {
                id: count()
            },
            "Go to blog"
        }
        div {
            h1 { "High-Five counter: {count}" }
            button { onclick: move |_| count += 1, "Up high!" }
            button { onclick: move |_| count -= 1, "Down low!" }
            button {
                onclick: move |_| async move {
                    if let Ok(data) = get_server_data().await {
                        tracing::info!("Client received: {}", data);
                        text.set(data.clone());
                        post_server_data(data).await.unwrap();
                    }
                },
                "Get Server Data"
            }
            p { "Server data: {text}"}
          + p { "Blog data: {blog_data:?}"}
        }
    }
}

Jsonデータが取れていそうです.

411c4007-c8d3-405c-9861-81ec44ed7089.png

curlで確認してもレンダリングされています.

> curl http://localhost:8080/

...
<body>
  <div id="main"><a href="/blog/0" dioxus-prevent-default="onclick" data-node-hydration="0,click:1"><!--node-id1-->Go to blog<!--#-->
  </a><div data-node-hydration="2"><h1><!--node-id3-->High-Five counter: 0<!--#--></h1><button data-node-hydration="4,click:1">Up high!</button><button data-node-hydration="5,click:1">Down low!</button><button data-node-hydration="6,click:1">Get Server Data</button><p><!--node-id7-->Server data: ...<!--#--></p>
  <p><!--node-id8-->Blog data: Some(Ok(&quot;{\n  \&quot;userId\&quot;: 1,\n  \&quot;id\&quot;: 1,\n  \&quot;title\&quot;: \&quot;sunt aut facere repellat provident occaecati excepturi optio reprehenderit\&quot;,\n  \&quot;body\&quot;: \&quot;quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto\&quot;\n}&quot;))<!--#-->
  </p></div><meta hidden="true" id="dioxus-storage-data" data-serialized="oWRkYXRhgZkBkBhvGFcYShhQGGEYMxhrGEIYShhIGHMYSxhJGEMYQRhpGGQYWBhOGGwYYxhrGGwYaxhJGGoYbxhnGE0YUxh3GEsYSRhDGEEYaRhhGFcYURhpGE8YaRhBGHgYTBhBGG8YZxhJGEMYShgwGGEYWBhSGHMYWhhTGEkYNhhJGEMYShh6GGQYVxg1GDAYSRhHGEYYMRhkGEMYQhhtGFkYVxhOGGwYYxhtGFUYZxhjGG0YVhh3GFoYVxh4GHMYWRhYGFEYZxhjGEgYShh2GGQYbRhsGGsYWhhXGDUYMBhJGEcYORhqGFkYMhhGGGwYWRgyGEYYMBhhGFMYQhhsGGUYRxhOGGwYYxhIGFIYMRhjGG0YaxhnGGIYMxhCGDAYYRhXGDgYZxhjGG0YVhh3GGMYbRhWGG8YWhhXGDUYaxhaGFgYShhwGGQYQxhJGHMYQxhpGEEYZxhJGG0YShh2GFoYSBhrGGkYTxhpGEEYaRhjGFgYVhhwGFkYUxhCGGwYZBhDGEIYehhkGFgYThhqGGEYWBhCGHAYZBhGGHgYdRhjGDMYVhh6GFkYMhhsGHcYYRhYGFEYZxhjGG0YVhhqGGQYWBhOGGgYYhhtGFIYaBhaGFMYQhhqGGIYMhg1GHoYWhhYGEYYMRhkGFcYNRgwGGQYWBhJGGcYWhhYGGgYdxhaGFcYUhhwGGQYRxhFGGcYWhhYGFEYZxhZGDMYVhh0GFgYRxg1GHkYWhhYGEIYeRhaGFcYaBhsGGIYbRhSGGwYYxhtGGwYMBhJGEcYMRh2GGIYRxhWGHoYZBhHGGwYaBhaGFMYQhgxGGQYQxhCGDEYZBhDGEIYeBhkGFcYRhh6GEkYSBhSGHYYZBhHGEYYdBhYGEcYNRh1GGIYMxhOGDAYYxhuGFYYdBhJGEgYShhsGGMYbhhWGHQYSRhHGFYYehhkGEMYQhhoGGQYWBhSGGwYYhhTGEIYehhkGFcYNRgwGEkYSBhKGGwYYhhTGEIYbBhkGG0YVhh1GGEYVxhWGDAYSRhHGEYYeRhZGDIYaBhwGGQYRxhWGGoYZBhHGDgYaRhDGG4YMBg9" />
...

逆にクライアントでデータをfetchしたいパターンもあると思います.
その場合はuse_futureuse_resourceを使います.
use_futureは戻り値を返すことができないのに対してuse_resourceは戻り値を返すことができます.

今回はget_blog_data()がStringを返すのでuse_resourceを使います.

Homeコンポーネントを以下のように書き換えます.

#[component]
fn Home() -> Element {
    let mut count = use_signal(|| 0);
    let mut text = use_signal(|| String::from("..."));
    let blog_data = use_server_future(get_blog_data)?.value();
  + let blog_data_resource = use_resource(get_blog_data).value();

    rsx! {
        Link {
            to: Route::Blog {
                id: count()
            },
            "Go to blog"
        }
        div {
            h1 { "High-Five counter: {count}" }
            button { onclick: move |_| count += 1, "Up high!" }
            button { onclick: move |_| count -= 1, "Down low!" }
            button {
                onclick: move |_| async move {
                    if let Ok(data) = get_server_data().await {
                        tracing::info!("Client received: {}", data);
                        text.set(data.clone());
                        post_server_data(data).await.unwrap();
                    }
                },
                "Get Server Data"
            }
            p { "Server data: {text}"}
            p { "Blog data: {blog_data:?}"}
          + p { "Blog data client: {blog_data_resource:?}"}
        }
    }
}

ブラウザで確認してみます.

1db892bc-1bb2-4580-85b2-9c83fc8e13d2.png

見た目は変わらないですね

しかしcurlで確認してみると
Blog data client: Noneとレンダリングされています.
ブラウザでもfetchが完了するまでの一瞬だけNoneが表示されます.

> curl http://localhost:8080/

<body>
  <div id="main"><a href="/blog/0" dioxus-prevent-default="onclick" data-node-hydration="0,click:1"><!--node-id1-->Go to blog<!--#--></a><div data-node-hydration="2"><h1><!--node-id3-->High-Five counter: 0<!--#--></h1><button data-node-hydration="4,click:1">Up high!</button><button data-node-hydration="5,click:1">Down low!</button><button data-node-hydration="6,click:1">Get Server Data</button><p><!--node-id7-->Server data: ...<!--#--></p>
  <p><!--node-id8-->Blog data: Some(Ok(&quot;{\n  \&quot;userId\&quot;: 1,\n  \&quot;id\&quot;: 1,\n  \&quot;title\&quot;: \&quot;sunt aut facere repellat provident occaecati excepturi optio reprehenderit\&quot;,\n  \&quot;body\&quot;: \&quot;quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto\&quot;\n}&quot;))<!--#--></p>
  <p><!--node-id9-->Blog data client: None<!--#--></p></div><meta hidden="true" id="dioxus-storage-data" data-serialized="oWRkYXRhgZkBkBhvGFcYShhQGGEYMxhrGEIYShhIGHMYSxhJGEMYQRhpGGQYWBhOGGwYYxhrGGwYaxhJGGoYbxhnGE0YUxh3GEsYSRhDGEEYaRhhGFcYURhpGE8YaRhBGHgYTBhBGG8YZxhJGEMYShgwGGEYWBhSGHMYWhhTGEkYNhhJGEMYShh6GGQYVxg1GDAYSRhHGEYYMRhkGEMYQhhtGFkYVxhOGGwYYxhtGFUYZxhjGG0YVhh3GFoYVxh4GHMYWRhYGFEYZxhjGEgYShh2GGQYbRhsGGsYWhhXGDUYMBhJGEcYORhqGFkYMhhGGGwYWRgyGEYYMBhhGFMYQhhsGGUYRxhOGGwYYxhIGFIYMRhjGG0YaxhnGGIYMxhCGDAYYRhXGDgYZxhjGG0YVhh3GGMYbRhWGG8YWhhXGDUYaxhaGFgYShhwGGQYQxhJGHMYQxhpGEEYZxhJGG0YShh2GFoYSBhrGGkYTxhpGEEYaRhjGFgYVhhwGFkYUxhCGGwYZBhDGEIYehhkGFgYThhqGGEYWBhCGHAYZBhGGHgYdRhjGDMYVhh6GFkYMhhsGHcYYRhYGFEYZxhjGG0YVhhqGGQYWBhOGGgYYhhtGFIYaBhaGFMYQhhqGGIYMhg1GHoYWhhYGEYYMRhkGFcYNRgwGGQYWBhJGGcYWhhYGGgYdxhaGFcYUhhwGGQYRxhFGGcYWhhYGFEYZxhZGDMYVhh0GFgYRxg1GHkYWhhYGEIYeRhaGFcYaBhsGGIYbRhSGGwYYxhtGGwYMBhJGEcYMRh2GGIYRxhWGHoYZBhHGGwYaBhaGFMYQhgxGGQYQxhCGDEYZBhDGEIYeBhkGFcYRhh6GEkYSBhSGHYYZBhHGEYYdBhYGEcYNRh1GGIYMxhOGDAYYxhuGFYYdBhJGEgYShhsGGMYbhhWGHQYSRhHGFYYehhkGEMYQhhoGGQYWBhSGGwYYhhTGEIYehhkGFcYNRgwGEkYSBhKGGwYYhhTGEIYbBhkGG0YVhh1GGEYVxhWGDAYSRhHGEYYeRhZGDIYaBhwGGQYRxhWGGoYZBhHGDgYaRhDGG4YMBg9" />

ブラウザのデバッグツールで確認してみると
/api/get_blog_data7568530912534075807にPOSTしているようですね.

607a1e4f-8c3d-4b31-8e11-e515275f0aed.png

Prefixに/apiがつき,関数名+ランダムな数字のエンドポイントが自動的に生成されるようです.

ちなみになぜかAPIが2回叩かれているんですが,なんでなんですかね・・・

1fa4f37f-7543-4286-a0a8-2244aad0c681.png

ここまでで,一通りのことは出来そうです.

さて,今回はブログサイトを構築することを念頭においているのでOGPタグを設定したくなりますよね.
というわけで設定してみたいと思います.

OGPタグを(無理やり)設定する

Cargo.tomlaxum, tokio, hyper, hyper-utilを追加してserver featuresが有効の場合のみビルドされるようにします.

[package]
name = "demo"
version = "0.1.0"
authors = ["Romira915 <[email protected]>"]
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
serde = { version = "1.0.197", features = ["derive"] }

dioxus = { version = "0.5", features = ["fullstack", "router"] }

# Debug
tracing = "0.1.40"
dioxus-logger = "0.5.0"
reqwest = "0.12.4"

+ axum = { version = "0.7.5", optional = true }
+ tokio = { version = "1.38.0", optional = true }
+ hyper = { version = "1.0.0", features = ["full"], optional = true }
+ hyper-util = { version = "0.1.1", features = ["client-legacy"] , optional = true }

[features]
default = []
± server = ["dioxus/axum", "axum", "tokio", "hyper", "hyper-util"]
web = ["dioxus/web"]

main.rsに以下を追加します.

fn main() {
    #[cfg(feature = "server")]
    {
        let handle = std::thread::spawn(|| {
            tokio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    launch_reverse_proxy().await;
                });
        });
    }

    // Init logger
    dioxus_logger::init(Level::INFO).expect("failed to init logger");
    launch(App);
}

#[cfg(feature = "server")]
async fn launch_reverse_proxy() {
    use axum::body::Body;
    use axum::extract::{Path, Request, State};
    use axum::http::{StatusCode, Uri};
    use axum::response::{Html, IntoResponse, Response};
    use hyper_util::client::legacy::connect::HttpConnector;
    use axum::Router;
    use axum::routing::get;
    use hyper_util::rt::TokioExecutor;

    type Client = hyper_util::client::legacy::Client<HttpConnector, Body>;

    async fn handler(State(client): State<Client>, mut req: Request) -> Result<Response, StatusCode> {
        let path = req.uri().path();
        let path_query = req
            .uri()
            .path_and_query()
            .map(|v| v.as_str())
            .unwrap_or(path);

        let uri = format!("http://127.0.0.1:8080{}", path_query);

        *req.uri_mut() = Uri::try_from(uri).unwrap();

        let response = client
            .request(req)
            .await
            .map_err(|_| StatusCode::BAD_REQUEST)?
            .into_response();

        Ok(response)
    }

    async fn blog(Path(id): Path<u32>) -> Result<Html<String>, StatusCode> {
        let html = reqwest::get(format!("http://127.0.0.1:8080/blog/{}", id))
            .await
            .map_err(|_| StatusCode::BAD_REQUEST)?
            .text()
            .await
            .map_err(|_| StatusCode::BAD_REQUEST)?;

        let (head_before, head_after) = html.split_once("</head>").unwrap();
        let mut head_before = head_before.to_owned();

        head_before.push_str(r#"<meta property="og:title" content="Dioxus" />"#);
        head_before.push_str("</head>");
        let renderered_html = head_before + head_after;

        Ok(Html(renderered_html))
    }

    let client: Client =
        hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
            .build(HttpConnector::new());

    // build our application with a route
    let app = Router::new()
        .route("/blog/:id", get(blog))
        .fallback(
            get(handler)
                .post(handler)
                .put(handler)
                .delete(handler)
                .patch(handler),
        )
        .with_state(client);

    // run it
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

ビルドしてcurlで確認してみます.
リクエスト先がhttp://localhost:3000であることに注意です.

> curl http://localhost:3000/blog/0

<!DOCTYPE html>
<html>
<head>
  <title>demo</title>
  <meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta charset="UTF-8" />

<meta property="og:title" content="Dioxus" /></head>

<meta property="og:title" content="Dioxus" /></head>

og:titleが設定されていますね

何をやっているかというと,server featuresでビルドされたときのみ別スレッドでaxumサーバーを起動します

    #[cfg(feature = "server")]
    {
        let handle = std::thread::spawn(|| {
            tokio::runtime::Runtime::new()
                .unwrap()
                .block_on(async move {
                    launch_reverse_proxy().await;
                });
        });
    }

launch_reverse_proxy()の実装をみていくと,2つのHandlerが定義されています.

async fn handler(State(client): State<Client>, mut req: Request) -> Result<Response, StatusCode> {
    let path = req.uri().path();
    let path_query = req
        .uri()
        .path_and_query()
        .map(|v| v.as_str())
        .unwrap_or(path);

    let uri = format!("http://127.0.0.1:8080{}", path_query);

    *req.uri_mut() = Uri::try_from(uri).unwrap();

    let response = client
        .request(req)
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?
        .into_response();

    Ok(response)
}

handler()はパスクエリをhttp://127.0.0.1:8080に付け替えてリクエストし,返ってきたレスポンスをそのまま返しています.
所謂リバースプロキシ的な役割を果たしています.

async fn blog(Path(id): Path<u32>) -> Result<Html<String>, StatusCode> {
    let html = reqwest::get(format!("http://127.0.0.1:8080/blog/{}", id))
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?
        .text()
        .await
        .map_err(|_| StatusCode::BAD_REQUEST)?;

    let (head_before, head_after) = html.split_once("</head>").unwrap();
    let mut head_before = head_before.to_owned();

    head_before.push_str(r#"<meta property="og:title" content="Dioxus" />"#);
    head_before.push_str("</head>");
    let rendered_html= head_before + head_after;

    Ok(Html(rendered_html))
}

let app = Router::new()
    .route("/blog/:id", get(blog))
    .fallback(
        get(handler)
            .post(handler)
            .put(handler)
            .delete(handler)
            .patch(handler),
    )
    .with_state(client);

blog()"/blog/:id"でリクエストを受け付けるようになっています.
idを抽出し,handler()と同様に"http://127.0.0.1:8080に付け替えてリクエストしています.
その後,返ってきたレスポンスに対して,<head>タグ内に<meta property="og:title" content="Dioxus" />を埋め込んでレスポンスを返しています.

なかなか厳しいですね.

まとめ

今回は,Dioxusを用いてOGPの設定などもできるようにSSRなWebアプリケーションの構築を行いました.

dioxusはHTML タグがstructで定義されているのでエディタからの補完が効くのが良いところです.
SPAアプリケーションを開発するときはyewより良さげです.

しかしSSR+Hydration用途で使用したい場合は機能不足やdioxusそのものの設計がネックになりました.
今回構築したdioxus-fullstackはaxumと密に結合しているためこちらで手出しができないところが出てしまいます.
routeを追加したり,ミドルウェアを追加することが(おそらく)できないのがつらいところです.

またdioxusはSSRだけ(rsx!で記述したDOM構造をStringにレンダリングする機能)を行うこともでき,Hydrationを自身で行うこともできますが,doixus-ssrはroute機能を提供していないので単一ページしかレンダリングできません.

以上の理由からyewからdioxusへの移行は今のところ見送っています.
ただdioxusは開発が活発なので今後改善される可能性もありそうなのでアップデート情報はウォッチしています.