No último artigo discorremos sobre o que é uma stream e demos um exemplo prático onde elas podem se encaixar. Nesta segunda parte da nossa história, vamos aprender o que é uma stream de verdade, seus tipos e como podemos entender melhor o funcionamento delas por baixo dos panos.
Tipos de streams
Fundamentalmente, existem quatro tipos de streams no Node.js:
- Readable, ou somente leitura: são abstrações de uma origem de dados. Pode ser um arquivo, uma request, em geral qualquer lugar que pode prover dados que podem ser consumidos. Um exemplo clássico e bem visível é o método fs.createReadStream do pacote fs do Node, que permite que leiamos o conteúdo de um arquivo a partir da sua stream. Neste caso, a fonte abstraída é o arquivo.
- Writable ou somente escrita, assim como as Readable, são abstrações para destinos de dados onde podemos enviar informações ou escrever algo. Pode ser um arquivo, uma resposta HTTP, entre outros. Da mesma forma, o modelo mais visível é o fs.createWriteStream, que permite que escrevamos em um arquivo apenas enviando dados para essa stream.
- Duplex são streams que, ao mesmo tempo, são Writable e Readable. O exemplo mais comum são os sockets TCP, muito utilizados com socket.io, que permitem a escrita de informações ao mesmo tempo que a leitura também é permitida.
- Streams de transformação, ou Transform Streams, são basicamente streams do tipo Duplex que, em geral, são utilizadas para modificar os dados trafegados por elas. Por exemplo, podemos utilizar uma stream que recebe uma String em letra minúscula, converter toda ela para maiúsculo e escrever em outra stream. Um exemplo desse modelo são Buffers e zlib.createGzip, que comprimem o dado usando o formato gzip.
Podemos pensar que uma stream de transformação é uma função, onde a parte Writable é a entrada de dados (no nosso caso, o recebimento da String) e a saída seria a parte Readable (onde daríamos o output com a String maiúscula). Essas streams também são chamadas de streams de passagem (em inglês: through streams).
Para interagirmos com streams, vamos utilizar, na maioria dos casos, eventos. Por isso que streams em Node são instancias do objeto EventEmitter. Portanto, emitem eventos que podem ser capturados e tratados em determinados casos.
Pipe
Além disso, podemos consumir streams utilizando o método pipe, que vimos na primeira parte deste artigo. Com ele, podemos simplesmente pegar todos os dados de uma entrada do tipo Readable e passar para um destino Writable.
A grande sacada para se lembrar dos tipos das streams, é lembrar que tudo que é destino deve ser Writable, enquanto tudo que é origem deve ser Readable, mas nada impede que ambos os lados sejam Duplex.
Utilizar o método pipe é tão simples quanto:
origemReadable.pipe(destinoWritable)
O que fazemos nesta linha é basicamente pegar todos os dados de saída da nossa origem e encaminhar para a entrada do nosso destino, como se fossem dois canos (pipes, em inglês) conectados. Como comentamos antes, a entrada deve ser Readable e a saída deve ser Writable, mas isso não impede que ambos os lados sejam streams duplex, então neste caso podemos encadear nossos pipes como fazemos nas nossas chamadas de comando.
origemDuplex.pipe(destinoDuplex1).pipe(destinoDuplex2).pipe(destinoWritable)
Note que, para que seja possível fazermos esse encadeamento, o pipe retorna a stream de destino (que está dentro dos parênteses), o que torna o comando acima algo como:
origemDuplex.pipe(destinoDuplex1) // Retorna destinoDuplex1 destinoDuplex1.pipe(destinoDuplex2) // Retorna destinoDuplex2 destinoDuplex2.pipe(destinoWritable) // Retorna destinoWritable
Para fazer uma analogia, imagine que streams Readable são canos que bombeiam informações para fora; você não pode bombear nada para dentro dele porque o fluxo não permite. Já as Writable são canos de sucção que não bombeiam nenhum tipo de informação para fora, apenas consomem o que está sendo enviado.
Neste contexto, duplex seria um cano aberto de ambos os lados, sem nenhuma bomba ou sucção. Portanto, você pode tanto bombear informação para dentro (que sairia do outro lado na forma de sucção) ou então bombear informações para fora.
Eventos
O método pipe que vimos é o modo mais simples de consumir uma stream, além de ser inteligente, pois gerencia erros e EOF’s ao longo da stream e também trata casos onde uma stream pode ser mais lenta do que a outra. Também é fácil de usar, porém, essa simplificação também limita o que podemos fazer com os dados. O pipe só nos permite ler e escrever o que foi lido, sem modificações. Quando você precisa de uma certa customização no seu consumo, o ideal é utilizar os eventos, mas evite misturar as duas coisas.
Streams de leitura e escrita possuem eventos diferentes e tratamentos diferentes para cada tipo de evento disparado.
Eventos de uma stream de leitura:
- data: disparado sempre que a stream passa uma chunk de dados para o consumidor.
- end: disparado sempre que a stream não possui mais dados para serem consumidos.
- error: disparado quando um erro é detectado.
- close: disparado quando uma stream é fechada, indicando que nenhum outro evento será emitido.
- readable: disparado quando existem dados disponíveis para serem lidos na stream.
Eventos de uma stream de escrita:
- drain: disparado quando a stream está disposta a receber novo conteúdo.
- finish: disparado quando todos os dados recebidos já foram escritos.
- error: disparado quando um erro é detectado.
- close: disparado quando uma stream é fechada, indicando que nenhum outro evento será emitido.
- pipe/unpipe: disparado quando o método pipe ou unpipe é chamado em uma stream de leitura.
Além dos eventos, temos também métodos que geralmente são utilizados em conjunto com estes eventos para prover melhor controle:
Métodos de streams de Leitura
- pipe(), unpipe()
- read(), unshift(), resume()
- pause(), isPaused()
- setEncoding()
Métodos de streams de escrita
- write()
- end()
- cork(), uncork()
- setDefaultEncoding()
Estes eventos combinados com os métodos, dão um poder incrível ao uso das streams, permitindo uma customização muito avançada. Por exemplo, podemos consumir uma stream de leitura usando pipe ou então read, unshift e resume e então podemos escrever em uma stream de escrita fazendo dela o destino do pipe que utilizamos, ou então manualmente chamando write para cada chunk de dados e depois end ao final de tudo. Por exemplo, se formos simular o pipe usando eventos, podemos fazer isto:
# leitura.pipe(escrita) leitura.on('data', (chunk) => { escrita.write(chunk) }) leitura.on('end', () => escrita.end())
Todos as definições e explicações mais detalhadas sobre os métodos de streams de leitura e escrita estão na documentação oficial sobre readables e na documentação oficial sobre writables.
Modos de fluxo para streams de leitura
Toda stream de leitura tem dois modos possíveis: pausado (paused) ou em fluxo (flowing). Algumas vezes estes modos serão descritos como pull e push.
Por padrão, todas as streams de leitura começam no modo pausado. Podemos ler dados sob demanda utilizando o método read. Porém, se ela está no estado de fluxo (flowing) então os dados estão continuamente sendo enviados e, para podermos ler este tipo de informação, precisamos ouvir aos eventos que a stream nos emite.
O modo de fluxo contínuo, se não utilizado corretamente, pode ser prejudicial, pois os dados podem, de fato, ser perdidos se não houver ninguém para consumi-los. Por isso que, sempre que temos uma stream em modo de fluxo, temos que ter um listener para o evento data:
leitura.on('data', (chunk) => {...})
Na verdade, o Node é um pouco mais inteligente para evitar que dados sejam perdidos, porque streams de leitura são automaticamente setadas para o modo de pausa se não há nenhum consumidor de eventos data para ela. Assim que este consumidor é criado, a stream entra em modo de fluxo.
leitura.read() // Retorna o que lemos, stream em modo de pausa leitura.read() // Retorna o próximo dado, continua em modo de pausa leitura.on('data', (chunk) => {...}) // Leitura contínua, stream passa para modo de fluxo
Da mesma forma, remover um handler de data, retorna a stream para o modo de pausa. Mas, se você quiser alternar manualmente entre os modos de pausa e fluxo, é possível utilizar os métodos pause() e resume().
Conclusão
Neste artigo aprendemos um pouco mais sobre como streams são implementadas e como podemos utilizar seus modos para tirar proveito das suas funcionalidades. Além disso, demos uma passada pelos eventos e métodos disponíveis de uma stream.
Se você quiser saber um pouco mais, dê uma olhada neste artigo, que serviu de base para essa parte da nossa sequência de artigos sobre streams. No próximo capítulo, vamos aprender a implementar streams e utilizá-las de forma efetiva, mostrando alguns exemplos no meio do caminho.