Características do Node.js
Do ponto de vista histórico, a linguagem JavaScript foi criada para possibilitar a execução de programas dentro dos navegadores web. Cada navegador possui um JavaScript engine que realiza essa execução. O Chrome, por exemplo, utiliza o V8. A grande inovação tradiza pelo Node.js levar JavaScript para o lado servidor de uma aplicação para web.
O Node.js é um runtime JavaScript baseado no programa V8 que possui, além da engine, uma biblioteca composta de diversos módulos JavaScript. Um dos módulos, chamado http, implementa o protocolo HTTP (versões 1.1 e 2). Isso permite que uma aplicação para web completa seja desenvolvida sem depender de nenhuma instalação adicional de outros módulos. Na prática, porém, o mais comum é que sejam utilizados outros módulos, como por exemplo o Express (http://expressjs.com/), que proporcionam uma abstração mais alto nível em relação ao http, simplificando a tarefa do desenvolvedor web.
Ferramenta npm
Acompanha o Node.js a ferramenta npm (Node Package Manager) para gerenciamento de pacotes (módulos) escritos em JavaScript.
npm permite a criação, compartilhamento e uso de módulos JavaScript. No desenvolvimento de aplicações para web, no entanto, o cenário mais comum é apenas utilizar (baixar) módulos desenvolvidos por terceiros.
O repositório https://www.npmjs.com/ possui mais de 700 mil módulos que podem ser baixados pelo npm e utilizados no desenvolvimento de outros módulos ou no desenvolvimento de aplicações para web.
Bilioteca libuv
A biblioteca libuv, utilizada pelo Node.js, determina o estilo assíncrono e dirigido por eventos de programação usado em aplicações para web.
O grande desafio de escalabilidade em aplicações para web é atender requisições HTTP que realizam alguma operação de IO (seja para ler/gravar dados en um arquivo ou banco de dados local seja para acessar enviar/obter dados via rede).
Se a operação de IO for bloqueante então a execução do algoritmo JavaScript será síncrona, ou seja, a execusão da próxima instrução é suspensa até que os dados tenham sido lidos(enviados)/gravados(recebidos).
A solução definida pela bibioteca libuv permite que as operações de IO sejam não-bloqueantes utilizando o conceito de evento. No lugar de suspender a execução, solicita ao sistema operacional que envie eventos relacionados com a operação de IO (por exemplo, indicando a disponibilidade de dados) e registra, em uma fila de eventos, qual código JavaScript deve ser executado (para, por exemplo, consumir os dados) quando o evento ocorrer. Este código é conhecido por função de callback.
Event Loop
O conceito de event loop, implementado pela biblioteca libuv, define a assência do funcionamento de uma aplicação para web executada pelo Node.js. Seu objetivo é possibilitar a execução assíncrona de operações de I/O. O algoritmo que implementa o event loop sempre é executado por uma única thread.
Uma das principais consequência do event loop é que se N requisições HTTP chegarem ao mesmo tempo no servidor então elas serão colocadas em uma fila e executadas sequencialmente.
O algoritmo (função JavaScript) que trata uma requisição precisa ser muito rápido ou então precisa ser assíncrono (tipicamente porque envolve uma operação de I/O). Se isso não acontecer as demais requisições ficarão na fila por muito tempo, aguardando a vez de serem executadas.
Uma versão simplificada do event loop é explicada por meio do seguinte exemplo: considere a situação onde dois usuários, U1 e U2, enviam, ao mesmo tempo, uma única requisição cada para o servidor. A requisição R1 do usuário U1 tem por objetivo obter uma página HTML simples gerada por uma concatenação simples de strings. Já a requisição R2 do usuário U2 tem por objetivo obter uma página HTML cujo conteúdo depende de informações armazenadas em um banco de dados. Vamos supor que os tempos necessários para atender R1 e R2 sejam, respectivamente de 10 e 100 ms.
Para o exemplo, há duas situações possíveis: R1 entra na fila antes de R2 ou o contrário acontece. Se R1 for a primeira da fila, ela será atendida imediatamente e, pouco mais de 10 ms depois o usuário U1 estará recebendo a página HTML solicitada. Somente depois de 10 ms, a segunda requisição é retirada da fila e atendida. Pouco mais de 100 ms depois o usuário U2 estará recebendo sua página HTML. Nesta situação o usuário U1 aguardou pouco mais de 10 ms para receber sua página e fica satisfeito com uma resposta tão rápida. O usuário U2 aguardou, no total, pouco mais de 110 ms para receber sua página e também fica satisfeito pois ele entende que "demora um pouquinho" para acessar um banco de dados.
O outro caso possível é onde R2 entra na fila antes de R1. O usuário U2 recebe sua página pouco depois de 100 ms e fica satisfeito. Já o usuário U1 estará satisfeito? Em quanto tempo ele receberá sua página? A resposta depende do algoritmo utilizado para atender a requisição R2.
Se a requisição R2 utilizar um algoritmo bloqueante (para acessar o banco de dados) então a requisição R1 só começará a ser atendida depois que R2 for atendida. Logo, o usuário U1 terá que aguardar um pouco mais de 110 ms para receber sua página. Ele ficará completamente decepcionado com o desempenho da aplicação.
Se, por outro lado, a requisição R2 utilizar um algoritmo não-bloqueante (para acessar o banco de dados) então o algoritmo utilizará uma função de callback para gerar a página com os dados que vierem do banco. Esta função de callback é inserida na fila do event loop e o algoritmo de R2 é concluído em poucos milisegundos. A fila agora possui dois códigos JavaScript aguardando execução: a requisição R1 e a função de callback. Um novo ciclo do loop de eventos volta a ser executado. R1 é retirada da fila, executada e a página HTML resultante é gerada e enviada ao usuário U1. Este receberá sua página em, digamos, pouco mais de 10 ms e ficará satisfeito com o desempenho da aplicação.
O algoritmo que implementa o event loop é bem mais sofisticado que o apresentado no exemplo acima, mas o princípio é o mesmo.
Streams
O conceito de stream (fluxo) de dados está presente no Node por meio do módulo chamado stream. Este módulo define quatro tipos de stream:
Readable : representa a fonte de dados do stream, ou seja, a origem dos dados que irão fluir através do stream.
Writeable : representa o destino dos dados do stream.
Duplex : pode ser usado, ao mesmo tempo, como fonte e como destino dos dados do stream.
Transform : transforma/modifica os dados de um stream.
Com estes tipos de stream é possível desenvolver aplicações para web que manipulem uma quantidade muito grande de dados e/ou que não estejam todos disponíveis ao mesmo tempo.
O exemplo clássico de utilização de streams é em aplicações que fazem download de arquivos. Sem o conceito de stream, a solução tradicional está baseada no seguinte algoritmo no lado servidor:
- A requisição HTTP chega no servidor e contém o nome do arquivo desejado.
- O conteúdo do arquivo é lido
- O conteúdo lido é inserido no corpo da resposta HTTP
- A resposta HTTP é enviada ao cliente
O problema deste algoritmo está nas etapas 2 e 3: o conteúdo do arquivo precisa estar todo em memória. Se o arquivo for muito grande ou houver muitos usuários ao mesmo tempo então esta solução não é escalável.
A solução, envolvendo o conceito de stream, estaria baseada no seguinte algoritmo:
- A requisição HTTP chega no servidor e contém o nome do arquivo desejado.
- Cria-se um readable stream para que gere (envie para memória) dados que representam uma pequena parte do arquivo.
- Conecta-se o readable stream à resposta HTTP que é um writeable stream.
- O conteúdo do arquivo passa por esse fluxo de dados até que não haja mais dados a serem enviados. O lado cliente vai recebendo o conteúdo do arquivo aos poucos.
Nesta solução há dois benefícios importantes:
- A qualquer momento durante o download do arquivo, o consumo de memória é constante, independentemente do tamanho do arquivo.
- Os streams definidos no Node implementam nativamente um mecanismo de backpressure para ajustar automaticamente as variações de velocidade entre o produtor e o consumidor de dados. Não há, portanto, risco de o readable stream inundar o writeable stream (que deveria então guardar em memória os dados recebidos).
Protocolo HTTP
No Node, o protocolo HTTP é implementado utilizando-se o conceito de stream:
uma requisição HTTP é implementada como um stream do tipo readable
uma resposta HTTP é implementada como um stream do tipo writeable.
Bibliotecas
A utilização de bibliotecas no desenvolvimento de software é uma prática consagrada há muito anos. Afinal, não faz sentido "reinventar a roda". Com o Node ocorre a mesma coisa.
A instalação do Node contém, além do runtime, diversas bibliotecas que, no jargão JavaScript, são chamadas de módulos. Alguns dos módulos disponíveis são:
- http : permite a criação de servidor web que utiliza o protocolo HTTP.
- https : permite a criação de servidor web que utiliza a versão criptografada do HTTP.
- File System (fs) : permite o acesso ao sistema de arquivos do computador possibilitando, por exemplo, a leitura e gravação de dados em arquivos. Possui versões síncronas e assíncronas uma vez que envolve operação de IO.
- path : permite a manipulação de caminhos (paths) de arquivos de maneira independente do sistema operacional. Obs: Windows e Unix/Linux/MacOS representam caminhos de formas diferentes.
- os : permite acesso a diversas informações relativas ao sistema como, por exemplo, descobrir quantas CPUs existem no computador que está executando o programa.
- url : permite diversas manipulações envolvendo URLs
Naturalmente, como ocorre com qualquer linguagem de Programação, uma aplicação para web muito provavelmente utilizará diversas outras bibliotecas criadas por terceiros. Para aplicações baseadas no Node, o endereço https://www.npmjs.com/ abriga um repositório com certa de 700 mil módulos.
Leitura Obrigatória |
---|
IO Bloqueante X IO Não-bloqueante no Node.js |
libuv - conceitos básicos |
Post: Node.js Streams: Everything you need to know |
Leitura Sugerida |
---|
Node Event Loop |
Por quê não bloquear Event Loop e Worker Pool? |
V8 JavaScript engine |