Back-End

27 jun, 2018

Streams no Node.js: o que são streams, afinal? – Parte 01

Publicidade

Se você já utilizou Node.js para o desenvolvimento de algum projeto, provavelmente deve ter esbarrado nas famosas Streams. Sua reputação de serem coisas complicadas e difíceis de compreender se estende para muito além do mundo JavaScript. Por isso, muitos desenvolvedores criaram bibliotecas e APIs para trabalhar com elas de uma forma mais eficaz.

Mas, como tudo na vida, podemos trabalhar somente com o essencial, sem precisar baixar nenhuma Lib. O pacote do NPM vai fazer a abstração das streams, vamos utilizar a biblioteca nativa do Node para streams.

Um pouco de conceito

Stream não é um conceito novo, ele já data de muito antes das linguagens mais atuais. Temos implementações de streams desde o .NET Framework 2.0 e, muito provavelmente, antes disso também.

Uma stream é uma forma de representar uma sequencia de bits. Podem ser entendidas como coleções de dados, exatamente como Arrays ou objetos, mas a diferença é que os dados em uma stream podem não estar disponíveis todos de uma vez, além disso, eles também não precisam utilizar a memória, ou seja, não é necessário que esses bits sejam armazenados na RAM.

Isto torna as streams ferramentas poderosas para desenvolvimento focado em grandes massas de dados, pois podemos trabalhar em pequenos pedaços (chunks, como são chamados) e continuamente, sem precisar salvar nada na memória.

Outra vantagem que ganhamos com streams é a capacidade de podermos enviar o resultado de uma stream direto para a outra, a chamada composição de comandos. Se você já usou Linux alguma vez já deve ter usado a famosa notação “pipe” no bash, não é mesmo? Talvez alguma coisa desse tipo:

$ cat meuarquivo.txt | grep meutexto

Pois bem, podemos fazer exatamente a mesma coisa com streams, inclusive o método para tal comando é justamente chamado de pipe.

Para ficar mais fácil, imagine um rio. O fluxo do rio é único e sempre corrente (não podemos parar o rio) – inclusive esta é a origem do nome “stream”, que significa “fluxo” em inglês – mas podemos desviar seu curso, modificar a água corrente, retardar ou até impedir o avanço de uma determinada porção dele. Bem como podemos utilizar um cano para ligar o fluxo a outro rio, por exemplo. Tudo isso pode ser feito com streams.

Streams que conhecemos

Streams podem ter duas formas: a writable stream e a readable stream. Alguns exemplos de writable streams, são:

  • Requisições HTTP enviadas pelo cliente
  • Respostas HTTP enviadas pelo server (sim, res.send() é uma stream)
  • No módulo fs podemos criar uma writableStream para qualquer arquivo e escrever nela
  • O módulo crypto responsável por algoritmos de criptografia também trabalha com streams
  • Todo o tipo de Socket half duplex é uma stream de escrita ou de leitura
  • process.stdout, process.stderr são exemplos de streams que são manipuladas por uma API famosa, chamada console.log e console.error que você já deve ter usado.

As readable streams também têm exemplos:

  • Respostas HTTP no cliente, dadas pelo servidor. Ou seja, escrevemos em uma writable stream no servidor e lemos em uma readable stream no client
  • Requests HTTP no servidor, pelo motivo inverso do item anterior
  • Da mesma forma o módulo fs também permite a criação de readStreams para a leitura de arquivos
  • process.stdin, a famosa “entrada da linha de comando”

Um outro tipo de stream que pode existir é o modelo RW, que é ao mesmo tempo de escrita e leitura, como os Sockets full duplex implementados via TCP no Node e as streams do módulo crypto.

Uma outra implementação interessante é que, quando usamos o child process do Node para criar outros processos filhos do processo atual, na verdade, temos as streams invertidas, ou seja, o process.stdin de um processo filho é uma stream de escrita, enquanto process.stdout e stderr são streams de leitura, para que fique mais fácil a manipulação destes objetos a partir do processo pai.

Um exemplo real

Vamos imaginar que temos que ler uma lista de arquivos imensos de um servidor e salvar estes arquivos em nosso próprio servidor de backup. Se fossemos fazer isso de forma tradicional, poderíamos criar um Buffer, ler todo o arquivo primeiro e depois salvar tudo de uma vez em um arquivo, não é mesmo?

O problema com este tipo de implementação é que, se o arquivo tiver, por exemplo, uns 400Mb de texto, nós vamos ter que carregar 400Mb na memória para depois descarregar essa memória de uma vez só para um arquivo, o que não é nada prático, ainda mais se quisermos fazer isso de forma rápida, porque neste modelo só conseguiríamos trazer um arquivo por vez (a não ser que tenhamos um servidor com memória capaz de armazenar 100 arquivos de 400Mb cada diretamente na RAM).

Se implementarmos uma stream para a leitura do arquivo e depois simplesmente passarmos a entrada desta stream para outra stream, que seria a stream de saída, podemos paralelizar todo o processo e ainda vamos poupar muita memória. Seria algo mais ou menos assim:

const http = require('http')
const fs = require('fs')

const listaArquivos = [] // Uma lista de nomes de arquivo

for (let arquivo of listaArquivos) {
  let request = http.get(`https://meuserver.com.br/arquivo/${arquivo}`, (resposta) => {
    resposta.pipe(fs.createWriteStream(`./backups/${arquivo}`))
  })
}

Mas como o servidor funcionaria? Ele deveria devolver uma stream porque vimos que respostas do servidor são streams de leitura, mas vamos armazenar o arquivo todo antes de enviar? Mais ou menos assim:

const fs = require('fs')
const server = require('http').createServer()

server.on('request', (request, resposta) => {
  const nomeArquivo = request.url.split('/').pop()
  fs.readFile(`./${nomeArquivo}`, (err, dados) => {
    if (err) throw err
    resposta.end(dados)
  })
})

server.listen(5656)

Não! O problema com isso é que vamos responder o arquivo todo de uma vez só! Nosso consumo de memória vai pular lá para o alto. Vamos melhorar um pouco o código:

const fs = require('fs')
const server = require('http').createServer()

server.on('request', (request, resposta) => {
  const nomeArquivo = request.url.split('/').pop()
  fs.createReadStream(`./${nomeArquivo}`).pipe(resposta)
})

server.listen(5656)

Agora, ao invés de salvar todo o arquivo na memória a cada requisição, vamos enviar à medida que formos recebendo a leitura do mesmo, assim não travamos nosso event loop e também não consumimos muito mais memória do que o necessário para alocar uma stream. Podemos testar isso com arquivos da ordem de Gigabytes de espaço e vamos conseguir servi-los da mesma forma.

Isto acontece porque não estamos lendo tudo, mas sim lendo pequenos pedaços e enviando parte a parte. Não é como se nunca guardássemos nada na memória, mas o que guardamos fica alocado somente até a próxima chunk ser lida e ai é descartada, reutilizando o espaço; muito mais eficiente.

Continua…

Para não transformar este artigo em um livro, vamos quebrá-lo em partes. Nesta parte, entendemos o que são streams e como elas funcionam. Na próxima parte vamos aprender a criar e trabalhar com essas streams e, na parte final, vamos aprender a implementar uma stream.

Se você ficou curioso para saber um pouco mais sobre o assunto, vou deixar aqui uma lista de links que deram base para este texto:

Aguardem!