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.
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).
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:
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
:
Já na API A (arquivo api-a.js) temos o seguinte:
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:
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:
- Usar o navegador para acessar o endereço local onde a aplicação está rodando:
http://localhost:3000/
- Selecionar um arquivo grande em
Choose File
- Clicar no
Submit
- Conforme os dados vão sendo recebidos pela API, nossa classe (código acima) printa a quantidade de bytes recebidos no console
- Ao término, o
Storage2API
chama o callbackcb
para indicar ao multer que os bytes foram lidos. O objeto que passamos no segundo parâmetro deste callback é adicionado noreq.file
, veja na imagem abaixo:
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:
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.
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.
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));
}
}
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.