🏹

Next.jsで、formidableを使ってGoogle Cloud Storageに画像ファイルをストリーミングアップロードする

2022/08/22に公開

Next.jsを使ったWebアプリについて、FormDataでリクエストした画像をクラウドストレージにアップロードしたいことがあります。しかしながら、クライアント → APIサーバ → クラウドストレージと都度アップロードするのは余分な通信が発生してしまいます。

そこで、node-formidable/formidableライブラリを使って、Google Cloud Storage(GCS)へのストリーミングアップロードに対応してみました。

前提

Node.jsのStream APIを使用するので、サーバサイド上でのみ利用可能です。

全体フロー図

ポイントは以下3点です。

  1. 画像アップロードAPIのみ、リクエストの自動パースは切っておく
  2. リクエストのパース前に、GCSのクライアントライブラリよりWritableを得ておく
  3. 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定義

/pages/api/images/new.ts
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操作部(インタフェース定義)

/repositories/images/interface.ts
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操作部(実装)

/repositories/images/index.ts
// 以下、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側でのリクエストの自動パースをオフにします。

/pages/api/images/new.ts
export const config = {
  api: {
    bodyParser: false,
  },
}

もしオフにしない場合、formidableにリクエストデータが流れてこないのでパースが行われず、後述のパース完了時のコールバックが呼ばれません。別途タイムアウトを指定しない限り、APIがレスポンスを返さず動いたままの状態になってしまいます。

2. GCSのクライアントライブラリより、Writableストリームを得る

以下コードのgetUploadWriteStream()が該当します。通常GCSを使うときと同様にアップロード先のファイル参照を作っておきます。bucket.file(...)で得られるのがFileオブジェクトのため、createWriteStream()によりWritableストリームが得られます。

/repositories/images/index.ts
  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ストリームは直に渡すことはできません。

/pages/api/images/new.ts
  // writableはstream.Writable型の変数
  const form = formidable({
    fileWriteStreamHandler: () => writable,
  });

4. formidableを使って、リクエストをパース+GCSへのストリーミングアップロードを行う

3.でストリームのつなぎこみはできているので、後はリクエストをパースすればOKです。自動的にGCSへ画像がアップロードされます。

formidableにおいて、リクエストのパースはparse関数を使います。第二引数にパース完了後のcallback関数を指定するのですが、結果をawaitで受けとりたいのでpromise化しています。

/api/images/new.ts
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
/api/images/new.ts
    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になり処理方法が変わります。

/api/images/new.ts
    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型を定義しています。

/pages/api/images/new.ts
  // 6. GCS上にアップロードした画像に対し、metadataを追加する
  await imageRepository.setMetaData(id, {
    cacheControl: "public,max-age=1800",
    contentType: mimetype,
  });
/repositories/images/interface.ts
export type ImageMetadata = {
  cacheControl?: string;
  contentType?: string;
};
/repositories/images/index.ts
  public async setMetaData(id: string, metadata: ImageMetadata): Promise<void> {
    const bucket = getStorage().bucket();
    const file = bucket.file(`images/${id}`);
    file.setMetadata(metadata);
  }

以上となります。

脚注
  1. formidables.Fileから取得できる情報の一覧は公式README.mdをご覧ください。 - https://github.com/node-formidable/formidable#file ↩︎

  2. 追加できるメタデータは公式リファレンスを参照してください。 - https://cloud.google.com/storage/docs/gsutil/addlhelp/WorkingWithObjectMetadata?hl=ja ↩︎

Discussion