ファイルアップロード周りの処理を調べてたら、バックエンド側の実装も含め整理したくなったので(すぐ忘れそうだし)、ざっくり調べた内容をまとめてみた。尚、サーバサイドは、express(Node.js)の利用が前提。
POST データの授受
まず、ミドルウェアに頼らない素の express では、form タグで POST された データの処理をどう実装するものなのか。次のようなフォームがあった場合、
<form action="/simple_post" method="post">
<div>
name:
<input name="name" type="text" value="山田太郎" />
</div>
<button type="submit">send</button>
</form>
Node.js では着信データをストリーミングで受け付けるため、次のようにデータを組みたてて、はじめて完成された 1 つのデータとして扱える。
const port = process.env.PORT || 3000;
const app = express();
app.post("/simple_post", (req, res) => {
let buffer = "";
req.on("data", (chunk) => {
buffer += chunk;
});
req.on("end", function () {
console.log(buffer); // 送信されてきたデータが完成!
res.end("送信されました!");
});
});
app.listen(port, () => console.log(`localhost:${port}`));
組み立てられたbuffer
の値を見ると、 URL エンコードされた値になっていることが分かる。
name=%E5%B1%B1%E7%94%B0%E5%A4%AA%E9%83%8E
URL エンコードされるのは、form 要素のenctype
属性のデフォルト値がapplication/x-www-form-urlencoded
になっているためで、devTools のネットワークタブを見ても、同様の値が送信されていることが確認できる。
サーバ側でこの値を実際に使用する際は、qs
等でパースして json 化したりする。
console.log(qs.parse(buffer)); // { name: '山田太郎' }
着信データを組み立てるミドルウェア
body-parser
というミドルウェアを適用すると、着信データの組み立てと JSON 化を内部的に行い、request.body
プロパティに送信されたデータを格納してくれる。
import bodyParser from "body-parser";
app.use(bodyParser.urlencoded({ extended: true }));
app.post("/simple_post", (req, res) => {
console.log(req.body); // { name: '山田太郎' }
});
...というような記事がググるとでてくるが、このようにミドルウェアを使うのは昔の書き方で、express が v4.16.0 以降であればミドルウェアを使わずとも、次のように記述で同じことができる。
app.use(express.urlencoded({ extended: true }));
尚、上記のように記述すると、app 全体にミドルウェアが適用されてしまい「エントリポイントによっては、このミドルウェアは適用したくないんだけど...」って場合もある。そのような場合は、個別のエントリポイント用に生成したexpress
インスタンスにミドルウェアを適用し、そのインスタンスを app に割り当てればよい。
const simple_post1 = express();
simple_post1.post('/simple_post1', (req, res) => { ... });
app.use(simple_post1);
const simple_post2 = express();
simple_post2.use(express.urlencoded({ extended: true }));
simple_post2.post('/simple_post2', (req, res) => { ... });
app.use(simple_post2);
ファイル送信時のフォームとデータ構造
form 要素でファイルを送信したい場合は、input[type="file"]
でファイルを指定するが、それだけだとファイルの中身は送信されず、ファイル名のみが送信される。
name=%E5%B1%B1%E7%94%B0%E5%A4%AA%E9%83%8E&attached=cyokodog.jpg
// qsでパースすると以下のようになる
// { name: '山田太郎', attached: 'sample.txt' }
ファイルの中身を送信するには、enctype
属性にmultipart/form-data
の指定が必要。
<form action="..." method="post" enctype="multipart/form-data">
<div>
name:
<input name="name" type="text" value="山田太郎" />
<input name="attached" type="file" />
</div>
<button type="submit">send</button>
</form>
これは、複数種類のファイルの複合データを 1 回の HTTP 通信で送信することを意味し、リクエストヘッダーのContent-Type
にはmultipart/form-data
が設定され、同時に、ファイルの境界を示すデリミタ文字列がboundary
というキーで設定される。
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryxDl6b3uA61KGXyY6
リクエストボディにはboundary
キーの設定値で区切られた、複数のファイルが格納される。
------WebKitFormBoundaryxDl6b3uA61KGXyY6
Content-Disposition: form-data; name="name"
山田太郎
------WebKitFormBoundaryxDl6b3uA61KGXyY6
Content-Disposition: form-data; name="attached"; filename="sample.txt"
Content-Type: text/plain
sample.txtの中身
------WebKitFormBoundaryxDl6b3uA61KGXyY6--
boundary
の文字列で区切られた各エリアの中身を抜きせば、ファイルの中身も取得できる。
ファイルデータの参照
着信データはストリームで且つ、multipart/form-data
で送られる上記のようなデータはパースも面倒。そのため、Node.js に限らずだいたいどの言語でも、multipart/form-data
で送られたデータをシンプルに扱えるモジュールが存在する。
express の場合、次のようなミドルウェアがある。
- express-fileupload
- formidable
- multer
ざっと見た感じexpress-fileupload
が扱いやすそうだったので、これを使ってみる。
import fileUpload from "express-fileupload";
const multipart_post2 = express();
multipart_post2.use(fileUpload());
multipart_post2.post("/multipart_post2", (req, res) => {
console.log("req.body", req.body);
console.log("req.files", req.files);
res.end("ok!");
});
app.use(multipart_post2);
body-parser
同様にrequest.body
プロパティに送信データが json で格納され、type="file"
で指定されたファイルの情報はrequest.files
プロパティに格納される。
// request.body
{ name: '山田太郎' }
// request.files
{
attached: {
name: 'sample.txt',
data: <Buffer 73 61 6d 70 6c 65 2e 74 78 74 e3 81 ae e4 b8 ad e8 ba ab>,
size: 19,
encoding: '7bit',
tempFilePath: '',
truncated: false,
mimetype: 'text/plain',
md5: '723321fd83439a69951ee6cb5e348955',
mv: [Function: mv]
}
}
上記のrequest.files
にはattached
というキーがあるが、これは input 要素のname
属性の値が設定さている。そして、このattached
というキーにはUploadedFile
オブジェクトが 1 つ割り当てられている。
{
attached: { ... } // UploadedFile オブジェクト
}
もし、<input name="attached_multi" type="file" multiple />
のように、multiple
属性の指定により、複数のファイルがアップロードされた場合は、ファイル数分のUploadedFile
オブジェクトが配列形式で保持される構成になる。
例えば、次のようなフォームで複数ファイル指定して送信された場合、
<input name="attached" type="file" /><br />
<input name="attached_multi" type="file" multiple />
次のような構成になる。
{
attached: { ... }, // UploadedFile オブジェクト
attached_multi: [
{ ... }, // UploadedFile オブジェクト
{ ... }, // UploadedFile オブジェクト
{ ... }, // UploadedFile オブジェクト
],
}
ファイルの保存方法
ファイルの保存はUploadedFile
オブジェクトのmv
メソッドにパスを指定することで行える。ファイル名もname
プロパティで参照可能。
uploadedFile.mv(__dirname + '/' + uploadedFile.name)
前述のようにUploadedFile
オブジェクトは配列に格納されるケースもある。なのでファイルの保存完了を待機しレスポンスを返すのであれば、次のようなコードで実現できる。
const saveFile = (dir: string, uploadedFile: UploadedFile): Promise<void> => {
return new Promise(async (resolve) => {
const path = dir + "/" + uploadedFile.name;
await uploadedFile.mv(path);
resolve();
});
};
const saveFiles = (
dir: string,
fileArray: FileArray | undefined
): Promise<void[]> => {
if (!fileArray) {
return Promise.resolve([]);
}
let promises: Promise<void>[] = [];
for (let key in fileArray) {
const filesItem: UploadedFile | UploadedFile[] = fileArray[key];
const newPromises =
filesItem instanceof Array
? filesItem.map((uploadedFile) => saveFile(dir, uploadedFile))
: [saveFile(dir, filesItem)];
promises = [...promises, ...newPromises];
}
return Promise.all(promises);
};
const multipart_post2 = express();
multipart_post2.use(fileUpload());
multipart_post2.post("/multipart_post2", async (req, res) => {
await saveFiles(__dirname, req.files);
res.end("ok!");
});
app.use(multipart_post2);
ちなみにexpress-fileupload
は、busboy
というモジュールをラップして作られており、サイズ容量が大きくなりがちな動画ファイルを、busboy
を使いストリームで受信しつつ YouTube にアップロードする、なんてこともできるらしい。
単一ファイルのみのアップロード
multipart/form-data
はパースが面倒なのでミドルウェアを使ったけど、単一ファイルしかアップロードしない前提であれば、ミドルウェアを使わずともシンプルなコードで済ませられる。
ただし、この場合、フロント側は JS の利用が前提となり、任意のバイナリ形式を意味するapplication/octet-stream
をContent-Type
に指定し、バイナリで送信する必要がある。
例えば、次のように、送信データをBlob
でリクエストボディに設定し、その他のメタデータをquery
に指定すると、
const blob = new Blob(["this is text"], { type: "application/text" });
const query = new URLSearchParams({ fileName: "sample.txt" });
fetch(`/octet_stream_post?${query}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
},
body: blob,
});
バックエンドでは、express.raw()
を使い送信された内容をそのまま受け取り、query
に指定されたファイル名で保存することができる。
const octetStreamPost = express();
octetStreamPost.use(express.raw());
octetStreamPost.post("/octet_stream_post", async (req, res) => {
const uploadPath = __dirname + "/" + req.query.fileName;
await fs.writeFileSync(uploadPath, req.body);
res.send("end");
});
app.use(octetStreamPost);
ユーザが指定したファイルの送信
ユーザにより指定されたファイルは、File
オブジェクトで取得できる。例えば<input type="file">
で指定されたファイルは、FilesList
を経由しFile
オブジェクトとして参照できる。
const el = document.querySelector("input[type=file]");
if (!el || !el.files || !el.files.length) return;
const fileList: FileList = el.files;
const file: File = fileList[0];
そして、File
は、ファイル名等を保持するBlob
を継承したオブジェクトなため、Blob
と同様の扱いで送信できる。
const query = new URLSearchParams({ fileName: file.name });
fetch(`/octet_stream_post?${query}`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
},
body: file,
});
FormData によるアップロード
JS 経由のmultipart/form-data
による送信なら、FormData
でもアップロードできる。例えば<input type="file">
を含む form 要素が存在しているのであれば、その form 要素を引数にFormData
を生成し、リクエストボディに設定すれば良い。
const formEl = document.querySelector("form");
if (formEl) {
const formData = new FormData(formEl);
fetch("/multipart_post2", { method: "POST", body: formData });
}
また、fetch
は、Content-Type
の指定を省略すると自動判定で設定される。上記の場合であれば<input type="file">
がFormData
に含まれているので、boundary
付きのmultipart/form-data
が設定される。
multipart/form-data; boundary=----WebKitFormBoundaryHAGzEiHqQeFAJncA
もし、form
要素が存在しないなら、次のように任意のデータでFormData
を組み立てることもできる。
const fileEl = document.querySelector('input[type=file]') as HTMLInputElement;
if (fileEl && fileEl.files && fileEl.files.length) {
const formData = new FormData();
formData.append('name', 'taro yamada');
formData.append('attached', fileEl.files[0]);
fetch('/multipart_post2', {
method: 'POST',
body: formData,
});
}
ドラッグ&ドロップによるアップロード
アップロードしたいファイルをFile
オブジェクトで取得できれば、ここまで見てきた方法でアップロードできることがわかった。
ドラッグ&ドロップで掴んだファイルは、DragEvent.DataTransfer.FileList
を経由し参照できるので、次の実装でアップロードできる。
<div onDragover={onDragover} onDrop={onDrop}>
ここにファイルをドロップするとアップロードされます
</div>
const onDragover = (e: DragEvent) => {
e.stopPropagation();
e.preventDefault();
};
const onDrop = (e: DragEvent) => {
e.stopPropagation();
e.preventDefault();
const dataTransfer: DataTransfer | null = e.dataTransfer;
if (!dataTransfer) return;
const files: FileList = dataTransfer.files;
if (!files || !files.length) return;
const file: File = files[0];
const formData = new FormData();
formData.append("attached", file);
fetch("/multipart_post2", {
method: "POST",
body: formData,
});
};
クリップボードのファイルをアップロード
スクリーンショットや、Finder でのファイルのCMD+C
により、クリップボードに記録されたファイルは、DOM 上のpaste
イベントを通じClipboardEvent
から得られる。
ClipboardEvent
が保持するclipboardData
というプロパティの実態は、上述のDataTransfer
でにあたるため、ドラッグ&ドロップと同様に実装でFile
を参照し、アップロードすることができる。
<textarea
onPaste={onPaste}
placeholder="ファイルをペーストするとアップロードされます"
></textarea>
const onPaste = (e: ClipboardEvent) => {
const dataTransfer: DataTransfer | null = e.clipboardData;
if (!dataTransfer) return;
const files: FileList = dataTransfer.files;
if (!files || !files.length) return;
const file: File = files[0];
const formData = new FormData();
formData.append("attached", file);
fetch("/multipart_post2", {
method: "POST",
body: formData,
});
};
また、スクリーンショットをペーストした時のFile.name
は、image.png
の固定値になる。
アップロード前の画像のサイズを縮小したい場合
画像の大きさを一定サイズ以下に強制変換してアップロードしたい場合は、
- File -> Image -> Canvas(サイズ変更) -> Blob -> File
の順で変換することができる。
const justifyImageSizeAsync = (
file: File,
params: { maxWidth: number }
): Promise<File> => {
const { maxWidth } = params;
return new Promise((resolve) => {
if (!/^image\/(png|jpeg|jpg|bmp)$/.test(file.type)) {
resolve(file);
return;
}
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
const { naturalWidth, naturalHeight } = img;
if (naturalWidth < maxWidth) {
resolve(file);
return;
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
canvas.width = maxWidth;
canvas.height = (naturalHeight * canvas.width) / naturalWidth;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], file.name));
}
});
};
});
};
画像を base64 で送りたい場合
画像ファイルのやり取りをバイナリでしたくない場合は、base64 にして、JSON に包んで送信する方法もある。上述のように一旦、Canvas にしてるのであれば、canvas.toDataURL()
で base64 にできるが、特に加工が不要なのであればFileReader#readAsDataURL
でも変換できる。
const toBase64Async = (file: File): Promise<string> => {
return new Promise(resolve => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
const base64 = reader.result as string;
resolve(base64);
};
});
};
const sendByJson = async () => {
const el = document.querySelector('#sendByJson input[type=file]') as HTMLInputElement;
if (!el || !el.files || !el.files.length) return;
const fileList: FileList = el.files;
const file: File = fileList[0];
if (!/^image\/(png|jpeg|jpg|bmp)$/.test(file.type)) return;
const base64 = await toBase64Async(file);
fetch('/json_post', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ base64, fileName: file.name }),
});
};
バックエンド側では、サイズオーバーでリジェクトされないようにexpress.json({ limit: '10mb' })
のように上限サイズを大き目に設定しておく。また、画像ファイルをバイナリ形式で保存したい場合はBuffer.from
で変換すれば OK。
const json_post = express();
json_post.use(express.json({ limit: "10mb" }));
json_post.post("/json_post", async (req, res) => {
const match = req.body.base64.match(/data:image\/(.+);base64,(.+)/);
const [, _extension, base64] = match;
const buffer = Buffer.from(base64, "base64");
const uploadPath = __dirname + "/" + req.body.fileName;
await fs.writeFileSync(uploadPath, buffer);
res.json({ status: "ok" });
});
app.use(json_post);