Posted at

React + fetch APIでFileをUploadする

More than 1 year has passed since last update.


記事の目的

だいたいのWebアプリケーションにはファイルのUploadがあります。一昔前は普通のpostリクエストにファイルをくっつけて送っていたと思いますが、最近ではSPA化の流れでajaxでのUploadがよく使われるかと思います。

このとき、とりあえずReactやfetch APIでググッて見つけたものをコピペすると実装できるものの、応用の効かない知識になってしまうと思ったので、簡単にフロント / サーバーでどう処理されているかを見ていこうと思います。


formでのpostの場合

上記で言うところのajaxでない昔ながらのやり方ですね。

このようなhtmlを用意します。

<html>

<body>
<form method="post" action="/upload" enctype="multipart/form-data">
<input type="file" name="myFile">
<p>
<input type="submit">
</p>
</form>

</body>
</html>

これでファイルを選択してsubmitボタンを押すと、 /upload にファイルが送られるのは問題ないでしょうか?

これをChromeのDevTools→Networkで見てみると、おおかたこんな感じで表示されます。

▼ Request headers

Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryNubG2JDdL7cC8HZA

▼ Request payload
------WebKitFormBoundaryNubG2JDdL7cC8HZA
Content-Disposition: form-data; name="myFile"; filename="90b2f2bbdf58300c.gif"
Content-Type: image/gif

------WebKitFormBoundaryNubG2JDdL7cC8HZA--

ここで、uploadリクエストはContent-Type:multipart/form-dataで来ていること、

uploadされるファイルに対して、name, filename, Content-Typeというパラメータが付いてくることがわかります。

(ここで、ファイルの名前はnameでなくfilenameであり、nameというパラメータはmultipartでのリクエストにおける各ファイルの識別子であることに注意してください。後で何度も出てきます。)


ajaxの場合

ここでようやくReact + fetch APIでの実装例をお見せします。

(なお、リポジトリはこちらです。-> https://github.com/uryyyyyyy/react-redux-sample/tree/fileUpload


Component側


Component.tsx

//①

handleChangeFile(e: any) {
const target: HTMLInputElement = e.target as HTMLInputElement;
const file: File = target.files.item(0);
updateFile(this.props.dispatch, file);
}

...

//②
<input type="file" onChange={(e) => this.handleChangeFile(e)}/>
//③
<button onClick={() => uploadFile(this.props.dispatch, this.props.value.file)}>upload</button>


ここでは、ファイル指定用のinputタグ(②)と、upload用のbuttonタグ(③)を記載しています。

ここで②に指定されるファイルが変わるたびにonChangeの中で①が呼ばれるのですが、ここではHTMLInputElementにあるであろうファイルオブジェクトを取得してきます(ここでfiles.item(0)と書いているのは、inputタグは複数ファイルの指定ができるためです。)

③のuploadボタンについては後ほど触れます。


Action側

export default function reducer(state: CounterState = initialState, action: MyAction): CounterState {

switch (action.type) {
case UPDATE_FILE: {
return Object.assign({}, state, {file: action.file});
}

/*...*/

default:
return state
}
}

//①
export function updateFile(dispatch: Dispatch<any>, file: File) {
dispatch({ type: UPDATE_FILE, file: file})
}

//②
export function uploadFile(dispatch: Dispatch<any>, file: File) {
const failCB = (err: Error) => {
console.error(err);
dispatch({type: FETCH_FAIL, error: err})
};

const successCB:(response: IResponse) => Promise<void> = (response) => {
if(response.status === 200){ //2xx
return response.json<any>().then((json) => {
console.log(json);
});
}else{
dispatch({type: FETCH_FAIL, error: response.status})
}
};

//③
const formData = new FormData();
formData.append('myFile', file);

//④
return fetch('/api/upload', {method: 'POST', body: formData})
.then(successCB)
.catch(failCB);
}

ここで、①は先程のinputタグの挙動ですが、Fileオブジェクトの変更を通知するだけです。

②ではuploadボタンが押された時の挙動を示しています。順番に見ていくと、


  1. ③で、fetchで送ろうとしているFormデータオブジェクトを作成する。

  2. 送信対象のFileオブジェクト(onChangeで取得してきたもの)をmyFileという名前で登録する。

  3. ④で先ほどのformDataをbodyに付けて送信する。

といった流れになります。これで、上述のformでの送信と同じリクエストがajaxで送られることになります。


サーバー側でどう受けるか

フロントとしては上記で完了ですが、サーバー側も見ていきましょう。ファイルuploadを理解するにはサーバー側の理解も必要です。

とはいえこちらはサーバーの言語やフレームワークによって処理の仕方が異なるので、ここでは僕がフロント開発時のモックサーバーとして利用しているexpressと、実運用で利用しているPlay Frameworkを例に上げて説明します。各ライブラリの使い方に留まるつもりはなく、サーバー側でどのように扱っているのかの一般的なやり方を説明します。

以下の例では、uploadされたファイルをカレントディレクトリに保存してみることにします。


expressの場合


dev-server.js

const fs = require('fs');

const express = require('express');
const app = express();

//①
const multer = require('multer');
const upload = multer({ dest: './uploads/' });

//②
app.post('/api/upload', upload.fields([ { name: 'myFile' } ]), (req, res) => {
//③
const myFile = req.files.myFile[0];
const tmp_path = myFile.path;
const target_path = './' + myFile.originalname;
//④
fs.rename(tmp_path, target_path, (err) => {
if (err) throw err;
//⑤
fs.unlink(tmp_path, () => {
if (err) throw err;
res.json({message: 'File uploaded to: ' + target_path + ' - ' + myFile.size + ' bytes'});
});
});
});

app.listen(3000, (err) => {
if (err) {
console.log(err);
}
console.log("server start at port 3000")
});


さて、expressの場合は実はそのままでは動かないので拡張モジュールを入れる必要があります。色々あるようなのですが、express公式で出しているmulterを組み込みます。multerの説明は省略するのでご了承ください。

①で必要なモジュールを読み込み、さらにmulterの場合はuploadされたデータの一時置き場をしていることになっています。ここでは./uploads/に置くようにしています。

②で、/api/uploadのエンドポイントにpostリクエストが来たら以下の処理をするようにきじゅつしています。ここで先程のmulterを利用して、uploadされたファイルにつく名前(識別子であり、filenameではない)がmyFileとなっているものを一時フォルダに保存しています。

③では、uploadされたファイル(実体は一時フォルダに置かれている)を扱っています。ここでreq.files.myFile[0]とありますが、myFileは先ほど指定した識別子です。配列になっているのは、送り方によっては複数ファイルに同名の識別子が付く場合があるからです。

例:

▼ Request headers

Content-Type:multipart/form-data; boundary=----WebKitFormBoundaryNubG2JDdL7cC8HZA

▼ Request payload
------WebKitFormBoundaryNubG2JDdL7cC8HZA
Content-Disposition: form-data; name="myFile"; filename="90b2f2bbdf58300c.gif"
Content-Type: image/gif

------WebKitFormBoundaryNubG2JDdL7cC8HZA
Content-Disposition: form-data; name="myFile"; filename="sample.txt"
Content-Type: plain/text

------WebKitFormBoundaryNubG2JDdL7cC8HZA--

④では一時ファイルをtargetのパスに置いていて、⑤で一時ファイルを消しています。

ここまでの処理が正常終了したらレスポンスを返します。


Playサーバーの場合

こちらはscalaの例です。(scalaに興味がない方は次の「サーバー側での一般的な処理の仕方」へ移ってもらっても構いません)


MyController.scala

  //①

def upload = Action(parse.multipartFormData) { request =>
//②
request.body.file("myFile").map { file =>
val contentType = file.contentType.get
Logger.logger.info(contentType)
val target = new File(s"./${file.filename}")
//③
file.ref.moveTo(target, replace = true)
Ok("File uploaded")
}.getOrElse {
Ok("nothing")
}
}

まず①で、このリクエストはmultipartFormDataを処理するためのものと記述します。

こうすることで以降でfileを扱えるようになります。(おそらくここでuploadされてきたファイルを永続化している)

②でmyFileという識別子を持ったファイルが送られてきたかを確認します。もしあった場合は以下の処理を続けることになり、もしなければOk("nothing")を返して終わりです。(ここでexpressの例を思い出して欲しいのですが、こちらは配列でなく普通のオブジェクトです。つまり同じ識別子に複数ファイルが送られてくることを想定していないことになります。まぁ.file(<key>)でなくfiles()というAPIを使えば可能なのですが。。)

③では、myFileの中身(実体は一時ファイル外して保存されている)をtargetにmoveしています。これで一時ファイルは消えて、カレントディレクトリにファイルが置かれます。


サーバー側での一般的な処理の仕方

上2つを見てみて気づくこととして、


  • multipartで送られてくるファイルにはnameという識別子がついてきて、それを元に処理を行う。

  • ファイルは最初に一時ファイルとして保存される。


    • (これは重いデータを扱うときにメモリ領域を専有しないように都度HDDに逃がしているのではと想定しています。)



  • 同名のname識別子で複数ファイルを扱うことも可能。

という感じになります。


まとめ

ファイルの扱いを改めて見てみましたがあまり難しくないですね。

ReactでのSPAアプリ作成がさらに便利になれば幸いです。