3
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?

More than 1 year has passed since last update.

ジョブカンAdvent Calendar 2023

Day 14

【Rust】Yewを使ってフロント実装します。その2: リクエスト編

Last updated at Posted at 2023-12-13

前回のアーティクルでは、 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 でラップします。

get
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() でボディの内容を設定することができます。

post
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
JSON
{"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 形式へ簡単にシリアライズすることができます。

さらに、任意の形式へ変換するメソッドを実装して独自のシリアライズを行うこともできます。

serializ_with
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
JSON
{"vec":[1,2,3],"set":"33,22,11"}

先ほどの例ではベクトル型も HashSet 型も同様にシリアライズされていましたが、要素の一つ一つを文字列へ変換しカンマを挟んで並べるようなシリアライザー関数 serialize_to_camma_separated を実装することで HashSet 型をカンマ区切りの文字列へ変換することができました。

HashMapHashSet は要素の順番がランダムになってしまう事があるので意図的にソートしたい場合であったり、 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 ではいつでも状態が setdispatch されたタイミングで再描画が行われるので、非同期な処理を実行していても任意のタイミングで画面を更新することができます。レスポンスを受け取るまでに時間がかかるリクエストを行う時にロード画面を表示して結果を表示できるようになってから表示を行ったり、とても時間のかかる処理を行う時には経過に応じて適宜進捗状況を表示したりすることもできそうです。


不定期の更新になりますが、何か簡単な機能を持った(だけど便利で誰かの役に立つ)アプリケーションを開発するところまで連載したいと思っています。
アイディア募集中です!よろしくお願いいたします!
ありがとうございました!


この文章の一部は Chat-GPT 3.5 によって和文校正されています。

お知らせ

DONUTSでは新卒中途問わず積極的に採用活動を行っています。
詳細はこちらをご確認ください。

ジョブカン事業部のエンジニア募集はこちら。

3
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
3
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?