sexta-feira, 9 de maio de 2014

Escalabilidade com Node.js e Redis


O Node.js é uma plataforma recente, baseada no conceito C10K, conforme já mencionamos aqui. Apesar de ser muito veloz para transações baseadas em arquivos, devemos tomar alguns cuidados quando temos operações mais complexas, ou que exigem maior consumo de CPU. Também já discutimos isso aqui. Agora, vamos ver como melhorar a experiência do usuário, combinando Threads Webworker com o servidor NoSQL Redis.




Características das aplicações servidoras modernas

O Node.js permite criar servidores C10K típicos: leves, rápidos e escaláveis. Conforme já descrevemos aqui, sua arquitetura single-thread e baseada em loop de eventos funciona muito bem com transações típicamente baseadas em I/O, como:
  • e-Commerce;
  • Chat;
  • Backend de games ou de aplicações móveis.
E se houver necessidade de processamento mais demorado? Por exemplo, um cálculo executado pelo Servidor, ou o acesso a um recurso demorado, como autorização de cartão de crédito, por exemplo? Neste caso, como demonstramos no artigo “Estressando o node.js”, o Servidor vai “travar” e segurar todos os requests até atender ao que causou a operação.


Isso é ruim, certo? Mas tem jeito! Conforme demonstramos no mesmo artigo, podemos resolver isso de duas maneiras básicas: escalando horizontalmente ou verticalmente. Escalar horizontalmente é criar várias instâncias da nossa aplicação Node.js, cada uma em uma VM separada, com um balanceador de carga entre elas. Isso foi demonstrado no referido artigo. Eis o código fonte do balanceador de carga:



Temos uma vários servidores, cada um com seu IP e porta, e os requests são direcionados para eles seguindo algum algoritmo de balanceamento (“round robin”, por exemplo). Se, em algum deles, um usuário solicitar a operação complexa, ele ficará “travado” executando, deixando todos os outros requests na fila. Os requests que caírem em outros servidores, serão processados sem problemas.


Essa é uma boa solução se:
  1. A operação demora pouco (até 15 segundos);
  2. Nosso Servidor é totalmente “stateless”, podendo os requests caírem em qualquer servidor;
  3. A operação não é muito comum, ou seja, nem todos os usuários a solicitam a todo momento.
Agora, se uma destas condições é falsa, podemos ter um problemão:
  1. Se a operação demorar muito, podemos ter vários requests caindo no servidor preso, e isso vai provocar reclamações;
  2. Se o servidor for “stateful”, então temos que usar algum mecanismo de “stick” para manter todos os requests subsequentes de um usuário no mesmo servidor. Isso poderá causar concentração de requests em determinados servidores e, se algum usuário iniciar a operação demorada, vai ser um baita problema!
  3. O uso da operação demorada é mais comum do que imaginamos. Neste caso, muita gente vai prender todos os servidores!

Então, podemos tentar a escalabilidade vertical, abrindo um novo “thread” no Servidor, delegando a execução demorada para ele. Com isso, só o usuário que requisitou a operação demorada ficará “preso”, os outros serão atendidos sem problemas. Eis o código-fonte que usa o pacote NPM “webworker-threads” para isso:



Como vemos, o usuário que invocou a operação demorada só terá sua resposta quando a mesma for concluída. Isso pode ser bom se:
  1. A operação demora relativamente pouco (até 20 segundos);
  2. Não existem outras coisas para o usuário fazer no Cliente da nossa aplicação;
Se a operação demorar mais de 20 segundos, certamente o usuário pensará que houve algum erro e poderá cancelar a mesma. E se for uma aplicação móvel? Usuários móveis não tem muita paciência com demoras.
E se o usuário puder realizar outras transações em nossa aplicação, enquanto espera a operação demorada concluir? Ele não poderá!



Nesse caso, é preciso fazer com que o pedido e a resposta da operação sejam assíncronos para o Cliente também!

Redis ao resgate!

O Redis (http://redis.io) é um banco de dados NoSQL muito rápido e leve, capaz de aguentar C10K e também suporta replicação. Ele é baseado em chave-valor, podendo armazenar vários tipos de dados:
  • Strings;
  • Listas;
  • Sets (conjuntos);
  • Hashes;
  • Sorted Sets;
Juntamente com o Node.js e o MongoDB, sua adoção foi recomendada pelo Technology Radar (http://www.thoughtworks.com/pt/radar/#/platforms), da Thoughtworks, de Janeiro de 2014. Eis o que é falado sobre ele (já traduzido):

O Redis já se provou útil em múltiplos projetos da ThoughtWorks projects, usado como cache estruturado, assim como data store distribuído por vários países.”

O uso do Redis é bem simples. Nós armazenamos o valor, associando-o a uma chave. Ele somente poderá ser recuperado através dessa chave. Ele tem um tutorial interativo muito bem feito (http://try.redis.io/), que vai te mostrar como ele funciona.

Basicamente, podemos armazenar valores dessa forma:

> set chave valor
OK
> get chave
"valor"

E podemos atribuir TTL (time to live) aos nossos dados:

> set chave1 valor1
OK
> expire chave1 10
(integer) 1

Nós fizemos a chave “chave1” durar por 10 segundos, após o que ela será deletada junto com o valor.

O Redis é muito leve, rápido e escalável, aguentando milhares de conexões de clientes. E o Node.js tem um módulo NPM para lidar com servidores Redis: “node-redis” (https://github.com/mranney/node_redis).

Podemos combinar a escalabilidade vertical com um Servidor Redis e tratar o pedido e a resposta de forma totalmente assíncrona, deixando o usuário livre para continuar a usar nossa aplicação, enquanto aguarda o término de uma operação complexa. Eis um fluxo demonstrando isso:


Quando o usuário solicitar a operação demorada, um novo “worker thread” será criado no Servidor para executá-la, liberando a resposta imediatamente para o navegador do Cliente. Um código de transação (um UUID) será devolvido para o Cliente. Esse mesmo código será usado como “chave” para gravar um valor no Redis.

Quando o Worker concluir o trabalho, gravará no Redis o valor usando o código de transação como chave, junto com um TTL.

A aplicação Cliente, seja no navegador ou em um ambiente mobile, poderá solicitar o “status” da operação, logo, o Servidor irá no Redis para saber se o cálculo foi concluindo, usando o código de transação passado pelo Cliente. Se foi concluído, responderá com o resultado.


A verificação de estado poderá ser automática, usando “setInterval()” (http://www.w3schools.com/jsref/met_win_setinterval.asp) ou poderá ser manual, com um botão para que o usuário verifique se a operação terminou.  

Nova solução, não bloqueante


Então, com esse novo recurso, vamos criar uma solução baseada em Node.js, Express e Redis, que conterá três funções em sua API:
  • Raiz (“/”): devolve a página única da aplicação (“index.html”), juntamente com o código Javascript Cliente;
  • Iniciar operação (“/operacao”): Pede ao Servidor para iniciar a operação demorada, recebendo uma resposta JSON ({ 'chave' : identificador - UUID });
  • Verificar (“/operacao/:chave”): Pergunta ao Servidor se a operação terminou, recebendo uma resposta JSON ({ "status" : estado, "valor" : valor }). Vamos convencionar assim:
    • Estado = 1: Não existe o pedido. Pode ter expirado;
    • Estado = 2: A operação ainda não terminou;
    • Estado = 3: A operação terminou e o valor foi retornado na resposta JSON;

Somente a primeira função (“Raiz”) tem uma resposta “text/html”, as outras são “application/json”, então, teremos que usar algum mecanismo no Cliente para disparar requests Ajax, como o jQuery, por exemplo.

O projeto completo da solução está no GitHub e você pode fazer “fork”, ou baixar um ZIP se quiser: https://github.com/cleuton/asyncnode.

Também gravei “Gists” com os trechos de código, para facilitar.

Para começar, recomendo que você instale o Node.js, se é que já não o fez. Depois, instale o Express: “npm install express -g”. Depois, instale o Redis:

Suba o servidor Redis e teste com o cliente (redis-cli).

Rodando o teste


Abra um Terminal ou prompt de comandos, navegue para o diretório onde está o arquivo “app.js” e rode: “node app”.

Abra um navegador e digite: “http://localhost:8080”. A página inicial será mostrada:


Clique no botão “Iniciar” para executar o request “/operacao”, que fará com que o Servidor inicie a operação demorada. Depois, disso, o botão vai se transformar em “Verificar”. Clique nele para saber se já terminou:


Quando a operação terminar, a resposta será assim:


Na janela “Terminal”, você poderá ver quando o “worker thread” foi iniciado e quando terminou:


O projeto


Vamos ver como foi implementado esse projeto. Começando pela camada de apresentação, que é toda feita em HTML 5 + Javascript com jQuery.
O projeto foi feito usando o Express, que usa um mecanismo de template, como o Jade (http://jade-lang.com/), para gerar as páginas HTML. Então, tem uma pasta “views” que contém o template Jade para geração da página. São dois arquivos de template, um geral, com o layout geral da aplicação, e outro mais específico. Vamos ver o arquivo geral (“layout.jade”):

doctype html
html
  head
    title= title
    link(rel='stylesheet', href='/stylesheets/style.css')
    script(type='text/javascript', src='http://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js')
    script(type='text/javascript', src='/javascripts/index.js')
  body
    block content

Há dois blocos nesse template: “head” e “body”. O bloco “head” contém os links para os arquivos Javascript que eu vou usar: a biblioteca jQuery e um arquivo de eventos que eu criei “/javascripts/index.js”.


O template “index.jade” é a página inicial da aplicação:


extends layout
block content
  h1= title
  p Welcome to #{title}
  input(type="button",value="#{botao}",id="btn")
  div(id="saida")

Este template herda a configuração do template “layout.jade”, e redefine o bloco “content”. Nesse bloco, ele coloca um título e um botão, cujos respectivos conteúdos são passados pelo Express. Há duas variáveis: #{title} e #{botao}, que serão substituídas pelo Express, no momento em que ele usar este template.


Na verdade é uma página HTML básica, renderizada desta forma:

<!DOCTYPE html>
<html>
<head>
<title>Operação assíncrona</title>
<link rel="stylesheet" href="/stylesheets/style.css">
<script type="text/javascript"
</script>
<script type="text/javascript"
src="/javascripts/index.js">
</script>
</head>
<body>
<h1>Operação assíncrona</h1>
<p>Welcome to Operação assíncrona</p>
<input type="button" value="Iniciar" id="btn">
<div id="saida"></div>
</body>
</html>

Há um arquivo Javascript cliente, que eu coloquei na pasta “public/javascripts” chamado “index.js”, que é baixado pela página. Este arquivo é o arquivo de eventos jQuery, que controla a camada de apresentação no Cliente:


Ao clicar no botão “Iniciar”, ele vai enviar um request Ajax para a URL “/operacao”, sem enviar parâmetro algum. Neste caso, o Servidor iniciará a operação demorada, e o Cliente mudará seu estado, pois agora está aguardando resposta.

A resposta desse request é um objeto JSON que contém a “chave” para saber se a operação foi concluída. Esta chave é um UUID gerado pelo Servidor. O Cliente a armazena para uso futuro.

Então o rótulo do botão muda para “Verificar” e, ao ser pressionado, enviará o request Ajax “/operacao/:chave”, enviando a chave que recebeu no request inicial como “extra path”. A resposta será um objeto JSON que contém um “status” e um valor. Dependendo do “status”, o Cliente atualiza a “DIV” com o resultado. O status pode ser: 1 - Não encontrou a operação, 2 - Em execução ou 3 - Concluída.

A camada de lógica de negócios é um RESTful Webservice feito com Node.js e Express. O arquivo “app.js” é o iniciador do Serviço e configura as rotas do Express:




Cada “app.get” estabelece uma rota possível, e elas estão dentro do arquivo “index.js”, criado na pasta “routes”:



Cada variável criada dentro de “exports” é uma rota criada, e deve estar associada a um comando “app.get”, dentro do arquivo “app.js”.

A rota inicial é simples, pois ele usa o template “Jade” criado e passa o título e o nome do botão.

A rota “iniciar” é a mais complexa. Temos que fazer o seguinte:
  1. Criar um UUID (node-uuid), que será o identificador da operação;
  2. Criar um “worker thread” (webworker-threads) com a função que calcula a série de fibonacci;
  3. Postar uma mensagem para esse “worker thread”, de modo que calcule o quadragésimo quinto termo;
  4. gravar no Redis um objeto com o número e o estado do cálculo, sob a chave do identificador gerado no primeiro passo;
  5. Criar um “callback” para quando o “worker thread” terminar o cálculo. Esse “callback” atualiza o Redis com o número calculado e o estado de término;
  6. Retornar um objeto JSON para o Cliente com a chave gerada;

A rota “verify” é mais simples. Recebemos um UUID como chave da operação, então temos que:
  1. Verificar se existe esse UUID no servidor Redis (como estamos marcando um TTL de 120 segundos, pode ser que ele tenha sido deletado);
  2. Se existe, e se o estado ainda está como “calculando”, avisa isso ao Cliente;
  3. Se existe e se terminou, retorna a informação ao Cliente;

Conclusão


Conseguimos realizar uma operação demorada sem prender ninguém, nem mesmo o Cliente que a solicitou. É claro que poderíamos ter criado uma verificação automática, sem necessidade do Usuário pressionar o botão, o que é bem simples de fazer.

Dessa forma, nossa aplicação pode atender a muitas conexões simultâneas sem perder desempenho. É claro que, como o cálculo é feito na mesma máquina, pode haver alguma degradação de desempenho, dependendo da complexidade da operação, mas isso poderia ser designado para execução remota também.

Podemos combinar esta escalabilidade assíncrona com a escalabilidade horizontal, aumentando muito a disponibilidade da nossa aplicação Web.

Nenhum comentário:

Postar um comentário