10
5

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 5 years have passed since last update.

Rustその2Advent Calendar 2019

Day 20

Rust製のフロントエンドフレームワークYewでファイルアップロードを実装してみる

Last updated at Posted at 2019-12-19

Rustその2 Advent Calendar 2019』の 20 日目の記事です。Yew についてご紹介します。

Yew は Elm と React にインスパイアされたフレームワークとのことで、The Elm Architecture で書けるようになっています。

公式のサンプルのカウンターアプリを見ていただくとわかると思うのですが、Elm で何か書いたことがある方にはスッと馴染めると思いますし、そうでない方でもシンプルなのでちょっと試していただけると嬉しいです。

ちなみに Elm も Yew も英語での意味では木材の一種で、Elm はニレ、Yew はイチイのようです。

はじめに

さて、今回ご紹介するファイルアップロードですが、具体的には

『ファイルフィールドで選択されたファイルを Yew 側でいったんすべて読み込み、その内容を筆者作のAPIサーバーの GraphQL エンドポイントに Mutation クエリで送信する』

という内容でやっていきます。

内容

1. プロジェクトの準備

まずは以下で Hello, World! を画面に表示します。

# プロジェクトを作成する。
$ cargo new fileupload-sample

# yew を使うように Cargo.toml の [dependencies] セクションに追記する。
$ echo 'yew = "0.10.0"' >> Cargo.toml

# src/main.rs に適当なコードを書く。
$ cat <<EOF > src/main.rs
use yew::prelude::*;
use yew::services::ConsoleService;

fn main() {
    yew::start_app::<Model>();
}

struct Model {
    #[allow(dead_code)]
    console: ConsoleService,
}

enum Msg {}

impl Component for Model {
    type Message = Msg;
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Model {
            console: ConsoleService::new(),
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {}

        #[allow(unreachable_code)]
        true
    }

    fn view(&self) -> Html<Self> {
        html! {
            <div>
                { "Hello, World!" }
            </div>
        }
    }
}
EOF

# 起動する (事前に https://github.com/koute/cargo-web のインストールが必要)
$ cargo web start

この状態でブラウザで http://localhost:8000 を開くと以下のように表示されます。

スクリーンショット 2019-12-14 16.34.32.png

2. ファイルフィールドの onchange イベントで ChangeData::Files(FileList) を受け取り File を取り出す

ファイルフィールドは <input type="file" /> で表示できます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -32,7 +32,7 @@ impl Component for Model {
     fn view(&self) -> Html<Self> {
         html! {
             <div>
-                { "Hello, World!" }
+                <input type="file" />
             </div>
         }
     }
スクリーンショット 2019-12-14 16.36.38.png

これだけだと何も起こらないため、onchange イベントを登録します。
onchange イベントでは yew::html::ChangeData 1が取得できるのですが、まずはそれをコンソールに出力してみます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -10,7 +10,9 @@ struct Model {
     console: ConsoleService,
 }

-enum Msg {}
+enum Msg {
+    ChooseFile(ChangeData),
+}

 impl Component for Model {
     type Message = Msg;
@@ -23,7 +25,11 @@ impl Component for Model {
     }

     fn update(&mut self, msg: Self::Message) -> ShouldRender {
-        match msg {}
+        match msg {
+            Msg::ChooseFile(change_data) => {
+                self.console.log(format!("{:?}", change_data).as_str());
+            }
+        }

         #[allow(unreachable_code)]
         true
@@ -32,7 +38,7 @@ impl Component for Model {
     fn view(&self) -> Html<Self> {
         html! {
             <div>
-                <input type="file" />
+                <input type="file" onchange=|change_data| Msg::ChooseFile(change_data) />
             </div>
         }
     }

Files(FileList(Reference(16))) が出力されました。

demo.gif

以下の yew::html::ChangeData の定義を見てみます。enum になっています。コメントを読む限り、確かにファイルフィールドの時には Files(FileList) が取得できそうです。

https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L158-L170

/// A type representing change of value(s) of an element after committed by user
/// ([onchange event](https://developer.mozilla.org/en-US/docs/Web/Events/change)).
#[derive(Debug)]
pub enum ChangeData {
    /// Value of the element in cases of `<input>`, `<textarea>`
    Value(String),
    /// SelectElement in case of `<select>` element. You can use one of methods of SelectElement
    /// to collect your required data such as: `value`, `selected_index`, `selected_indices` or
    /// `selected_values`. You can also iterate throught `selected_options` yourself.
    Select(SelectElement),
    /// Files
    Files(FileList),
}

具体的には以下の箇所で input フィールドの typefile なら ChangeData::Files としているようです。

https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L104-L123

// Inspired by: http://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html-Events
impl_action! {
// ...(省略)...
    onchange(event: ChangeEvent) -> ChangeData => |this: &Element, _| {
// ...(省略)...
        match this.node_name().as_ref() {
            "INPUT" => {
                let input: InputElement = this.clone().try_into().unwrap();
                let is_file = input.get_attribute("type").map(|value| {
                        value.eq_ignore_ascii_case("file")
                    })
                    .unwrap_or(false);
                if is_file {
                    let files: FileList = js!( return @{input}.files; )
                        .try_into()
                        .unwrap();
                    ChangeData::Files(files)
// ...(省略)...

ちなみに impl_action! マクロ自体は同ファイル内の https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L9-L50 に定義されていました。

さて、先程の Files(FileList)FileList の方ですが、こちらは Yew の依存する crate の stdweb 側で定義されていまして、Iterator を実装するように定義されています。

https://github.com/koute/stdweb/blob/dff1e06086124fe79e3393a99ae8e2d424f5b2f1/src/webapi/file_list.rs

その ItemFile は name にアクセスできるようでした。

https://github.com/koute/stdweb/blob/8049357b33d49b306ae04a75ecc9a11a41f28b67/src/webapi/file.rs

さて、次からはこの stdweb の File を使うため、if letfor で取り出しておきます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -27,7 +27,11 @@ impl Component for Model {
     fn update(&mut self, msg: Self::Message) -> ShouldRender {
         match msg {
             Msg::ChooseFile(change_data) => {
-                self.console.log(format!("{:?}", change_data).as_str());
+                if let ChangeData::Files(files) = change_data {
+                    for file in files {
+                        self.console.log(format!("{:?}", file).as_str());
+                    }
+                }
             }
         }

3. ReaderServiceFile を読み込む

前回の stdweb の FileReaderServicefn read_file(&mut self, file: File, callback: Callback<FileData>) -> ReaderTask を使って読み込んでいきます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,5 @@
 use yew::prelude::*;
-use yew::services::ConsoleService;
+use yew::services::{reader::ReaderService, ConsoleService};

 fn main() {
     yew::start_app::<Model>();
@@ -8,6 +8,7 @@ fn main() {
 struct Model {
     #[allow(dead_code)]
     console: ConsoleService,
+    reader_service: ReaderService,
 }

 enum Msg {
@@ -21,6 +22,7 @@ impl Component for Model {
     fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
         Model {
             console: ConsoleService::new(),
+            reader_service: ReaderService::new(),
         }
     }

@@ -29,7 +31,8 @@ impl Component for Model {
             Msg::ChooseFile(change_data) => {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
-                        self.console.log(format!("{:?}", file).as_str());
+                        let callback = Callback::from(|_| panic!());
+                        let _reader_task = self.reader_service.read_file(file, callback);
                     }
                 }
             }

read_file では第2引数にコールバックを指定できます。このコールバックは、第1引数に指定したファイルの読み込みが完了した時点で呼ばれ、コールバックの引数は読み込んだファイルのデータになります。ここではいったん panic!() だけするようなコールバックを登録します。

実は上記の修正だけではだめで、以下のように read_file の戻り値の ReaderTask を登録する必要があります。2

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,8 @@
 use yew::prelude::*;
-use yew::services::{reader::ReaderService, ConsoleService};
+use yew::services::{
+    reader::{ReaderService, ReaderTask},
+    ConsoleService,
+};

 fn main() {
     yew::start_app::<Model>();
@@ -9,6 +12,7 @@ struct Model {
     #[allow(dead_code)]
     console: ConsoleService,
     reader_service: ReaderService,
+    reader_tasks: Vec<ReaderTask>,
 }

 enum Msg {
@@ -23,6 +27,7 @@ impl Component for Model {
         Model {
             console: ConsoleService::new(),
             reader_service: ReaderService::new(),
+            reader_tasks: vec![],
         }
     }

@@ -32,7 +37,8 @@ impl Component for Model {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
                         let callback = Callback::from(|_| panic!());
-                        let _reader_task = self.reader_service.read_file(file, callback);
+                        let reader_task = self.reader_service.read_file(file, callback);
+                        self.reader_tasks.push(reader_task);
                     }
                 }
             }

これで想定通りコールバックの panic が出力されるようになりました。

demo.gif

さて、読み込んだデータを表示するようにしてみましょう。ComponentLink3fn send_back<F, IN>(&mut self, function: F) -> Callback<IN> where F: Fn(IN) -> COMP::Message + 'static を使います。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,6 +1,6 @@
 use yew::prelude::*;
 use yew::services::{
-    reader::{ReaderService, ReaderTask},
+    reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };

@@ -13,21 +13,24 @@ struct Model {
     console: ConsoleService,
     reader_service: ReaderService,
     reader_tasks: Vec<ReaderTask>,
+    link: ComponentLink<Self>,
 }

 enum Msg {
     ChooseFile(ChangeData),
+    FileLoaded(FileData),
 }

 impl Component for Model {
     type Message = Msg;
     type Properties = ();

-    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
+    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
         Model {
             console: ConsoleService::new(),
             reader_service: ReaderService::new(),
             reader_tasks: vec![],
+            link,
         }
     }

@@ -36,12 +39,16 @@ impl Component for Model {
             Msg::ChooseFile(change_data) => {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
-                        let callback = Callback::from(|_| panic!());
+                        let callback = self.link.send_back(|file_data| Msg::FileLoaded(file_data));
                         let reader_task = self.reader_service.read_file(file, callback);
                         self.reader_tasks.push(reader_task);
                     }
                 }
             }
+
+            Msg::FileLoaded(file_data) => {
+                self.console.log(format!("{:?}", file_data).as_str());
+            }
         }

上記のように修正すると、以下のように FileData 構造体の name フィールドにファイル名が、content フィールドに Vec<u8> 型でファイルの内容が表示されます。

demo.gif

4. 読み込んだ FileData の content を Base64 エンコードして FetchService で送信する

読み込んだファイルの内容を FetchServicefetch メソッドで送信していきます。

その前に、今回は JSON で送るため、他にもやり方はいろいろあるのでしょうが、いったん Base64 エンコードしてから送ります。

まずは Cargo.tomlbase64 = "0.11.0" を追記します。

--- fileupload-sample/Cargo.toml
+++ fileupload-sample/Cargo.toml
@@ -8,3 +8,4 @@ edition = "2018"

 [dependencies]
 yew = "0.10.0"
+base64 = "0.11.0"

そして、読み込んだファイルの内容に対して base64::encode を呼びます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -47,7 +47,8 @@ impl Component for Model {
             }

             Msg::FileLoaded(file_data) => {
-                self.console.log(format!("{:?}", file_data).as_str());
+                let filename = file_data.name;
+                let encoded_data = base64::encode(&file_data.content);
             }
         }

さて、後は HTTP リクエストをするだけですが、今回のリクエスト先は筆者作のAPIサーバーregisterVideo(key: String!, data: String!) という Mutation のエンドポイントです。

サーバー側の実装にご興味をお持ちの方はこちらをクリックしてください

以下の registerVideo field 内の処理が呼ばれます。内容としては以下です。

  1. 渡された key と data をもとにして S3 に PutObject する。
  2. 渡された key を持つようなレコードを videos テーブルに登録する。
  3. 登録したファイルに対して Pre-Signed URL を発行した情報を返す。

https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/graphql/schema.rs#L17-L23

use crate::util::s3::put_object;
use crate::video::decorator::VideoDecorator;
use crate::video::model::Video;
use crate::Context;
use juniper::{FieldError, FieldResult, RootNode, Value};

//...(省略)...

graphql_object!(Mutation: Context |&self| {
    field registerVideo(&executor, key: String, data: String) -> FieldResult<VideoDecorator> {
        put_object("videos".to_owned(), key.clone(), base64::decode(&data).unwrap());
        let video = Video::create(executor.context(), key).map_err(|e| FieldError::new(e, Value::null()))?;
        Ok(VideoDecorator::from(&video))
    }
});

S3 に PutObject する箇所は以下です。rusoto を使っています。

https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/util/s3.rs#L6-L24

use rusoto_core::credential::AwsCredentials;
use rusoto_core::{ByteStream, Region};
use rusoto_s3::util::{PreSignedRequest, PreSignedRequestOption};
use rusoto_s3::{GetObjectRequest, PutObjectRequest, S3Client, S3};

//...(省略)...

pub fn put_object(bucket: String, key: String, data: Vec<u8>) {
    let client = S3Client::new(get_region());

    let body = Some(ByteStream::from(data));
    let content_type = Some("video/mp4".to_owned());

    let output = client
        .put_object(PutObjectRequest {
            bucket,
            key,
            body,
            content_type,
            ..Default::default()
        })
        .sync()
        .unwrap();

    println!("{:?}", output);
}

//...(省略)...

fn get_region() -> Region {
    Region::Custom {
        name: "minio".to_owned(),
        endpoint: "http://localhost:9001".to_owned(),
    }
}

データベースに保存している箇所は以下です。diesel-rs/diesel を使っています。

https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/video/model.rs#L28-L39

use crate::schema::videos;
use crate::video::entity as E;
use crate::Context;
use diesel::prelude::*;

//...(省略)...

#[derive(Debug)]
pub struct Video {
    pub src: String,
}

#[derive(Insertable)]
#[table_name = "videos"]
pub struct NewVideo {
    pub src: String,
}

impl Video {
//...(省略)...
    pub fn create(context: &Context, src: String) -> Result<Video, String> {
        let conn = context.pool.get().map_err(|e| e.to_string())?;

        let new_video = NewVideo { src };

        let e_video: E::Video = diesel::insert_into(videos::table)
            .values(&new_video)
            .get_result(&conn)
            .map_err(|e| e.to_string())?;

        Ok(Video { src: e_video.src })
    }

以上、サーバー側の実装詳細でした。

先にレスポンスの型だけ定義しておきます。GraphQL の Mutation エンドポイントではレスポンスボディが { "data": { "<Mutation名>": <内容> } } という形式になるため、それ用の型を定義しておきます。4

まずは Cargo.toml に必要なクレートを追記します。

--- fileupload-sample/Cargo.toml
+++ fileupload-sample/Cargo.toml
@@ -9,3 +9,6 @@ edition = "2018"
 [dependencies]
 yew = "0.10.0"
 base64 = "0.11.0"
+failure = "0.1.6"
+serde = "1.0.103"
+serde_json = "1.0.44"

そして、src/main.rs を修正していきます。

--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,10 @@
+use failure::Error;
+use serde::*;
+use yew::format::Json;
 use yew::prelude::*;
 use yew::services::{
+    fetch::Response,
     reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };
@@ -64,3 +69,21 @@ impl Component for Model {
         }
     }
 }
+
+type GraphQLResponse<T> = Response<Json<Result<ResponseData<T>, Error>>>;
+
+#[derive(Deserialize)]
+struct ResponseData<T> {
+    data: T,
+}
+
+#[derive(Deserialize)]
+#[allow(non_snake_case)]
+struct RegisterVideoResponse {
+    registerVideo: Video,
+}
+
+#[derive(Debug, Deserialize)]
+struct Video {
+    src: String,
+}

以上、レスポンスの定義でした。次はリクエストの処理を書いていきます。

ReaderService 同様 FetchService も Task (FetchTask) を返すため、それを一時的に格納する先として fetch_task を用意します。
また、ReaderServiceread_file と同様に FetchServicefetch メソッドも第2引数にコールバックを取るのですが、こちらのコールバックも同様に Msg を返すように実装する必要があります。

以下がその修正です。駆け足になってしまい少々量が多いですが、これで最後です。

diff --git fileupload-sample/src/main.rs fileupload-sample/src/main.rs
index df50027..8093d2f 100644
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,9 +1,10 @@
 use failure::Error;
 use serde::*;
+use serde_json::json;
 use yew::format::Json;
 use yew::prelude::*;
 use yew::services::{
-    fetch::Response,
+    fetch::{FetchService, FetchTask, Request, Response},
     reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };
@@ -18,11 +19,15 @@ struct Model {
     reader_service: ReaderService,
     reader_tasks: Vec<ReaderTask>,
     link: ComponentLink<Self>,
+    fetch_service: FetchService,
+    fetch_task: Option<FetchTask>,
 }
 
 enum Msg {
     ChooseFile(ChangeData),
     FileLoaded(FileData),
+    RequestCompleted(Video),
+    RequestFailed,
 }
 
 impl Component for Model {
@@ -35,6 +40,8 @@ impl Component for Model {
             reader_service: ReaderService::new(),
             reader_tasks: vec![],
             link,
+            fetch_service: FetchService::new(),
+            fetch_task: None,
         }
     }
 
@@ -53,6 +60,43 @@ impl Component for Model {
             Msg::FileLoaded(file_data) => {
                 let filename = file_data.name;
                 let encoded_data = base64::encode(&file_data.content);
+
+                let request_body = json!({
+                    "query":
+                        format!(
+                            "mutation {{ registerVideo(key: \"{}\", data: \"{}\") {{ src }} }}",
+                            filename, encoded_data
+                        )
+                });
+
+                let request = Request::post("http://localhost:8080/graphql")
+                    .header("Content-Type", "application/json")
+                    .body(Json(&request_body))
+                    .expect("Failed to build request.");
+
+                let callback =
+                    self.link
+                        .send_back(move |response: GraphQLResponse<RegisterVideoResponse>| {
+                            let (meta, Json(response_body)) = response.into_parts();
+                            if meta.status.is_success() {
+                                let video = response_body.unwrap().data.registerVideo;
+                                Msg::RequestCompleted(video)
+                            } else {
+                                Msg::RequestFailed
+                            }
+                        });
+
+                let fetch_task = self.fetch_service.fetch(request, callback);
+
+                self.fetch_task = Some(fetch_task);
+            }
+
+            Msg::RequestCompleted(video) => {
+                self.console.log(format!("{:?}", video).as_str());
+            }
+
+            Msg::RequestFailed => {
+                self.console.log("RequestFailed");
             }
         }

コードの修正は以上です。

APIサーバーを起動し、ファイルをアップロードした様子が以下です。APIサーバーはデータを受け取ると S3 に保存して Pre-Signed URL を生成してレスポンスを返すのですが、その URL がコンソールに出力されています。5

demo.gif

以上です、おつかれさまでした!! :tada:

雑感

  • フロントエンドなのにコードを潜っても Rust のコードが出てくるのは楽しい。(時々JavaScriptが顔を出すこともある)
  • The Elm Architecture はやはり気持ちがいい。
    • あるべきものがあるべき場所に存在するよう強制されるため、整いを感じる。
  • 気軽にデバッグできなそうな気配が・・??
    • ひとまず con<Tab>self.console.log(format!("{:?}", <cursor>).as_str()); が展開されるようにしてみたものの・・fmt::Debug を実装している値にしか使えない気が・・
    • Elm なら Debug.log でどんな値でもコンソールに出力してくれるらしい。
  • Elm と違って Mutable に書けるのは入門時の戸惑いが減るかもしれない・・??
    • Immutable な方が最終的には考えないといけないことが減ってよさそうな気もする・・??
  • js! マクロ内でサクッと JavaScript が書けるの便利 () だし、html! マクロ内の JSX-like なシンタックスもまったく違和感なかった。
  1. コード内では use yew::prelude::*; しているため yew::html::Change ではなく ChangeData で呼べます。(yew/src/lib.rs#L154, yew/src/lib.rs#L95-L96)

  2. このひと手間が必要な理由、『Model の変更を検知して View をレンダリングする』という仕組み上、いったん Model にタスクを突っ込んでおいてコールバックが呼ばれたらタスクを取り除くことで Model に変更を加えて View を再びレンダリングさせたいからなんだろうか・・

  3. ComponentLink の仕組みもうちょっと調べたかった・・間に合わなかった・・裏でどうリンクさせているんだろうか・・

  4. きっとこんなことしなくても schema.graphql の内容から自動で組み立てられるはず・・

  5. 本記事ではコンソールへの表示まででしたが、受け取った Pre-Signed URL を <video> タグで表示する処理は hioki-daichi/juniper-rocket-sample-frontend に記載していますので、そちらをご覧ください。

10
5
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
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?