NestJS — фреймворк для создания эффективных, масштабируемых серверных приложений на платформе Node.js. Вы можете встретить утверждение, что NestJS является платформо-независимым фреймворком. Имеется в виду, что он может работать на базе одного из двух фрейморков по Вашему выбору: NestJS+Express или NestJS+Fastify. Это действительно так, или почти так. Эта платформо-независимость заканчивается, на обработке запросов Content-Type: multipart/form-data. То есть практически на второй день разработки. И это не является большой проблемой, если Вы используете платформу NestJS+Express — в документации есть пример работы для Content-Type: multipart/form-data. Для NestJS+Fastify такого примера нет, и примеров в сети не так уж и много. И некоторые из этих примеров идут по весьма усложненному пути.

Выбирая между платформой NestJS+Fastify и NestJS+Express я сделал выбор в сторону NestJS+Fastify. Зная склонность разработчиков в любой непонятной ситуации вешать на объект req в Express дополнительные свойства и так общаться между разными частями приложения, я твердо решил что Express в следующем проекте не будет.

Только нужно было решить технический вопрос с Content-Type: multipart/form-data. Также полученные через запросы Content-Type: multipart/form-data файлы я планировал сохранять в хранилище S3. В этом плане реализация запросов Content-Type: multipart/form-data на платформе NestJS+Express меня смущала тем, что не работала с потоками.

Запуск локального хранилища S3


S3 — это хранилище данных (можно сказать, хотя не совсем строго, хранилище файлов), доступное по протоколу http. Изначально S3 предоставлялся AWS. В настоящее время API S3 поддерживается и другими облачными сервисами. Но не только. Появились реализации серверов S3, которые Вы можете поднять локально, чтобы использовать их во время разработки, и, возможно, поднять свои серверы S3 для работы на проде.

Для начала нужно определиться с мотивацией использования S3 хранилища данных. В некоторых случаях это позволяет снизить затраты. Например, для хранения резервных копий можно взять самые медленные и дешевые хранилища S3. Быстрые хранилища с высоким трафиком (трафик тарифицируется отдельно) на загрузку данных из хранилища, возможно, будут стоить сравнимо с SSD дисками того же объема.

Более весомым мотивом является 1) расширяемость — Вам не нужно думать о том, что место на диске может закончиться, и 2) надежность — сервера работают в кластере и Вам не нужно думать о резервном копировании, так как необходимое количество копий есть всегда.

Для поднятия реализации серверов S3 — minio — локально нужен только установленный на компьютере docker и docker-compose. Соответсвующий файл docker-compose.yml:

version: '3'
services:
  minio1:
    image: minio/minio:RELEASE.2020-08-08T04-50-06Z
    volumes:
      - ./s3/data1-1:/data1
      - ./s3/data1-2:/data2
    ports:
      - '9001:9000'
    environment:
      MINIO_ACCESS_KEY: minio
      MINIO_SECRET_KEY: minio123
    command: server http://minio{1...4}/data{1...2}
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
      interval: 30s
      timeout: 20s
      retries: 3

  minio2:
    image: minio/minio:RELEASE.2020-08-08T04-50-06Z
    volumes:
      - ./s3/data2-1:/data1
      - ./s3/data2-2:/data2
    ports:
      - '9002:9000'
    environment:
      MINIO_ACCESS_KEY: minio
      MINIO_SECRET_KEY: minio123
    command: server http://minio{1...4}/data{1...2}
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
      interval: 30s
      timeout: 20s
      retries: 3

  minio3:
    image: minio/minio:RELEASE.2020-08-08T04-50-06Z
    volumes:
      - ./s3/data3-1:/data1
      - ./s3/data3-2:/data2
    ports:
      - '9003:9000'
    environment:
      MINIO_ACCESS_KEY: minio
      MINIO_SECRET_KEY: minio123
    command: server http://minio{1...4}/data{1...2}
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
      interval: 30s
      timeout: 20s
      retries: 3

  minio4:
    image: minio/minio:RELEASE.2020-08-08T04-50-06Z
    volumes:
      - ./s3/data4-1:/data1
      - ./s3/data4-2:/data2
    ports:
      - '9004:9000'
    environment:
      MINIO_ACCESS_KEY: minio
      MINIO_SECRET_KEY: minio123
    command: server http://minio{1...4}/data{1...2}
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
      interval: 30s
      timeout: 20s
      retries: 3

Запускаем — и без проблем получаем кластер из 4 серверов S3.

NestJS + Fastify + S3


Работу с сервером NestJS опишу с самых первых шагов, хотя часть этого материала отлично описана в документации. Устанавливается CLI NestJS:

npm install -g @nestjs/cli

Создается новый проект NestJS:

nest new s3-nestjs-tut

Инсталлируются необходимые пакеты (включая те что нужны для работы с S3):


npm install --save @nestjs/platform-fastify fastify-multipart aws-sdk sharp
npm install --save-dev @types/fastify-multipart  @types/aws-sdk @types/sharp

По умолчанию в проекте устанавливается платформа NestJS+Express. Как установить Fastify описано в документации docs.nestjs.com/techniques/performance. Дополнительно нам нужно установить плагин для обработки Content-Type: multipart/form-data — fastify-multipart

import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import fastifyMultipart from 'fastify-multipart';
import { AppModule } from './app.module';

async function bootstrap() {
  const fastifyAdapter = new FastifyAdapter();
  fastifyAdapter.register(fastifyMultipart, {
    limits: {
      fieldNameSize: 1024, // Max field name size in bytes
      fieldSize: 128 * 1024 * 1024 * 1024, // Max field value size in bytes
      fields: 10, // Max number of non-file fields
      fileSize: 128 * 1024 * 1024 * 1024, // For multipart forms, the max file size
      files: 2, // Max number of file fields
      headerPairs: 2000, // Max number of header key=>value pairs
    },
  });
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    fastifyAdapter,
  );
  await app.listen(3000, '127.0.0.1');
}

bootstrap();

Теперь опишем сервис, загружающий файлы в хранилище S3, сократив код по обработке некоторых видов ошибок (полный текст есть в репозитарии статьи):

import { Injectable, HttpException, BadRequestException } from '@nestjs/common';
import { S3 } from 'aws-sdk';
import fastify = require('fastify');
import { AppResponseDto } from './dto/app.response.dto';
import * as sharp from 'sharp';

@Injectable()
export class AppService {
  async uploadFile(req: fastify.FastifyRequest): Promise<any> {

    const promises = [];

    return new Promise((resolve, reject) => {

      const mp = req.multipart(handler, onEnd);

      function onEnd(err) {
        if (err) {
          reject(new HttpException(err, 500));
        } else {
          Promise.all(promises).then(
            data => {
              resolve({ result: 'OK' });
            },
            err => {
              reject(new HttpException(err, 500));
            },
          );
        }
      }

      function handler(field, file, filename, encoding, mimetype: string) {
        if (mimetype && mimetype.match(/^image\/(.*)/)) {
          const imageType = mimetype.match(/^image\/(.*)/)[1];
          const s3Stream = new S3({
            accessKeyId: 'minio',
            secretAccessKey: 'minio123',
            endpoint: 'http://127.0.0.1:9001',
            s3ForcePathStyle: true, // needed with minio?
            signatureVersion: 'v4',
          });
          const promise = s3Stream
            .upload(
              {
                Bucket: 'test',
                Key: `200x200_${filename}`,
                Body: file.pipe(
                  sharp()
                    .resize(200, 200)
                    [imageType](),
                ),
              }
            )
            .promise();
          promises.push(promise);
        }
        const s3Stream = new S3({
          accessKeyId: 'minio',
          secretAccessKey: 'minio123',
          endpoint: 'http://127.0.0.1:9001',
          s3ForcePathStyle: true, // needed with minio?
          signatureVersion: 'v4',
        });
        const promise = s3Stream
          .upload({ Bucket: 'test', Key: filename, Body: file })
          .promise();
        promises.push(promise);
      }
    });
  }
}

Из особенностей следует отметить, что мы пишем входной поток в два выходных потока, если загружается картинка. Один из потоков сжимает картинку до размеров 200х200. Во всех случаях используется стиль работы с потоками (stream). Но для того, чтобы отловить возможные ошибки и вернуть их в контроллер, мы вызываем метод promise(), который определен в библиотеке aws-sdk. Полученные промисы накапливаем в массиве promises:

        const promise = s3Stream
          .upload({ Bucket: 'test', Key: filename, Body: file })
          .promise();
        promises.push(promise);

И, далее, ожидаем их разрешение в методе Promise.all(promises).

Код контроллера, в котором таки пришлось пробросить FastifyRequest в сервис:

import { Controller, Post, Req } from '@nestjs/common';
import { AppService } from './app.service';
import { FastifyRequest } from 'fastify';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Post('/upload')
  async uploadFile(@Req() req: FastifyRequest): Promise<any> {
    const result = await this.appService.uploadFile(req);
    return result;
  }
}

Запускается проект:

npm run start:dev

Репозитарий статьи github.com/apapacy/s3-nestjs-tut

apapacy@gmail.com
13 августа 2020 года