見出し画像

Nest.jsでmultipart/form-dataとうまく付き合う【後編】

はじめに

こんにちは、LaKeel Product Groupの土屋です。
前回の投稿からかなり間が空いてしまいました。
後編である本記事では、前回取り上げたNest.jsやmultipart/form-dataの基礎を踏まえた実装方法をご紹介します。
今回は特にファイルアップロード系の機能にフォーカスして、Nest.jsの高い柔軟性を活かしたサンプルコードとともに、具体的な実装方法をお伝えしてまいります。
みなさまの開発にお役立ていただければ幸いです。


1. 基本的なmultipart/form-dataの扱い方

以下は、リクエストされたmultipart/form-dataを処理するサンプルコードです。

import { Controller, Post, UploadedFile, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { AnyDto } from "./AnyDto";
@Controller('file')
export default class FileController {
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo'))
    upload(@UploadedFile() file: Express.Multer.File, @Body() body: AnyDto): void {
        // ファイル本体はこちら
        console.log(file);
        // ファイル以外のフィールドはこちら
        console.log(body);
    }
}

この例では、Nest.jsのFileInterceptorを利用してリクエストを処理しています。
ポイントは以下の4点です。

  • ControllerのメソッドにUserInterceptorを設定し、引数にFileInterceptorを渡します

  • FileInterceptorの第一引数には、リクエストFormDataのファイル本体のフィールド名を指定します

  • `@UploadedFile()`が指定された引数にファイル本体が引き渡されます

  • `@Body()`が指定された引数にファイル以外のフィールドが引き渡されます

このFileInterceptorは、単一ファイルのアップロードに特化しています。
次の章ではNest.jsで利用可能なその他のInterceptorをご紹介します。

2. 利用可能なInterceptorとその機能

2-1. 各Interceptorの比較

Nest.jsでは、アップロードされたファイルを処理するためのInterceptorが5つ提供されています。
いずれも非常に便利ですが、それぞれの特徴や引数に設定するデコレータの組み合わせが少しややこしいので、表にまとめました。

Interceptorの比較

FileInterceptorでは@UploadedFile()を、それ以外では@UploadedFiles()を、それぞれ引数に設定する必要があります。
型推論が効かず、特にハマりやすいポイントになりますのでお気をつけください。
(実際に私もハマって、30分くらい頭を悩ませたことがあります…。)
以下に簡単なサンプルコードを載せます。

  • NoFilesInterceptor

// ファイル受信不可
@UseInterceptors(NoFilesInterceptor())
upload(@Body body) {
    console.log(body);
}
  • FileInterceptor

// フィールド名fooでファイル受信
@UseInterceptors(FileInterceptor('foo'))
upload(@UploadedFile() file) {
    console.log(file.originalname);
}
  • FilesInterceptor

// フィールド名fooでファイル受信(123件まで)
@UseInterceptors(FilesInterceptor('foo', 123))
upload(@UploadedFiles() files[]) {
    console.log(files.map((file)=>file.originalname))
}
  • AnyFilesInterceptor

// 任意のフィールド名でファイル受信
@UseInterceptors(AnyFilesInterceptor())
upload(@UploadedFiles() files[]) {
    console.log(files.map((file)=>file.originalname))
}
  • FileFieldsInterceptor

// フィールド名foo(2件まで)、bar(3件まで)でファイル受信
@UseInterceptors(FileFieldsInterceptor([{ name: 'foo', maxCount: 2, }, { name: 'bar', maxCount: 3 }]))
upload(@UploadedFiles() files: { foo?: Express.Multer.File[], bar?: Express.Multer.File[] }) {
    console.log(files?.foo?.map((file) => file.originalname));
}

2-2. その他オプション

上記のInterceptorは、内部でmulterを使用しています。
multerというのは、multipart/form-data形式のリクエストを扱うNode.jsパッケージです。
各Interceptorを呼び出す際、multerのオプションを使用できます。
主なオプションを使用したサンプルを以下に示します。

@UseInterceptors(new FileInterceptor('file', {
    // ファイルをストレージに保存する場合
    storage: multer.diskStorage({
        destination: (_req, _file, cb) => { cb(null, './foo'); }, // 保存先のパス
        filename: (_req, _file, cb) => { cb(null, 'filename.txt') } // 保存先のファイル名
    }),
    // ファイルをメモリに格納する場合
    storage: multer.memoryStorage(),
    // ファイルやフィールドに対する制限
    limits: {
        files: 1,          // 最大ファイル数
        fileSize: 1048576, // ファイルの最大サイズ(Byte、デフォルト: 無限)
    },
    // 処理対象ファイルのハンドリング
    fileFilter: (_req, file, cb) => {
        try {
            if(file.originalname === 'foo.txt') {
                cb(null, true);  // 処理対象とする場合
            } else {
                cb(null, false); // 処理対象外とする場合
            }
        } catch(error) {
            cb(error, false);    // 処理中に何らかのエラーが発生した場合
        }
    }
}))

各オプションの詳細が知りたい方は、multerのreadmeをご確認ください。

3. よくある課題と対処法

ここからは、ファイルを扱う上で直面することの多い課題と対処法を、ここまでの内容を踏まえてお伝えしていきます。

3-1. ファイルサイズが大きいとき

上述の各Interceptorのデフォルト設定では、受け取ったファイルをメモリに展開します。
巨大なファイルを受信する場合、その分メモリの容量を多めに確保しておく必要があり、コスト面でデメリットが大きいです。
そこでこの章では、メモリを節約しつつ、巨大なファイルに対処する方法を3つご紹介いたします。

①受け取れるファイルサイズを制限する

受取可能なファイルサイズを制限します。
セキュリティ的な観点でも重要なため、恐らく一番最初に検討することが多いかと思います。
Nest.jsでは、ParseFilePipeかmulterオプションを使ってファイルサイズを制限できます。

  • ParseFilePipe

ParseFilePipeというPipeを使うことで、ファイルサイズに対してバリデーションをかけることができます。
特にエラー時のレスポンスを変えたい場合、こちらのParseFilePipeを利用すると便利です。

@Controller('file')
export default class FileController {
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo'))
    upload(
      @UploadedFile(
          new ParseFilePipe({
              validators: [
                  // 1MByteを超える場合400/BadRequestで'File size is too large.'をレスポンス
                  new MaxFileSizeValidator({
                    maxSize: 1048576,
                    message: 'File size is too large.',
                  }),
              ],
              errorHttpStatusCode: HttpStatus.BAD_REQUEST
          }),
      ) file: Express.Multer.File,
    ): void {
        console.log(file);
    }
}
  • multerオプション

multerオプションのlimits.fileSizeに処理可能な最大Byte数を指定することで、バリデーションをかけることができます。

@Controller('file')
export default class FileController {
    // 1MByteを超える場合413/PayloadTooLargeで'File too large'をレスポンス
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo', { limits: { fileSize: 1048576 }}))
    upload(@UploadedFile() file: Express.Multer.File): void {
        console.log(file);
    }
}

②ディスクに保存する

サイズの大きなファイルを扱う場合、ディスクに一時保存してから実際の処理を開始することも多いかと思います。
以下は、ディスクにファイルを保存した後、202/Acceptedを返すサンプルです。
この例ではfileにファイル本体が格納されないのでご注意ください。

@Controller('file')
export default class FileController {
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo', {
        storage: multer.diskStorage({
          destination: (_req, _file, dest) => {
              dest(null, './foo');
          },
          filename: (_req, _file, name) => {
              name(null, 'bar.txt');
          }
        })
    }))
    upload(
        @UploadedFile() file: Express.Multer.File,
        @Res() res: Response,
    ): void {
        res.status(HttpStatus.ACCEPTED).json({ message: 'accepted' });
        // ↓↓レスポンス後の処理↓↓
    }
}

③streamで処理する

ファイルの圧縮やクラウドストレージへのアップロードなど、サイズの大きなファイルに対して何かしらの処理を施したい要件も多いかと思います。
該当の処理がstreamに対応している場合、ファイルを受け取りながら処理することも可能です。
実現方法は非常に多いのですが、ここではmulterのストレージエンジンをカスタマイズする方法と、独自のInterceptorを作成する方法の2つをご紹介します。

  • multerのストレージエンジンをカスタマイズする

既にファイルが抽象化されているので扱いが簡単です。
また、S3へアップロードするような頻出の要件でしたら、Multer S3などnpmで公開されているストレージエンジンの活用も選択肢に入ります。
以下はWriteStreamでファイルを保存する例です。

import * as crypto from 'crypto';
import { type Request } from "express";
import { createWriteStream, unlink } from "fs";
import { type StorageEngine } from 'multer';
import path from "path";

class CustomStorageEngine implements StorageEngine {
    constructor(
        protected uploadDirectory = './uploads',
        protected fileName = 'test-file-' + crypto.randomUUID() + '.txt',
        protected highWaterMark = 1024,
    ) { }

    /** 受け取ったファイルの保存 */
    _handleFile(_req: Request, file: Express.Multer.File, handle: (error?: Error, info?: Partial<Express.Multer.File>) => void): void {
        const saveTo = path.join(__dirname, this.uploadDirectory, path.basename(this.fileName));
        const writeStream = createWriteStream(saveTo, { highWaterMark: this.highWaterMark });
        file.stream.pipe(writeStream);

        writeStream
            .on('close', () => { handle(undefined, file) })
            .on('error', (err) => { handle(err, file) });
    }

    /** 保存に失敗したファイルの削除 */
    _removeFile(_req: Request, file: Express.Multer.File, handle: (error: Error | null) => void): void {
        const deleteTo = path.join(__dirname, this.uploadDirectory, path.basename(this.fileName));
        unlink(deleteTo, (err) => {
            if (err != null) {
                handle(err);
            } else {
                handle(null);
            }
        });
    }
}

export default function customStorage(): CustomStorageEngine {
    return new CustomStorageEngine();
}
@Controller('file')
export default class FileController {
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo', { storage: customStorage() }))
    upload(
        @UploadedFile() file: Express.Multer.File,
    ): void {
        console.log(file);
    }
}
  • 独自のInterceptorを使う

先の案と比較すると、リクエストからファイルを抽出する処理が必要なため難易度はやや高めですが、その分自由度も高いです。
特に、処理結果に応じてレスポンスを出し分ける場合は、こちらの案が有力候補になるかと思います。
また、リクエストからのstreamを扱うので、Controllerでも同様に処理できます。
以下は、busboyを使ってリクエストからファイルを抽出しつつ、ディスクにファイルを保存する例です。

import { type CallHandler, type ExecutionContext, Injectable, type NestInterceptor } from "@nestjs/common";
import Busboy from "busboy";
import { type Request, type Response } from 'express';
import { createWriteStream } from "fs";
import path from "path";
import { Observable, type Subscriber } from "rxjs";
import { type Stream } from "stream";

interface FileUploadResponse { message: string; };

@Injectable()
export default class StreamFileInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, _next: CallHandler): Observable<FileUploadResponse> {
        const ctx = context.switchToHttp();
        const req = ctx.getRequest<Request>();
        const res = ctx.getResponse<Response>();
        const uploadsDir = path.join(__dirname, './uploads');

        const subscriber = (observer: Subscriber<FileUploadResponse>): void => {
            const busBoy = Busboy({ headers: req.headers });

            busBoy
                .on('file', (_fieldName: string, file: Stream, info: { filename: string; encoding: string; mimeType: string; }) => {
                    const { filename } = info;
                    const saveTo = path.join(uploadsDir, path.basename(filename));
                    const writeStream = createWriteStream(saveTo);
                    file.pipe(writeStream);
                    writeStream
                        .on('close', () => { })
                        .on('error', (_err) => { });
                })
                .on('finish', () => {
                    observer.next({ message: 'File upload process completed.' });
                    observer.complete();
                })
                .on('error', (err) => {
                    observer.error(err);
                });

            req.pipe(busBoy);
        }
        return new Observable<FileUploadResponse>(subscriber);
    }
}

3-2. ファイルに対してバリデーションチェックをかけたいとき

ファイルサイズや拡張子など、ファイルのメタデータに対するバリデーションチェックは、ParseFilePipeが非常に便利です。
デフォルトで利用可能なFileTypeValidator/MaxFileSizeValidatorに加えて、カスタムバリデータ(CustomFileSizeValidator)を使ったサンプルコードを以下に示します。

import { Controller, FileTypeValidator, ParseFilePipe, Post, UploadedFile, UseInterceptors } from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { CustomFileSizeValidator } from "./CustomFileSizeValidator"

@Controller('file')
export default class FileController {
    @Post('upload')
    @UseInterceptors(FileInterceptor('foo'))
    upload(
      @UploadedFile(
          new ParseFilePipe({
              validators: [
                  // MIMEタイプが'text/'の形式でなければ400/BadRequestで'Invalid mime type.'を返却する
                  new FileTypeValidator({
                      fileType: /text\//,
                      message: 'Invalid mime type.',
                  }),
                  // 1MByteを超える場合400/BadRequestで'File size is too large.'を返却する
                  new MaxFileSizeValidator({
                      maxSize: 1048576,
                      message: 'File size is too large.',
                  }),
                  // CustomFileSizeValidatorによるバリデーションチェック
                  // 50Byte~100Byteの範囲外の場合400/BadRequestを返却する
                  new CustomFileSizeValidator({ minSize: 50, maxSize: 100 }),
              ],
              errorHttpStatusCode: HttpStatus.BAD_REQUEST
          }),
      ) file: Express.Multer.File,
    ): void {
        console.log(file);
    }
}
import { FileValidator } from '@nestjs/common';
import { type IFile } from '@nestjs/common/pipes/file/interfaces';

export class CustomFileSizeValidator extends FileValidator<FileSizeValidatorOptions, IFile> {
    // エラーメッセージの生成
    buildErrorMessage(): string {
        if ('message' in this.validationOptions && this.validationOptions.message !== undefined) {
            if (typeof this.validationOptions.message === 'function') {
                return this.validationOptions.message(this.validationOptions.maxSize, this.validationOptions.minSize);
            }
            return this.validationOptions.message;
        }

        return `Validation failed (expected size is more than ${this.validationOptions.minSize} and less than ${this.validationOptions.maxSize})`;
    }

    // バリデーションチェック処理
    public isValid(file?: IFile): boolean {
        if (file === null || file === undefined) return false;
        if (this.validationOptions === null || this.validationOptions === undefined) return true;

        return 'size' in file && this.validationOptions.minSize < file.size && file.size < this.validationOptions.maxSize;
    }
}

export interface FileSizeValidatorOptions {
    minSize: number;
    maxSize: number;
    message?: string | ((maxSize: number, minSize: number) => string);
}

おわりに

今回はNest.jsを使ったファイルアップロード方法についてご紹介しました。
Nest.jsは柔軟性や拡張性の高さが非常に魅力的なフレームワークです。
一方で、日本語の書籍や記事がまだまだ少なく、トラブルシューティングや仕様調査に苦労した経験もあるのではないでしょうか。
もしこの記事がそういった方々の助けになれたなら嬉しい限りです。
最後までお読みいただきありがとうございました。