前回のアーティクルでは、 Yew で開発を進めるための機能を紹介しました。
今回のアーティクルは Yew コンポーネントから(というよりも Wasm コンパイルする時に)httpリクエストを送信する方法を紹介します。
「フロント実装をする」ということで、データベースを操作したりバックエンドについては触れません。
しかし、何かサービスを開発しようとした時、フロントエンドのみを提供することはほとんど無いでしょう。
フロントエンドのみでできることにはどのようなことがあるでしょうか?
デザインを提供することが重要であれば、フロントエンドのみで十分ですがそれならばあえて Wasm を使う必要はありません。( Yew でスタイリングする方法も後に紹介したいですね!)
もし、バックエンドとともに開発を進めることになれば、データフェッチングの管理やリクエストの送信などを実装する必要があります。
さらに、バックエンドとなるサービスを自分で用意しなくても API などを公開している外部のサービスをより便利に使う、というサービスも提供できるようになるかもしれません!
http リクエストの送信
Rust で httpリクエストを送信する方法はいくつかありますが、今回は reqwest を使う方法を紹介します。
reqwest は Wasm でも動作する http クライアントです。
Wasm で http リクエストを送信するには gloo_net を使う方法もありますが、実装するための資料を探すと reqwest の方が多く見つかるでしょう。
Wasm へコンパイルするプロジェクトで reqwest を使用するときは --features blocking
が動作しないため、 async
による非同期処理を使う必要があります。
本来ならば async
による非同期処理を実装するためには tokio
などのランタイムが必要ですが、Wasm では tokio
が動作しません。
Wasm で async
を使うためには wasm-bindgen-futures を使う必要があります。
まずは Cargo.toml にクレートを追加しましょう。
$ cargo add reqwest wasm-bindgen-futures
reqwest::Client
からリクエスト送信とレスポンスの受け取りを非同期で行います。
非同期で処理を行うため、 async
ブロックの中に記述して、そのブロックを wasm_bindgen_futures::spawn_local
でラップします。
use gloo::console;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[function_component]
fn App() -> Html {
let response_data = use_state(|| "response".to_string());
let send = {
let response_data = response_data.clone();
move |_| {
let response_data = response_data.clone();
console::log!("send");
spawn_local(async move {
let client = reqwest::Client::new();
let req = client
.get("https://jsonplaceholder.typicode.com/posts/1")
.send()
.await
.expect("failed to send request");
console::log!(format!("headers: {:?}", req.headers()));
let data = req.text().await.expect("failed to get response");
response_data.set(format!("{:?}", data));
});
}
};
html! {
<div>
<button onclick={ send }>{"send"}</button>
<p>{ &response_data.replace(r#"\""#, "\"").trim_matches('"') }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
get()
を使用することで GET
リクエストを作成することができます。
response_data
を 2 回シャドイングしているのは所有権とライフタイムの都合です。
POST
リクエストを作成するにはpost()
を使用します。
さらに header()
でヘッダーを、 body()
でボディの内容を設定することができます。
use gloo::console;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[function_component]
fn App() -> Html {
let response_data = use_state(|| "response".to_string());
let send = {
let response_data = response_data.clone();
move |_| {
let response_data = response_data.clone();
console::log!("send");
spawn_local(async move {
let client = reqwest::Client::new();
let req = client
.post("https://jsonplaceholder.typicode.com/posts")
.header("Content-Type", "application/json; charset=UTF-8")
.body(r#"{"title": "foo", "body": "bar", "userId": 1}"#)
.send()
.await
.expect("failed to send request");
console::log!(format!("headers: {:?}", req.headers()));
let data = req.text().await.expect("failed to get response");
response_data.set(format!("{:?}", data));
});
}
};
html! {
<div>
<button onclick={ send }>{"send"}</button>
<p>{ &response_data.replace(r#"\""#, "\"").trim_matches('"') }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
返ってきたレスポンスの JSON の内容が確認できましたか?
クレートを使えばリクエストを送信するのは簡単でしたね!
先ほどの例では、 POST
リクエストを送るときにボディの内容を直接指定していました。
リクエストを送るときにはパラメーターを付加することで柔軟な API にも対応できるようになるのですが、送りたくなるかもしれないリクエストをあらかじめ全て記述しておくのはとても大変ですね。
次は Rust のストラクトなどから、 GET
リクエストに使うクエリパラメーターや POST
リクエストのボディに付加する JSON データへ変換する方法を紹介します。
serde
serde
はデータのシリアライズ( Serializing )とデシリアライズ( Deserialinzing )を行うことができるクレートです。
データのシリアライズを行えるようにするには、シリアライズしたいデータ構造のストラクトを定義し serde::Serialize
トレートを実装します。
cargo add serde serde_json serde_url_params
use std::collections::{HashMap, HashSet};
use serde::Serialize;
use yew::prelude::*;
#[derive(Serialize)]
struct Data {
number: i32,
float_number: f32,
text: String,
option_some: Option<i32>,
option_none: Option<i32>,
array: [i32; 3],
vector: Vec<i32>,
map: HashMap<String, i32>,
set: HashSet<i32>,
}
#[function_component]
fn App() -> Html {
let data = Data {
number: 1,
float_number: 2f32,
text: "Hello World!".to_string(),
option_some: Some(3),
option_none: None,
array: [3, 4, 5],
vector: vec![6, 7, 8],
map: HashMap::from([("key1".to_string(), 9), ("key2".to_string(), 0)]),
set: HashSet::from([11, 22, 33]),
};
html! {
<div>
<p>{ serde_url_params::to_string(&data).unwrap() }</p>
<p>{ serde_json::to_string(&data).unwrap() }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
このコードをコンパイルしてページを開くとシリアライズされた文字列データが表示されるはずです。
number=1&float_number=2&text=Hello+World%21&option_some=3&array=3&array=4&array=5&vector=6&vector=7&vector=8&key2=0&key1=9&set=33&set=11&set=22
{"number":1,"float_number":2.0,"text":"Hello World!","option_some":3,"option_none":null,"array":[3,4,5],"vector":[6,7,8],"map":{"key2":0,"key1":9},"set":[33,11,22]}
serde_url_params
クレートを使用すればクエリパラメータ形式へ、 serde_json
クレートを使用すれば JSON 形式へ簡単にシリアライズすることができます。
さらに、任意の形式へ変換するメソッドを実装して独自のシリアライズを行うこともできます。
use std::collections::HashSet;
use serde::Serialize;
use yew::prelude::*;
#[derive(Serialize)]
struct Data {
vec: Vec<i32>,
#[serde(serialize_with = "serialize_to_camma_separated")]
set: HashSet<i32>,
}
fn serialize_to_camma_separated<S, T>(
elements_set: &HashSet<T>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
T: ToString,
{
let data = elements_set
.iter()
.map(|x| x.to_string())
.collect::<Vec<String>>()
.join(",");
serializer.serialize_str(&data)
}
#[function_component]
fn App() -> Html {
let data = Data {
vec: vec![1, 2, 3],
set: HashSet::from([11, 22, 33]),
};
html! {
<div>
<p>{ serde_url_params::to_string(&data).unwrap() }</p>
<p>{ serde_json::to_string(&data).unwrap() }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
vec=1&vec=2&vec=3&set=33%2C22%2C11
{"vec":[1,2,3],"set":"33,22,11"}
先ほどの例ではベクトル型も HashSet
型も同様にシリアライズされていましたが、要素の一つ一つを文字列へ変換しカンマを挟んで並べるようなシリアライザー関数 serialize_to_camma_separated
を実装することで HashSet
型をカンマ区切りの文字列へ変換することができました。
HashMap
や HashSet
は要素の順番がランダムになってしまう事があるので意図的にソートしたい場合であったり、 Serialize
トレートを実装しただけではシリアライズできない特殊な型をシリアライズしたい場合などは自分でシリアライザーを実装してみましょう。
データのシリアライズ方法が分かったので実際にリクエストと組み合わせてみましょう。
use gloo::console;
use serde::Serialize;
use wasm_bindgen_futures::spawn_local;
use yew::prelude::*;
#[derive(Serialize)]
struct Body {
title: String,
body: String,
user_id: u32,
}
#[function_component]
fn App() -> Html {
let request_body = use_state(|| Body {
title: "foo".to_string(),
body: "bar".to_string(),
user_id: 1,
});
let request_body = request_body.clone();
let response_data = use_state(|| "response".to_string());
let send = {
let request_body = request_body.clone();
let response_data = response_data.clone();
move |_| {
let request_body = request_body.clone();
let response_data = response_data.clone();
console::log!("send");
spawn_local(async move {
let client = reqwest::Client::new();
let req = client
.post("https://jsonplaceholder.typicode.com/posts")
.header("Content-Type", "application/json; charset=UTF-8")
.body(serde_json::to_string(&*request_body).unwrap())
.send()
.await
.expect("failed to send request");
console::log!(format!("headers: {:?}", req.headers()));
let data = req.text().await.expect("failed to get response");
response_data.set(format!("{:?}", data));
});
}
};
html! {
<div>
<button onclick={ send }>{"send"}</button>
<p>{ &response_data.replace(r#"\""#, "\"").trim_matches('"') }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
これでリクエストパラメータも変数として扱うことができました!
所有権の移動とライフタイムを合わせるためにシャドイングが増えてしまいましたが、 UseStateHandle
でラップすることでオリジナルのデータをすべてクローンする必要がないので効率的に動作させることができます。
より多くの状態を持つコンポーネントを実装する場合には、それらの状態を 1 つのストラクトで管理し use_reducer
を使って取りまとめることでコードの煩雑さを軽減することができるでしょう。
おまけ:非同期処理
cargo add wasm_timer@0.2.5
cargo add parking_lot@0.11 --features wasm-bindgen
use std::time::Duration;
use gloo::console;
use wasm_bindgen_futures::spawn_local;
use wasm_timer::Delay;
use yew::prelude::*;
#[function_component]
fn App() -> Html {
let playing = use_state(|| false);
let count = use_state(|| 0);
let time = use_state(|| "over".to_string());
let add_count = {
let playing = playing.clone();
let count = count.clone();
move |_| {
if *playing {
count.set(*count + 1);
}
}
};
let game_start = {
let playing = playing.clone();
let count = count.clone();
let time = time.clone();
move |_| {
if *playing {
console::log!("already started");
return;
}
console::log!("game start");
playing.set(true);
count.set(0);
let playing = playing.clone();
let time = time.clone();
spawn_local(async move {
for i in (1..=5).rev() {
time.set(format!("{}s", i));
Delay::new(Duration::from_secs(1)).await.unwrap();
}
time.set("over".to_string());
playing.set(false);
console::log!("game over");
});
console::log!("game started");
}
};
html! {
<div>
<button onclick={ game_start }>{ "start" }</button>
<p> { &*time } </p>
<button onclick={ add_count }>
{
if *playing {
"get point on click !"
} else {
"no point in click..."
}
}
</button>
<p>{ *count }</p>
</div>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
非同期ブロックの中でタイマーを起動して5秒間に何回ボタンを押すことできるかにチャレンジするミニゲームを作ってみました。
スタートした後に「get point on click!」ボタンを押すと5秒間だけカウントを増やすことができます。ボタンを押した時のカウント数の変更や非同期ブロックで行われる残り時間表示の変更もそれぞれ動作していますね。 Yew ではいつでも状態が set
や dispatch
されたタイミングで再描画が行われるので、非同期な処理を実行していても任意のタイミングで画面を更新することができます。レスポンスを受け取るまでに時間がかかるリクエストを行う時にロード画面を表示して結果を表示できるようになってから表示を行ったり、とても時間のかかる処理を行う時には経過に応じて適宜進捗状況を表示したりすることもできそうです。
不定期の更新になりますが、何か簡単な機能を持った(だけど便利で誰かの役に立つ)アプリケーションを開発するところまで連載したいと思っています。
アイディア募集中です!よろしくお願いいたします!
ありがとうございました!
この文章の一部は Chat-GPT 3.5 によって和文校正されています。
お知らせ
DONUTSでは新卒中途問わず積極的に採用活動を行っています。
詳細はこちらをご確認ください。
ジョブカン事業部のエンジニア募集はこちら。