Arquitetura do NodeJS I: Uma introdução geral

7 min de leitura
NodeJSJavaScriptInternalsV8Libuv
Disponível também em:English

Desde que a década de 2020 começou, os avanços em Inteligência Artificial e a forma como os desenvolvedores trabalham têm mudado com grande frequência. Hoje, fala-se muito sobre programação em linguagem natural, sem de fato tocar em linhas de código como era feito anteriormente. Isso representa uma grande mudança na forma como olhamos para as máquinas, mas o que de fato mudou na maneira como as máquinas nos olham?

Para um processador, programar em Assembly, Python ou em linguagem natural, são apenas abstrações para os zeros e uns que serão interpretados por suas portas lógicas. O grande valor das abstrações se destaca quando um ser humano precisa interpretar determinada instrução escrita no código ou criar uma nova funcionalidade, permitindo que ele trabalhe de forma mais rápida e eficaz, enquanto os motores da linguagem utilizada lidam com todo o trabalho pesado que acontece nas camadas mais próximas à eletrônica da máquina.

Dentre diversas linguagens de programação, o JavaScript com certeza está entre as mais populares. Uma linguagem que inicialmente era interpretada apenas por browsers, mas que hoje possui superpoderes e está em quase todos os tipos de dispositivos. A fim de trazer suas funcionalidades para o desenvolvimento backend, surge em 2009 o NodeJS, uma nova runtime capaz de interpretar código JavaScript no servidor, que, desde então, consolidou uma quantidade enorme de seguidores em torno de sua comunidade.

Com quase duas décadas de história, muita coisa evoluiu na arquitetura do NodeJS, originando um ecossistema robusto para aplicações de grande porte. Apesar de sua importância no desenvolvimento de software atual, muitos desenvolvedores que utilizam a ferramenta nunca dedicaram tempo para entender como essa enorme engrenagem funciona, e é exatamente nisso que iremos aprofundar nesta sequência de postagens.

OS DESAFIOS DO SERVER-SIDE

Quando saímos de aplicações web para aplicações executadas em servidores, praticamente todas funcionalidades precisam ser repensadas:

  • Compilação: não temos mais um browser para nos auxiliar, agora precisamos fazer com que a linguagem gere código de máquina nativo para os processadores;
  • Operações I/O: como faremos escrita ou leitura em rede ou em disco, tráfego de dados, temporizadores, etc;
  • Uso de memória: como iremos manipular os dados, como serão armazenados e como lidar com diferentes estruturas de dados nativas da linguagem de programação;
  • Concorrência e Paralelismo: como executar instruções em mais de uma thread ou dividir a capacidade de processamento entre as diversas operações do sistema operacional ou da própria aplicação.

OS COMPONENTES DA ENGRENAGEM

Para tratar todos esses pontos, o NodeJS utiliza diversos componentes integrados e recursos do próprio sistema operacional.

Inicialmente, o JavaScript era uma linguagem apenas interpretada e sem foco em otimização, o que a tornava lenta. Porém, em 2008, junto ao primeiro release do Chrome, o Google lançou o V8, uma engine que buscava resolver o problema de lentidão de suas aplicações web complexas, como o Gmail. Este mecanismo impactou a estrutura dos browsers, mudando a execução de “linha por linha” para uma estrutura de compilação para código nativo em tempo real, processo chamado de JIT (Just-in-Time) compilation. Essa etapa acontece em diferentes passos, como o Parsing para uma representação em árvore da estrutura do código chamada AST (Abstract Syntax Tree), a interpretação dessa estrutura realizada pelo Ignition e a conversão final feita pelo TurboFan.

Apesar do V8 não ter sido criado para o NodeJS, ele se tornou um de seus principais componentes. Através dele, conseguimos reproduzir o código que operava no browser diretamente no servidor. Mas esse não é o único recurso necessário, precisamos também das operações de I/O.

Ao codificarmos aplicações server side, lidamos com chamadas ao sistema operacional, como leitura de arquivos, comunicação em rede, temporizadores, etc. Essas operações são conhecidas como operações I/O. Muitas linguagens conseguem realizar uma comunicação direta com o kernel, porém o NodeJS executa código JavaScript através do V8 em um ambiente isolado, ou seja, não é capaz utilizar recursos diretos da máquina.

Imagine que você está transportando uma caixa: você é o sistema operacional, que carrega informações e controla hardware. A caixa que você carrega é o runtime do NodeJS, e lá dentro o código JavaScript está sendo executado. Dessa forma, o código está completamente isolado e, para acessar qualquer recurso externo, seria necessário que alguém “abrisse a caixa”, ou seja, auxiliasse na comunicação com o kernel. Dessa necessidade nasce a Libuv, uma biblioteca escrita em C que permite a comunicação com operações de I/O, gerenciamento de threads e processamento assíncrono.

Arquitetura simplificada do NodeJS
Arquitetura simplificada do NodeJS

Contudo, como a Libuv é escrita em C, para chegarmos ao NodeJS precisamos de algo capaz de integrá-lo ao JavaScript. No ecossistema NodeJS, a “grande cola” entre o que acontece dentro e fora da caixa são os Bindings.

De forma geral, temos dois grandes componentes completamente diferentes e independentes que até então, operam de forma totalmente separadas sem a consciência do que está acontecendo com o outro. Quando nossa aplicação inicia, o V8 é preparado através de um processo de bootstrapping. Nesse momento, o NodeJS utiliza a API do V8 para registrar ponteiros de memória que ligam funções JavaScript a funções nativas em C++. A função internalBinding do NodeJS é responsável por manter esse mapeamento. Assim, quando nós programamos a leitura de um arquivo, os Bindings sabem nos dar o direcionamento para a função da Libuv que, de fato, fará o trabalho pesado.

Fluxo de inicialização e operação de I/O com NodeJS
Fluxo de inicialização e operação de I/O com NodeJS

Dessa forma, temos uma plataforma completa para interpretar JavaScript e permitir que, através dele, manipulemos chamadas ao sistema operacional.

Toda a orquestração desse fluxo, porém, não é tão linear quanto parece. Esse processo depende de mecanismos como o Event Loop, as Event Queues, etc., tópicos que abordaremos nas próximas postagens.