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

[前回](https://qiita.com/matilda-ray-kerria/items/16fb64a4cd6b2a99e6cd)のアーティクルでは、 Yew で開発を進めるための機能を紹介しました。

今回のアーティクルは Yew コンポーネントから（というよりも Wasm コンパイルする時に）httpリクエストを送信する方法を紹介します。

「フロント実装をする」ということで、データベースを操作したりバックエンドについては触れません。 
しかし、何かサービスを開発しようとした時、フロントエンドのみを提供することはほとんど無いでしょう。 
フロントエンドのみでできることにはどのようなことがあるでしょうか？ 
デザインを提供することが重要であれば、フロントエンドのみで十分ですがそれならばあえて Wasm を使う必要はありません。（ Yew でスタイリングする方法も後に紹介したいですね！）
もし、バックエンドとともに開発を進めることになれば、データフェッチングの管理やリクエストの送信などを実装する必要があります。 
さらに、バックエンドとなるサービスを自分で用意しなくても API などを公開している外部のサービスをより便利に使う、というサービスも提供できるようになるかもしれません！

## http リクエストの送信

Rust で httpリクエストを送信する方法はいくつかありますが、今回は [reqwest](https://docs.rs/reqwest/0.10.4/reqwest/) を使う方法を紹介します。

reqwest は Wasm でも動作する http クライアントです。 
Wasm で http リクエストを送信するには gloo_net を使う方法もありますが、実装するための資料を探すと reqwest の方が多く見つかるでしょう。

Wasm へコンパイルするプロジェクトで reqwest を使用するときは `--features blocking` が動作しないため、 `async` による非同期処理を使う必要があります。 
本来ならば `async` による非同期処理を実装するためには `tokio` などのランタイムが必要ですが、Wasm では `tokio` が動作しません。
Wasm で `async` を使うためには [wasm-bindgen-futures](https://docs.rs/wasm-bindgen-futures/0.4.39/wasm_bindgen_futures/) を使う必要があります。

まずは Cargo.toml にクレートを追加しましょう。

```bash
$ cargo add reqwest wasm-bindgen-futures
```

`reqwest::Client` からリクエスト送信とレスポンスの受け取りを非同期で行います。
非同期で処理を行うため、 `async` ブロックの中に記述して、そのブロックを `wasm_bindgen_futures::spawn_local` でラップします。

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

```rust: 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`](https://serde.rs/) はデータのシリアライズ（ ***Ser***ializing ）とデシリアライズ（ ***De***serialinzing ）を行うことができるクレートです。
データのシリアライズを行えるようにするには、シリアライズしたいデータ構造のストラクトを定義し `serde::Serialize` トレートを実装します。

```bash
cargo add serde serde_json serde_url_params
```

```rust
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();
}
```

このコードをコンパイルしてページを開くとシリアライズされた文字列データが表示されるはずです。

```text: クエリパラメータ
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: 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 形式へ簡単にシリアライズすることができます。

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

```rust: 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();
}
```

```text: クエリパラメータ
vec=1&vec=2&vec=3&set=33%2C22%2C11
```

```json: JSON
{"vec":[1,2,3],"set":"33,22,11"}
```

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

`HashMap` や `HashSet` は要素の順番がランダムになってしまう事があるので意図的にソートしたい場合であったり、 `Serialize` トレートを実装しただけではシリアライズできない特殊な型をシリアライズしたい場合などは自分でシリアライザーを実装してみましょう。

***

データのシリアライズ方法が分かったので実際にリクエストと組み合わせてみましょう。

```rust
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` を使って取りまとめることでコードの煩雑さを軽減することができるでしょう。

## おまけ：非同期処理

```bash
cargo add wasm_timer@0.2.5
cargo add parking_lot@0.11 --features wasm-bindgen
```

```rust
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();
}
```

非同期ブロックの中でタイマーを起動して５秒間に何回ボタンを押すことできるかにチャレンジするミニゲームを作ってみました。

スタートした後に「get point on click!」ボタンを押すと５秒間だけカウントを増やすことができます。ボタンを押した時のカウント数の変更や非同期ブロックで行われる残り時間表示の変更もそれぞれ動作していますね。 Yew ではいつでも状態が `set` や `dispatch` されたタイミングで再描画が行われるので、非同期な処理を実行していても任意のタイミングで画面を更新することができます。レスポンスを受け取るまでに時間がかかるリクエストを行う時にロード画面を表示して結果を表示できるようになってから表示を行ったり、とても時間のかかる処理を行う時には経過に応じて適宜進捗状況を表示したりすることもできそうです。

***

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

***

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

## お知らせ

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

https://www.donuts.ne.jp/recruit/

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

https://recruit.jobcan.jp/donuts/show/B001/1396297