『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 を開くと以下のように表示されます。
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>
}
}
これだけだと何も起こらないため、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)))
が出力されました。
以下の yew::html::ChangeData
の定義を見てみます。enum
になっています。コメントを読む限り、確かにファイルフィールドの時には Files(FileList)
が取得できそうです。
/// 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
フィールドの type
が file
なら ChangeData::Files
としているようです。
// 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 を実装するように定義されています。
その Item
の File
は name にアクセスできるようでした。
https://github.com/koute/stdweb/blob/8049357b33d49b306ae04a75ecc9a11a41f28b67/src/webapi/file.rs
さて、次からはこの stdweb の File
を使うため、if let
と for
で取り出しておきます。
--- 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. ReaderService
で File
を読み込む
前回の stdweb の File
を ReaderService
の fn 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 が出力されるようになりました。
さて、読み込んだデータを表示するようにしてみましょう。ComponentLink
3 の fn 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>
型でファイルの内容が表示されます。
4. 読み込んだ FileData の content を Base64 エンコードして FetchService
で送信する
読み込んだファイルの内容を FetchService
の fetch
メソッドで送信していきます。
その前に、今回は JSON で送るため、他にもやり方はいろいろあるのでしょうが、いったん Base64 エンコードしてから送ります。
まずは Cargo.toml
に base64 = "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 内の処理が呼ばれます。内容としては以下です。
- 渡された key と data をもとにして S3 に PutObject する。
- 渡された key を持つようなレコードを videos テーブルに登録する。
- 登録したファイルに対して Pre-Signed URL を発行した情報を返す。
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 を使っています。
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 を使っています。
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
を用意します。
また、ReaderService
の read_file
と同様に FetchService
の fetch
メソッドも第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
以上です、おつかれさまでした!!
雑感
- フロントエンドなのにコードを潜っても 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 なシンタックスもまったく違和感なかった。
-
コード内では
use yew::prelude::*;
しているためyew::html::Change
ではなくChangeData
で呼べます。(yew/src/lib.rs#L154, yew/src/lib.rs#L95-L96) ↩ -
このひと手間が必要な理由、『Model の変更を検知して View をレンダリングする』という仕組み上、いったん Model にタスクを突っ込んでおいてコールバックが呼ばれたらタスクを取り除くことで Model に変更を加えて View を再びレンダリングさせたいからなんだろうか・・ ↩
-
ComponentLink
の仕組みもうちょっと調べたかった・・間に合わなかった・・裏でどうリンクさせているんだろうか・・ ↩ -
きっとこんなことしなくても
schema.graphql
の内容から自動で組み立てられるはず・・ ↩ -
本記事ではコンソールへの表示まででしたが、受け取った Pre-Signed URL を
<video>
タグで表示する処理は hioki-daichi/juniper-rocket-sample-frontend に記載していますので、そちらをご覧ください。 ↩