アップロード処理の周辺実装のざっくりまとめ

ファイルアップロード周りの処理を調べてたら、バックエンド側の実装も含め整理したくなったので(すぐ忘れそうだし)、ざっくり調べた内容をまとめてみた。尚、サーバサイドは、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-streamContent-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);