Streams talvez seja um dos recursos de maior destaque na plataforma Node.js, porém ele ainda é pouco utilizado pelos iniciantes nesta plataforma. Neste post mostrarei um exemplo bem simples e prático do de streams fazendo uma caricatura do que acontece com aplicativos como Spotify.

Caso o leitor já tenha uma noção do conceito de stream, pode ir direto para a implementação do código sem precisar passar pela explanação do conceito.

Um problema, uma solução

Quando assistimos um vídeo no Youtube ou escutamos uma música no Spotify uma coisa é certa; não recebemos o vídeo nem o audio por completo quando interagimos com eles, recebemos pedacinho por pedacinho. Se recebêssemos os arquivos de uma só vez um vídeo de 2GB ou um arquivo de audio de 30MB teriam que ser carregados totalmente no servidor para então serem enviados para nós. Esse processo consumiria uma quantidade de memória RAM consideravelmente alta no servidor, sem falar que temos milhões de usuários acessando esses serviços todos os dias.

Já no lado de quem consome os dados, receber pedaço por pedaço tem vantagens também. Cada pedaço é processado e imediatamente descartado evitando também um alto consumo de memória. Dessa forma, podemos ter um cliente modesto em termos de memória que ele ainda conseguirá assistir aquele filme ou série favorito sem qualquer problema. O Netflix é um exemplo clássico dessa abordagem. Quando dizemos que recebemos “pedacinho por pedacinho”, estamos nos referindo a um processo chamado streaming.

No mundo Node.js, streams é a tecnologia que encapsula a complexidade de se lidar com streaming.

*C a l o p s i t a, piu piu! :)*

Streams em Node.js

Streams podem ser de leitura e de escrita. Streams de leitura podem ser associados a streams de escrita através da função pipe (tubo). Podemos definir o tamanho máximo em bytes a cada leitura, evitando que a memória do servidor seja sobrecarregada por carregar de uma única vez os dados da origem.

À medida que esses dados são enviados para o destino, eles são descartados no lado do servidor e novos chuncks com dados são enviados até que a fonte de leitura seja exaurida. Dessa forma, temos uma previsibilidade de quanta memória o servidor utilizará, pois apenas a porção que definirmos no buffer será carregada por vez em memória.

Agora que já temos uma ideia básica de streams, vamos para nossa implementação.

O projeto

Nosso projeto terá uma única página que ao ser exibida carregará um audio do servidor. A transferência do audio para index.html será feito através de streaming, no lugar de enviar todo arquivo de uma única vez.

Estrutura de pastas e arquivos

Antes de continuar, tenha certeza de estar usando Node.js 8.0 ou superior.

Vamos criar a pasta stream-de-audio com a seguinte estrutura e arquivos:

stream-de-audio
    ├── public
    │   └── index.html
    └── server.js

Baixe qualquer arquivo de audio do tipo ogg e salve-o dentro da pasta stream-de-audio com o nome audio.ogg:

├── audio.ogg
├── public
│   └── index.html
└── server.js

A página index.html

O conteúdo de stream-de-audio/public/index.html será uma simples página que possui a tag audio que terá como source o endereço /audio que ainda não existe em nosso servidor:

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Exemplo</title>
</head>
<body>
    <h1>Cangaceiro JavaScript</h1>
    <h2>Stream de audio</h2>
    <audio controls>
        <source src="/audio" type="audio/ogg">
    </audio>
</body>
</html>

Agora que já temos index.html, podemos baixar as dependências necessárias para criar nosso servidor.

Baixando as dependências da aplicação

Agora, dentro da pasta stream-de-audio criaremos o arquivo package.json e instalaremos o Express para nos ajudar a criar nosso servidor. Para isso, basta executar dentro da pasta stream-de-audio os seguintes comandos na sequência:

npm init -y
npm i express@4.15.4 --save

Nossa pasta ficará assim:

├── audio.ogg
├── node_modules
├── package-lock.json
├── package.json
├── public
│   └── index.html
└── server.js

Ótimo. Agora precisamos tornar a pasta public acessível através do navegador.

Compartilhando a pasta estática

Agora, em stream-de-audio/server.js vamos configurar o Express para compartilhar a pasta estática public:

// server.js
const express = require('express');
const app = express();
app.use(express.static('public'));
app.listen(3000, () => console.log('App na porta 3000'));

Já podemos testar o servidor. Basta executarmos o comando node server dentro da pasta stream-de-audio para que nosso servidor fique de pé. Vamos acessar o endereço http://localhost:3000 e verificar se o título “Exemplo de Stream de Audio” é exibido no navegador. Depois de testar, pare o servidor.

Com a certeza de que tudo esta funcionando, vamos para o próximo passo que é o endpoint que retornará o audio para a rota /audio, aquela usada pela tag source em index.html:

// server.js
const express = require('express');
const app = express();
app.use(express.static('public'));

app.get('/audio', (req, res) => {
    // ainda precisamos enviar o audio
});

app.listen(3000, () => console.log('app is running'));

Agora que já temos nossa rota configurada, precisamos implementá-la.

Extraindo informações do arquivo

Queremos fazer streaming de audio.ogg para quem acessar o endereço /audio. Para isso, precisaremos da ajuda do módulo fs padrão do Node.

Porém, antes que possamos fazer streaming do nosso audio, precisamos saber seu tamanho em bytes, tarefa que o próprio módulo fs pode realizar através da função fs.stat. No entanto, fs.stat é assíncrona e para melhorarmos a legibilidade do nosso código vamos “promisificá-la” através de util.promisify. No post “Por que você deveria estar usando util promisify” eu explico esse processo com detalhes. Com a API fs.stat promsificada, podemos usar async/await. Só não podemos esquecer de torna o callback de app.get uma função async:

/*
server.js

    importou o módulo fs e "promisificou" 
    a função fs.stat
*/
const express = require('express')
    , app = express()
    , fs = require('fs')
    , getStat = require('util').promisify(fs.stat);

app.use(express.static('public'));

// callback é async agora!
app.get('/audio', async (req, res) => {

    const filePath = './audio.ogg';
    
    // usou a instrução await
    const stat = await getStat(filePath);

    // exibe uma série de informações sobre o arquivo
    console.log(stat);
});

app.listen(3000, () => console.log('app is running'));

Excelente. O object stat possui uma série de informações sobre nosso arquivo de audio, entre elas o tamanho do arquivo. Vejamos a saída dele no terminal:

// saída no terminal

Stats {
  dev: 16777220,
  mode: 33188,
  nlink: 1,
  uid: 501,
  gid: 20,
  rdev: 0,
  blksize: 4096,
  ino: 34930808,
  size: 199087,
  blocks: 392,
  atimeMs: 1504113218000,
  mtimeMs: 1503949034000,
  ctimeMs: 1503949054000,
  birthtimeMs: 1503949032000,
  atime: 2017-08-30T17:13:38.000Z,
  mtime: 2017-08-28T19:37:14.000Z,
  ctime: 2017-08-28T19:37:34.000Z,
  birthtime: 2017-08-28T19:37:12.000Z }

Temos interesse na propriedade size.

Adicionando informações no cabeçalho

Agora que já temos estatísticas sobre o arquivo de audio, precisamos informar seu tamanho (Content-Length) no cabeçalho de resposta, inclusive o tipo de conteúdo (Content-Type) que será enviado:

// server.js
const express = require('express')
    , app = express()
    , fs = require('fs')
    , getStat = require('util').promisify(fs.stat);

app.use(express.static('public'));

app.get('/audio', async (req, res) => {

    const filePath = './audio.ogg';
    const stat = await getStat(filePath);
    console.log(stat);

    // informações sobre o tipo do conteúdo e o tamanho do arquivo
    res.writeHead(200, {
        'Content-Type': 'audio/ogg',
        'Content-Length': stat.size
    });
});

app.listen(3000, () => console.log('app is running'));

Estamos quase lá. Só precisamos agora criar um stream de leitura para nosso arquivo de audio.

Criando um stream através de fs.createReadStream()

Utilizaremos agora a função fs.createReadStream(). Ela permite a criação de um stream de leitura de uma maneira muito simples. Só precisamos indicar o caminho do arquivo para começarmos a transmitir. Mas transmitir para onde, cara pálida? Para o navegador, claro. A boa notícia é que a resposta do servidor (res) também é um stream, no caso, um stream de escrita. Podemos ligar o stream de leitura com o de escrita através da função pipe(). Nosso código ficará assim:

// server.js
const express = require('express')
    , app = express()
    , fs = require('fs')
    , getStat = require('util').promisify(fs.stat);

app.use(express.static('public'));

app.get('/audio', async (req, res) => {

    const filePath = './audio.ogg';
    const stat = await getStat(filePath);
    console.log(stat);    

    // informações sobre o tipo do conteúdo e o tamanho do arquivo
    res.writeHead(200, {
        'Content-Type': 'audio/ogg',
        'Content-Length': stat.size
    });

    const stream = fs.createReadStream(filePath);

    // só exibe quando terminar de enviar tudo
    stream.on('end', () => console.log('acabou'));

    // faz streaming do audio 
    stream.pipe(res);
});

app.listen(3000, () => console.log('app is running'));

Vamos subir o servidor novamente com a instrução node server para em seguida acessarmos o endereço http://localhost:3000. Já podemos clicar no botão play para que a música seja tocada.

Reduzindo o buffer

No entanto, como o arquivo de audio tem um tamanho bem pequeno, não conseguiremos ver a leitura do audio de maneira gradativa pelo player enquanto o audio toca. Para que possamos ver a barra de progresso de leitura vamos diminuir drásticamente o tamanho do buffer de leitura. Isso reduzirá a quantidade de informação enviada por vez e por conseguinte fará com que a barra de progresso seja exibida.

A função fs.createReadStream() aceita receber um segundo parâmetro, um objeto de configuracão. Nele, adicionaremos a propriedade highWaterMark com o valor do buffer que desejamos utilizar:

// server.js
const express = require('express')
    , app = express()
    , fs = require('fs')
    , getStat = require('util').promisify(fs.stat);

app.use(express.static('public'));

// 10 * 1024 * 1024 // 10MB
// usamos um buffer minúsculo! O padrão é 64k
const highWaterMark =  2;

app.get('/audio', async (req, res) => {

    const filePath = './audio.ogg';
    const stat = await getStat(filePath);
    console.log(stat);    
    
    // informações sobre o tipo do conteúdo e o tamanho do arquivo
    res.writeHead(200, {
        'Content-Type': 'audio/ogg',
        'Content-Length': stat.size
    });

    const stream = fs.createReadStream(filePath, { highWaterMark });

    // só exibe quando terminar de enviar tudo
    stream.on('end', () => console.log('acabou'));

    // faz streaming do audio 
    stream.pipe(res);
});

app.listen(3000, () => console.log('app is running'));

Parando o servidor e subindo-o novamente, já será possível ver a barra de progresso do carregamento do audio enquanto ele toca.