Upload de arquivos de uma API para outra com Express, Multer e node-fetch

Como realizar o upload de arquivos grandes entre APIs de forma eficiente, sem comprometer o consumo de memória ou disco.

Upload de arquivos de uma API para outra com Express, Multer e node-fetch
Upload de arquivos de uma API para outra com Express, Multer e node-fetch

Um pouco sobre o multer

Quando trabalhamos com aplicações que usam o framework Express e temos que lidar com upload de arquivos, com certeza o Multer é uma ótima escolha.

O Multer  é um middleware que internamente utiliza a lib  busboy para lidar com formulários multipart/form-data. Normalmente utilizamos o multipart/form-data para fazer o upload de arquivos. Inclusive já comentamos por aqui (link abaixo).

Upload de imagens ou arquivos utilizando multer - Node.js Express
Exemplo completo de como utilizar o multer para fazer upload de arquivos ou imagens em uma aplicação Node.js Express.

DiskStorage e MemoryStorage

Falando um pouco mais sobre o Multer, por padrão ele entrega 2 mecanismos (engines) para tratarmos o upload:

  • DiskStorage - Cria uma writable stream utilizando o createWriteStream para enviar os bytes recebidos para um arquivo.
  • MemoryStorage - Conforme os bytes vão chegando, este mecanismo os concatena de modo a guardar o arquivo inteiro em memória.

    Cuidado com esta opção, lembre-se de que carregar grandes arquivos ocupam muita memória. Inclusive há uma nota na documentação oficial do Express:

    WARNING: Uploading very large files, or relatively small files in large numbers very quickly, can cause your application to run out of memory when memory storage is used.

Em certos cenários estas 2 opções com certeza serão bem úteis, mas há casos onde criar um outro mecanismo de storage faz mais sentido.

Em casos mais complexos, onde por exemplo podemos ter diversos microserviços, salvar um arquivo inteiro em disco ou mantê-lo em memória para então encaminhá-lo para outra API pode não ser uma opção e nem a melhor escolha.

Considere um cenário onde uma API A recebe o upload e precisa encaminhá-la para uma API B. Não faz sentido receber o arquivo inteiro na API A, salvá-lo em disco ou em memória para então encaminhar para a API B. O que faz mais sentido é utilizar um stream para receber e enviar os chunks conforme vão chegando. Desta forma não ocupamos grandes espaços de memória nem espaço em disco.

O problema neste cenário hipotético, mas que ocorre no mundo real, é que as duas opções (DiskStorage e MemoryStorage) que o Multer oferece como padrão não atendem esta demanda, mas é possível criar uma classe para tratar este cenário, veja no decorrer deste texto.

Estruturando o cenário para upload entre APIs

A estrutura do projeto para simular o cenário será composta por apenas 4 arquivos:

projeto/
├─ api-a.js
├─ api-b.js
├─ multer-custom-storage.js
├─ package.json
Estrutura de arquivos
{
  "name": "consolelog-stream-front-api-api",
  "version": "1.0.0",
  "scripts": {
    "start-a": "node api-a.js",
    "start-b": "node api-b.js"
  },
  "type": "module",
  "author": "consolelog.com.br",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.1",
    "form-data": "^4.0.0",
    "multer": "^1.4.5-lts.1",
    "node-fetch": "^3.2.6"
  }
}
package.json

Observação: o "type": "module" trata os arquivos .js  como ESM - ES modules. Na prática você vai perceber a síntaxe import pacote from 'meu-modulo' ao invés de const pacote = require('meu-modulo').


A API B (arquivo api-b.js) irá trabalhar na porta 4000 e apenas irá receber um arquivo e salvar em disco com o multer:

import express from "express";
import multer from "multer";

const app = express();
const PORT = 4000;

// ********************************************************
// Quando informamos o "dest", o multer cria uma instância
// do DiskStorage.
// Trecho do arquivo index.js do multer:
//
//  if (options.storage) {
//    this.storage = options.storage
//  } else if (options.dest) {
//    this.storage = diskStorage({ destination: options.dest })
//  } else {
//    this.storage = memoryStorage()
//  }
//
// https://github.com/expressjs/multer/blob/master/index.js
// ********************************************************
const upload = multer({
  dest: "./",
});

app.post("/", upload.single("arquivo"), (req, res) => {
  res.json({
    mensagem: "Arquivo salvo com sucesso",
    detalhes: req.file,
  });
});

app.listen(PORT, () => {
  console.log(`Example app listening on port ${PORT}`);
});
api-b.js

Já na API A (arquivo api-a.js) temos o seguinte:

import express from "express";

import multer from "multer";
import { Storage2API } from "./multer-custom-storage.js";

const app = express();
const port = 3000;

app.get("/", (_, res) => {
  res.writeHead(200, { Connection: "close" });
  res.end(`
      <html>
        <head></head>
        <body>
          <form method="POST" enctype="multipart/form-data">
            <input type="file" name="arquivo"><br />
            <input type="submit">
          </form>
        </body>
      </html>
    `);
});

const multerCustomStorage = new Storage2API();
const upload = multer({ storage: multerCustomStorage });

app.post("/", upload.single("arquivo"), (req, res) => {
  console.log(req.file);
  res.send("OK");
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});
api-a.js

Criando um custom storage para o multer

O Storage2API (código acima) será responsável por receber os bytes do arquivo enviado pelo frontend e enviá-lo para uma outra API. Sua construção deve ser baseada na documentação do Multer para que tudo funcione corretamente:  link.

Storage engines are classes that expose two functions: _handleFile and _removeFile. Follow the template below to get started with your own custom storage engine.

Na documentação podemos ver que é necessário criar uma classe com dois métodos:

  • _handleFile
  • removeFile

O método _handleFile irá definir o que será feito com os bytes do arquivo (upload). Por exemplo, podemos salvar os bytes em um arquivo ou realizar qualquer outro tipo de processamento. Para começar, vamos adicionar alguns pontos de log para enteder melhor o funcionamento:

export class Storage2API {
  _handleFile(req, file, cb) {
    // Pega o nome original do arquivo postado e também
    // o objeto readable para termos acesso aos chunks
    // do arquivo
    const { originalname, stream: readable } = file;

    // Sempre que um "pedaço" (chunk) do arquivo for
    // recebido, o método abaixo será executado.
    // Este ciclo permanecerá até que todos os bytes
    // do arquivo sejam recebidos
    readable.on("data", (chunk) => {
      console.log(`Chunk recebido: ${chunk.length} bytes`);
    });

    // Quando todos os bytes forem recebidos, o
    // método abaixo será executado:
    readable.on("end", () => {
      console.log(`${originalname}: bytes recebidos`);
        
      // "Avisamos" ao multer que os dados foram
      // processados.
      cb(null, {
        mensagem: "Bytes recebidos",
      });
    });
  }
}
multer-custom-storage.js
The 'data' event is emitted whenever the stream is relinquishing ownership of a chunk of data to a consumer.

https://nodejs.org/api/stream.html#event-data

Para testar utilizei o comando npm run start-a e realizei os seguintes passos:

  1. Usar o navegador para acessar o endereço local onde a aplicação está rodando:  http://localhost:3000/
  2. Selecionar um arquivo grande em Choose File
  3. Clicar no Submit
  4. Conforme os dados vão sendo recebidos pela API, nossa classe (código acima) printa a quantidade de bytes recebidos no console
  5. Ao término, o Storage2API chama o callback cb para indicar ao multer que os bytes foram lidos. O objeto que passamos no segundo parâmetro deste callback é adicionado no req.file, veja na imagem abaixo:
Exemplo do upload de um arquivo. Conforme os bytes são recebidos na API são printadas mensagens no console
Exemplo do upload de um arquivo. Conforme os bytes são recebidos na API são printadas mensagens no console

Enviando o arquivo de uma API para outra

Até este ponto é possível perceber que conseguimos receber os bytes do arquivo parcialmente, ou seja, aos poucos. Com esta lógica em mente, vamos encaminhar estes bytes para uma API através de uma outra requisição (API A -> API B). Para fazer esta tarefa podemos utilizar a lib node-fetch e modificar o código da Storage2API para:

import fetch from "node-fetch";
import FormData from "form-data";

export class Storage2API {
  async _handleFile(req, file, callbackMulter) {
    const { originalname: filename, stream: readable } =
      file;

    const formData = new FormData();
    formData.append("arquivo", readable, filename);

    const requestOptions = {
      method: "POST",
      headers: {
        // Headers como authorization podem ser
        // configurados aqui
      },
      body: formData,
    };

    try {
      const response = await fetch(
        "http://localhost:4000",
        requestOptions
      );

      const data = await response.json();
      callbackMulter(null, data);
    } catch (error) {
      callbackMulter(error);
    }
  }
}
multer-custom-storage.js

O funcionamento é bem simples, conforme os chunks chegam na API A, eles são enviados para a API B sem a necessidade de guardar todo o arquivo em memória. Isto é possível porque conseguimos ter acesso ao stream do upload, disponibilizado pelo parâmetro file. Veja os detalhes da execução nas imagens abaixo.

Observação: deixei um cronometro e o monitor de memória para acompanharmos a execução do upload de um arquivo de 1GB.

Terminal com as 2 APIs em execução, um cronometro no tempo 00:00 e o consumo de memória das APIs: 31,9mb e 18,1mb
Iniciando as aplicações
Upload em andamento. Terminal com as 2 APIs em execução, um cronometro no tempo de 8 segundos e o consumo de memória das APIs: 31,2mb e 21,6mb
Iniciando o upload
Upload em andamento. Terminal com as 2 APIs em execução, um cronometro no tempo de 15 segundos e o consumo de memória das APIs: 28,6mb e 21,5mb
Upload em andamento
Upload finalizado. Terminal com as 2 APIs em execução, um cronometro no tempo 22 segundos e o consumo de memória das APIs: 37,6mb e 28,5mb
Upload finalizado

Ao término do upload, cerca de 20 segundos, podemos ver que o arquivo 0417bd646021b99892f901dc5a4bf59e foi criado (imagem abaixo). O arquivo que deu origem a este upload é o arquivo-grande.bin que tem exatamente o mesmo tamanho. Repare também no consumo de memória nas imagens acima, tivemos um pico em torno de 38mb.

Terminal mostrando o arquivo selecionado para fazer upload e o arquivo gerado após o upload
Arquivo original e a cópia criada no upload

Considerações

O que queria compartilhar neste texto é como realizar o upload de arquivos grandes entre APIs de forma eficiente, sem comprometer o consumo de memória ou disco. Executei o código acima nas versões 16.15.1 e 18.5.0 do Node.js.

Testes com axios

Utilizando a lib [email protected] ao invés da node-fetch, a aplicação teve um alto consumo de memória, me dando a impressão de que o arquivo foi carregado inteiro em memória para então enviar os bytes à API B. Procurando no Github encontrei relatos sobre este problema - link.

Abaixo o código testado:

import axios from "axios";
import FormData from "form-data";

export class Storage2API {
  _handleFile(req, file, callbackMulter) {
    const { originalname: filename, stream: readable } =
      file;

    const formData = new FormData();
    formData.append("arquivo", readable, { filename });

    const requestConfig = {
      headers: {
        ...formData.getHeaders(),
      },
      // * ************************************************
      // * (Node only option) defines the max size of the
      // * http request content in bytes allowed
      // * https://axios-http.com/docs/req_config
      // * ************************************************
      maxBodyLength: Infinity,
    };

    axios
      .post(
        "http://localhost:4000/",
        formData,
        requestConfig
      )
      .then((res) => {
        // "Avisa" o Multer que finalizamos.
        // Os dados que a API retorna ficam disponíveis em
        // req.file.<dados retornados pela API>
        callbackMulter(null, res.data);
      })
      .catch((error) => callbackMulter(error));
  }
}
Consumo de memória da API A e B respectivamente: 992,7mb e 20,9mb
Consumo de memória da API A e B respectivamente

Testes com o fetch do Node 18

Na versão 18 do Node.js foi liberada a API fetch em modo experimental. Fiz alguns testes realizando o upload de uma API para outra com o multipart/data-form, mas não consegui passar um Readable para o FormData (também nativo), peguei o seguinte erro:

TypeError: Failed to execute 'append' on 'FormData': parameter 2 is not of type 'Blob'

Olhando nos arquivo de definições achei a assinatura do FormData:

append(name: string, value: string | Blob, fileName?: string): void;

Então apenas para testar o fluxo eu retirei o multipart/form-data (trechos comentados no código abaixo) e passei o arquivo direto no body. Na outra ponta também modifiquei o código da API para salvar o body recebido em um arquivo:

export class Storage2API {
  async _handleFile(req, file, callbackMulter) {
    const { originalname: filename, stream: readable } =
      file;

    // const formData = new FormData();
    // formData.append("arquivo", readable, filename);

    const requestOptions = {
      method: "POST",
      headers: {},
      body: readable, // formData,
    };

    try {
      const response = await fetch(
        "http://localhost:4000",
        requestOptions
      );

      const data = await response.json();
      console.log(data);
      callbackMulter(null, data);
    } catch (error) {
      callbackMulter(error);
    }
  }
}
import { createServer } from "http";
import { createWriteStream } from "fs";
import { randomUUID } from "crypto";

const port = 4000;

const handleFile = (req, res) => {
  const nomeArquivo = randomUUID();
  const arquivo = new createWriteStream(`./${nomeArquivo}`);

  req.pipe(arquivo);
  req.on("end", () => {
    res.write(
      JSON.stringify({
        mensagem: "Arquivo salvo com sucesso",
      })
    );

    res.end();
  });
};

createServer(handleFile).listen(port, () =>
  console.log(`Example app listening on port ${port}`)
);

Funcionou perfeitamente com um pico de consumo de memória em torno de 50mb.

Para finalizar vou deixar alguns links interessantes que utilizei bastante durante a construção deste texto.