本サイトの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のドキュメントページに従って進めていきたいと思います.
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です.
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データが取れていそうです.
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("{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"sunt aut facere repellat provident occaecati excepturi optio reprehenderit\",\n \"body\": \"quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto\"\n}"))<!--#-->
</p></div><meta hidden="true" id="dioxus-storage-data" data-serialized="oWRkYXRhgZkBkBhvGFcYShhQGGEYMxhrGEIYShhIGHMYSxhJGEMYQRhpGGQYWBhOGGwYYxhrGGwYaxhJGGoYbxhnGE0YUxh3GEsYSRhDGEEYaRhhGFcYURhpGE8YaRhBGHgYTBhBGG8YZxhJGEMYShgwGGEYWBhSGHMYWhhTGEkYNhhJGEMYShh6GGQYVxg1GDAYSRhHGEYYMRhkGEMYQhhtGFkYVxhOGGwYYxhtGFUYZxhjGG0YVhh3GFoYVxh4GHMYWRhYGFEYZxhjGEgYShh2GGQYbRhsGGsYWhhXGDUYMBhJGEcYORhqGFkYMhhGGGwYWRgyGEYYMBhhGFMYQhhsGGUYRxhOGGwYYxhIGFIYMRhjGG0YaxhnGGIYMxhCGDAYYRhXGDgYZxhjGG0YVhh3GGMYbRhWGG8YWhhXGDUYaxhaGFgYShhwGGQYQxhJGHMYQxhpGEEYZxhJGG0YShh2GFoYSBhrGGkYTxhpGEEYaRhjGFgYVhhwGFkYUxhCGGwYZBhDGEIYehhkGFgYThhqGGEYWBhCGHAYZBhGGHgYdRhjGDMYVhh6GFkYMhhsGHcYYRhYGFEYZxhjGG0YVhhqGGQYWBhOGGgYYhhtGFIYaBhaGFMYQhhqGGIYMhg1GHoYWhhYGEYYMRhkGFcYNRgwGGQYWBhJGGcYWhhYGGgYdxhaGFcYUhhwGGQYRxhFGGcYWhhYGFEYZxhZGDMYVhh0GFgYRxg1GHkYWhhYGEIYeRhaGFcYaBhsGGIYbRhSGGwYYxhtGGwYMBhJGEcYMRh2GGIYRxhWGHoYZBhHGGwYaBhaGFMYQhgxGGQYQxhCGDEYZBhDGEIYeBhkGFcYRhh6GEkYSBhSGHYYZBhHGEYYdBhYGEcYNRh1GGIYMxhOGDAYYxhuGFYYdBhJGEgYShhsGGMYbhhWGHQYSRhHGFYYehhkGEMYQhhoGGQYWBhSGGwYYhhTGEIYehhkGFcYNRgwGEkYSBhKGGwYYhhTGEIYbBhkGG0YVhh1GGEYVxhWGDAYSRhHGEYYeRhZGDIYaBhwGGQYRxhWGGoYZBhHGDgYaRhDGG4YMBg9" />
...
逆にクライアントでデータをfetchしたいパターンもあると思います.
その場合はuse_future
やuse_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:?}"}
}
}
}
ブラウザで確認してみます.
見た目は変わらないですね
しかし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("{\n \"userId\": 1,\n \"id\": 1,\n \"title\": \"sunt aut facere repellat provident occaecati excepturi optio reprehenderit\",\n \"body\": \"quia et suscipit\\nsuscipit recusandae consequuntur expedita et cum\\nreprehenderit molestiae ut ut quas totam\\nnostrum rerum est autem sunt rem eveniet architecto\"\n}"))<!--#--></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しているようですね.
Prefixに/api
がつき,関数名+ランダムな数字のエンドポイントが自動的に生成されるようです.
ちなみになぜかAPIが2回叩かれているんですが,なんでなんですかね・・・
ここまでで,一通りのことは出来そうです.
さて,今回はブログサイトを構築することを念頭においているのでOGPタグを設定したくなりますよね.
というわけで設定してみたいと思います.
OGPタグを(無理やり)設定する
Cargo.toml
にaxum, 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は開発が活発なので今後改善される可能性もありそうなのでアップデート情報はウォッチしています.