Next.jsで、formidableを使ってGoogle Cloud Storageに画像ファイルをストリーミングアップロードする
Next.jsを使ったWebアプリについて、FormDataでリクエストした画像をクラウドストレージにアップロードしたいことがあります。しかしながら、クライアント → APIサーバ → クラウドストレージと都度アップロードするのは余分な通信が発生してしまいます。
そこで、node-formidable/formidableライブラリを使って、Google Cloud Storage(GCS)へのストリーミングアップロードに対応してみました。
前提
Node.jsのStream APIを使用するので、サーバサイド上でのみ利用可能です。
全体フロー図
ポイントは以下3点です。
- 画像アップロードAPIのみ、リクエストの自動パースは切っておく
- リクエストのパース前に、GCSのクライアントライブラリよりWritableを得ておく
- formidableの初期化時、options.fileWriteStreamHandlerで上記Writableを返すようにする
1.について、Next.jsではデフォルトでリクエストをパースし、そこからreq.query、req.bodyでリクエストパラメータを取得できます。しかし、v12.2.5現在でformDataのパースに対応していません。そこで、当該APIを定義するファイルにてconfigを定義し、その中で{ api: { bodyParser: false } }
を指定します。
2.について、bucket.file(...)
からFileオブジェクトが得られます。このFileオブジェクトはcreateWriteStream()
を持っており、この関数より書き込み用のストリームを取得できます。
3.について、formidableはデフォルトでパースしたデータをローカルファイルシステム上に書き込みます。代わりにoptions.fileWriteStreamHandlerを指定すると、Writableで指定した先にパースしたデータを書き込むようにできます。
ステップごとの具体処理
以下、コード全体になります。本コードでは、API処理部分とGCSへのアクセス部分を分けた構成にしています。
- API定義部: /pages/api/images/new.ts
- 画像関連のGCS操作部: /repositories/images/index.ts, /repositories/images/interface.ts
API定義
import type { NextApiRequest, NextApiResponse } from "next";
import { formidable, File as FormidableFile } from "formidable";
import imageRepository from "../../../repositories/image/firebase";
import { Writable } from "stream";
// 1. Next.js側によるリクエストの自動パースをOFFにする
export const config = {
api: {
bodyParser: false,
},
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!req.headers["content-type"]?.includes("multipart/form-data")) {
res.status(400).send(undefined);
return;
}
// 2. GCSのクライアントライブラリより、Writableストリームを得る
const { id, writable } = await imageRepository.getUploadWriteStream();
// 3.~5.の処理
const { err, mimetype } = await uploadImageByStream(writable, req);
if (err) {
res.status(500).send(undefined);
return;
}
// 6. GCS上にアップロードした画像に対し、metadataを追加する
await imageRepository.setMetaData(id, {
cacheControl: "public,max-age=1800",
contentType: mimetype,
});
// リクエスト元に対し、生成した画像のアクセスIDを返す
res.status(200).json({ id });
};
const uploadImageByStream = async (
writable: Writable,
req: NextApiRequest
): Promise<{
mimetype: string;
err?: Error;
}> => {
// 3. formidableの初期化時、1.で得たWritableストリームを渡す
const form = formidable({
fileWriteStreamHandler: () => writable,
});
return new Promise((resolve, reject) => {
// リクエストのパースが終わらなかった場合のタイムアウト処理を定義
const timeoutId = setTimeout(() => {
reject(new Error("upload timeout"));
}, 60_000);
// 4. リクエストをパース+GCSへのストリーミングアップロードを行う
form.parse(req, (err, _, files) => {
clearTimeout(timeoutId);
if (err) {
reject(err);
}
// 5. 処理完了後、mimetypeなどの画像パラメータを得る
// 本コードでは、formDataの"imageFile"に画像ファイルが指定された想定
if (!Object.hasOwn(files, "imageFile")) {
reject(new Error("imageFile not specified."));
}
// 画像は一つしか来ないとしてキャストしている
// (実運用上はFormidableFile | FormidableFile[]の両方をハンドリングすべき)
const { mimetype } = files.imageFile as FormidableFile;
resolve({
mimetype: mimetype ?? "application/octet-stream",
err,
});
});
});
};
export default handler;
画像関連のGCS操作部(インタフェース定義)
import { Writable } from "stream";
export type ImageUploading = {
id: string;
writable: Writable
};
// Firebase Admin SDK上でメタデータの型(Metadata)は
// anyで定義されているため、有効なパラメータを型定義している
export type ImageMetadata = {
cacheControl?: string;
contentType?: string;
};
export interface IImageRepository {
getUploadWriteStream(): Promise<ImageUploading>;
setMetaData(id: string, metadata: ImageMetadata): Promise<void>;
}
画像関連のGCS操作部(実装)
// 以下、Firebase Admin SDKのCloud Storageのケースで記述している。SDKの初期化については割愛している。
// Cloud Storage client libraryの場合、import {Storage} from "@google-cloud/storage"; などとする
import { getStorage } from "firebase-admin/storage";
import { IImageRepository, ImageMetadata, ImageUploading } from "./interface";
class ImageRepository implements IImageRepository {
private issueImageId(): string {
/* 画像IDを発行する処理 */
}
// 2. GCSのクライアントライブラリより、Writableストリームを得る
public async getUploadWriteStream(): Promise<ImageUploading> {
const id = this.issueImageId();
// Cloud Storage client libraryの場合、new Storage().bucket();が等価
const bucket = getStorage().bucket();
// 画像の保存パスは適宜修正すること
const uploadFile = bucket.file(`images/${id}`);
const writable = uploadFile.createWriteStream();
return {
id,
writable,
};
}
// 6. GCS上にアップロードした画像に対し、metadataを追加する
public async setMetaData(id: string, metadata: ImageMetadata): Promise<void> {
const bucket = getStorage().bucket();
const file = bucket.file(`images/${id}`);
file.setMetadata(metadata);
}
}
const imageRepository: IImageRepository = new ImageRepository();
export default imageRepository;
1. Next.js側によるリクエストの自動パースをOFFにする
formidableでリクエストをパースできるよう、Next.js側でのリクエストの自動パースをオフにします。
export const config = {
api: {
bodyParser: false,
},
}
もしオフにしない場合、formidableにリクエストデータが流れてこないのでパースが行われず、後述のパース完了時のコールバックが呼ばれません。別途タイムアウトを指定しない限り、APIがレスポンスを返さず動いたままの状態になってしまいます。
2. GCSのクライアントライブラリより、Writableストリームを得る
以下コードのgetUploadWriteStream()
が該当します。通常GCSを使うときと同様にアップロード先のファイル参照を作っておきます。bucket.file(...)
で得られるのがFileオブジェクトのため、createWriteStream()
によりWritableストリームが得られます。
public async getUploadWriteStream(): Promise<ImageUploading> {
const id = this.issueImageId();
// Cloud Storage client libraryの場合、new Storage().bucket();が等価
const bucket = getStorage().bucket();
// 画像の保存パスは適宜修正すること
const uploadFile = bucket.file(`images/${id}`);
const writable = uploadFile.createWriteStream();
return {
id,
writable,
};
}
3. formidableの初期化時、options.fileWriteStreamHandlerに1.で得たWritableストリームを返す関数を渡す
以下コードの通りとなります。なお、options.fileWriteStreamHandler
は() => Writable
型なので、Writableストリームは直に渡すことはできません。
// writableはstream.Writable型の変数
const form = formidable({
fileWriteStreamHandler: () => writable,
});
4. formidableを使って、リクエストをパース+GCSへのストリーミングアップロードを行う
3.でストリームのつなぎこみはできているので、後はリクエストをパースすればOKです。自動的にGCSへ画像がアップロードされます。
formidableにおいて、リクエストのパースはparse関数を使います。第二引数にパース完了後のcallback関数を指定するのですが、結果をawaitで受けとりたいのでpromise化しています。
const uploadImageByStream = async (/* 中略 */)<{/* 中略 */}> => {
const form = formidable({
fileWriteStreamHandler: () => writable,
});
return new Promise((resolve, reject) => {
// 中略
form.parse(req, (err, _, files) => {
// 中略
});
});
};
5. 処理完了後、mimetypeなどの画像パラメータを得る
parse関数におけるcallback関数では、第一引数に処理成否(err, エラーがあれば非null)、第二引数にformDataで指定したフィールド一式(fields)、第三引数にformDataで指定したファイル一式(files)が入ります。
Formidable v2の場合
例えば、formDataの"imageFile"に画像ファイルを指定した場合、 files.imageFileで参照できます。ただし、その型はFormidableFile | FormidableFile[]
になるので、型ガードを使って判別する必要があります(本例では説明上の都合で割愛しています)。
FormidableFile型まで型推論できれば、以下の情報などが得られます[1]。
- size(number): ファイルのバイト数
- originalFilename(string): アップロード元でのファイル名
- mimetype(string): アップロード元でのmimetype
form.parse(req, (err, _, files) => {
// 中略
// 本コードでは、formDataの"imageFile"に画像ファイルが指定された想定
if (!Object.hasOwn(files, "imageFile")) {
reject(new Error("imageFile is not specified."));
}
// 画像は一つしか来ないとしてキャストしている
// (実運用上はFormidableFile | FormidableFile[]の両方をハンドリングすべき)
const { mimetype } = files.imageFile as FormidableFile;
resolve({
mimetype: mimetype ?? "application/octet-stream",
err,
});
});
Formidable v3の場合
バージョン3.xの型定義において、filesの型がformidable.Files<T>
に変わりました。この関係で、filesから得られる値がformidable.File[] | undefined
になり処理方法が変わります。
form.parse(req, (err, _, files) => {
// 中略
const imageFiles = files["imageFile"];
// 画像は一つしか来ないとしてキャストしている
// (実運用上は画像がない、もしくは複数画像がある場合をハンドリングすべき)
if (imageFiles && imageFiles[0]) {
const { mimetype } = imageFiles[0];
resolve({
mimetype: mimetype ?? "application/octet-stream",
err,
});
}
reject(new Error("invalid image"));
6. GCS上にアップロードした画像に対し、metadataを追加する
5.で得られた画像の情報を基に、GCS上の画像にmetadataを付与します[2]。なお、公式ライブラリでmetadataの型はanyで定義されています。プロパティ名の指定ミスを防ぐため、インタフェース部分でImageMetadata型を定義しています。
// 6. GCS上にアップロードした画像に対し、metadataを追加する
await imageRepository.setMetaData(id, {
cacheControl: "public,max-age=1800",
contentType: mimetype,
});
export type ImageMetadata = {
cacheControl?: string;
contentType?: string;
};
public async setMetaData(id: string, metadata: ImageMetadata): Promise<void> {
const bucket = getStorage().bucket();
const file = bucket.file(`images/${id}`);
file.setMetadata(metadata);
}
以上となります。
-
formidables.Fileから取得できる情報の一覧は公式README.mdをご覧ください。 - https://github.com/node-formidable/formidable#file ↩︎
-
追加できるメタデータは公式リファレンスを参照してください。 - https://cloud.google.com/storage/docs/gsutil/addlhelp/WorkingWithObjectMetadata?hl=ja ↩︎
Discussion