Nest.jsでmultipart/form-dataとうまく付き合う【前編】
はじめに
こんにちは。株式会社ラキールで LaKeel DX Product Group Components Teamに所属する土屋です。
私が所属するチームでは、LaKeel DX基盤上で使えるソフトウェア部品を開発しています。
その中でも、私はファイル基盤というサービスの機能拡張を主に担当しています。
ファイル管理基盤は、WebAPIで各種クラウドストレージを透過的に操作できるサービスです。
このレイヤがあることでLaKeel DXの開発者は、AWSやGCPなど個別のクラウド環境の違いを意識せずに、同じインターフェースでアクセスできます。
マルチクラウドを実現する上で肝となるサービスです。
その機能拡張を通じて、Nest.jsのFileInterceptorという機能を利用して課題解決を行いましたので、その紹介をさせていただきます。
きっかけ
ファイル基盤サービスは開発当初Expressで開発していました。
その後、Nest.js(ExpressAdapter)へとリプレースしたのですが、その際リプレース前のコードを多く流用していました。
まずは、具体例を挙げて、どういう状況だったかご説明します。
以下のコードは、multipart/form-dataでファイルを受け取り、各種クラウドストレージサービスにアップロードするAPIを、簡略化したイメージです。
@Post()
async uploadFiles(@Req() req: Request, @Res() res: Response): Promise<FilesUploadResponse> {
const memoryStorage: multer.StorageEngine = multer.memoryStorage();
const upload: RequestHandler = multer({ storage: memoryStorage }).array('file'); // ・・・①
upload(req, res, async (err?: any): Promise<void> => {
if(req.params1.length <= 0) { throw new BadRequestException(); } // ・・・②
if(req.params2.length <= 0) { throw new BadRequestException(); } // ・・・②
// バリデーションチェックが続く…
this.anyCloudService.upload(req); // ・・・③
});
}
このAPIは、以下のようなリクエストを受け付けます。
Content-Type: multipart/form-data; boundary=----boundary
--boundary
Content-Disposition: form-data; name="file[0]"; filename="sample0.zip"
Content-Type: application/zip
(ファイル)
--boundary
Content-Disposition: form-data; name="params1[0]"
foo
--boundary
Content-Disposition: form-data; name="file[1]"; filename="sample1.zip"
Content-Type: application/zip
(ファイル)
--boundary
Content-Disposition: form-data; name="params1[1]"
bar
こちらのコードの大まかな流れは以下の通りです。
① Multer.array()を利用して、multipart/form-dataなリクエストからファイルやその他のリクエストパラメータを取得
② ①で取得したフィールドやファイルをバリデーションチェック
③ ファイルをクラウドストレージサービスにアップロード
②のバリデーションチェック処理が複雑化しており、Controller層に染み出していました。
そのためController層の本質的なロジックが埋もれて可読性が下がり、結果的に保守性を損ねていました。
保守性の低さは、ソフトウェアの品質や開発のアジリティに対してジワジワと悪影響を及ぼしていきます。
継続的な製品の付加価値向上とお客様への価値提供を目的とした場合、この点に課題があると感じ、解決策を検討しました。
InterceptorsとPipesについて
Nest.jsのFileInterceptorという機能を使うことでこの課題を解決しました。
Nest.jsはサーバサイドアプリケーションの開発に適した、非常に多くの便利な機能を提供しています。
その中には、AOP(アスペクト指向プログラミング)からインスピレーションを受けたInterceptorsという一連の機能群があります。
FileInterceptorはこのInterceptorsの一種で、multipart/form-dataなリクエストからファイルやパラメータを抽出してくれます。
Nest.jsにおけるリクエストのライフサイクルを俯瞰すると、以下緑色の辺りでInterceptorsが実行されます。
【参考】https://docs.nestjs.com/faq/request-lifecycle
ここで少し脱線してしまいますが、各イベントの用途を載せておきます。
Middleware
処理全体の一番最初と最後に処理を追加できます。
比較的自由度が高いため、様々な用途で使われます。
主に、Request/Responseオブジェクトに変更を加えたりロギング等で使うことが多いかと思います。
【参考】https://docs.nestjs.com/middleware
Guards
認証/認可のみを担当します。
【参考】https://docs.nestjs.com/guards
Interceptors
メソッドの実行前後に処理を追加できます。
関数の入出力を変換したり、関数のふるまいを拡張するために使うことが多いです。
【参考】https://docs.nestjs.com/interceptors
Pipes
主に、入力値の変換やバリデーションに利用します。
【参考】https://docs.nestjs.com/pipes
Filters
スローされた例外を処理して、適切なレスポンスを応答します。
【参考】https://docs.nestjs.com/exception-filters
本題
ここでミソになってくるのが、Pipesの前にInterceptorsが実行される、という点です。
WEBアプリケーション開発においてMVCフレームワークを活用する場合、セキュリティ的な観点から、Controller層での処理開始より前にバリデーションチェックする設計になることが多いかと思います。
Nest.jsでは、Pipesの一種であるValidationPipeと、class-validatorというパッケージを組み合わせることで、Controller層に入る前のバリデーションチェックを簡単かつ宣言的に行えます。非常に素晴らしい開発者体験ですね。
【参考】https://docs.nestjs.com/techniques/validation
弊社製品でもこの仕組みを採用しており、Requestクラスのパラメータにデコレータを設定するだけで、簡単にバリデーションチェックできます。
一方で、元々のファイルアップロードAPIのロジックでは、ValidationPipeをうまく活用できません。
何故かと言いますと、素のNest.jsではmultipart/form-dataなリクエストをサポートしておらず、ValidationPipeで解析/検証可能な形式でRequestを受け取れないからです。
その結果、Controller層でmultipart/form-dataを解析した後、バリデーションチェックを行う実装となっていました。
先述のFileInterceptorをうまく使うことで、Pipesより先にmultipart/form-dataをオブジェクトに変換してくれるので、ValidationPipeの恩恵を受けることができます。
【参考】https://docs.nestjs.com/techniques/file-upload
FileInterceptorについて
FileInterceptorについては、後編で詳しくご説明したいと思います。
具体的には以下の内容を予定しています。
FileInterceptorの利用方法について
FileInterceptorの仲間について
その他便利な機能について
おわりに
ここまでの内容を簡潔にまとめます。
Nest.jsにおいてリクエストの各イベントは、以下の順番に呼び出されます。
Middleware
Guards
Interceptors
Pipes
Controller
Filter
multipart/form-dataをValidationPipeでバリデーションチェックしたい場合、FileInterceptorを使うのがおすすめです。
長くなってしまいましたので一旦ここで区切って、残りは後編に続きます。
後編では、FileInterceptorやその仲間について詳しくご紹介する予定です。
注意点
MulterがFastifyをサポートしていないため、FastifyAdapterを採用しているプロジェクトでは動作しないのでご注意ください。