O que incluo em um micro-serviço Node.js

Escrevi um pequeníssimo serviço hoje de manhã (10/12/2015). Acho que é o primeiro servidor usando Node.js que publico como open-source e apesar de só ter algumas linhas, ele ilustra algumas das práticas que eu considero as melhores na plataforma hoje. O código está em disponível em: github.com/yamadapc/github-trending-api.

É um serviço que preciso para um projeto um pouco maior. Ele precisa extrair os repositórios trending do GitHub, por meio do módulo do NPM github-trending. É muito simples, só preciso que ele deixe a lista em um cache e exponha um endpoint que eu possa usar para a puxar.

Esse post lista os serviços e ferramentas que adicionei sem pensar muito; coisas que melhoram meu processo. Uma seleção breve de ferramentas que uso e que são úteis para mim, com comentários sobre cada uma. Espero que sejam úteis para você também.

Estrutura

A base da API usa:

  • express para routing e middlewares
  • bluebirdfaltaram coroutines com generators usando co ou Promise.coroutine, que são o futuro (async/await).
  • bunyanminha biblioteca de logging de escolha, pela facilidade de análise e de jogar no logstash
  • express-promise um pacote que usava quando estava trabalhando com node.js, cerca de um ano atrás e que ainda vale muito a pena (te deixa dar res.json(promessa) ou res.json({ field: promessa })

O express é um framework estilo Sinatra. Ele inclui um gerador de boilerplate, suporte a templates e muitas funções, mas o essencial para mim é o roteamento e a cadeia de middlewares. O express é uma camada que você pode incluir em um servidor escrito em Node.js em uma linha e começar a responder requisições.

Não tenho conhecimento aprofundado do Hapi. Então não irei comparar um com o outro.

Mas o que diria é que o express te deixa começar a ser produtivo imediatamente com o mínimo possível de boilerplate e que ele escala em termos de organização com o devido cuidado.

var app = require('express')();
app.get('/', function(req, res) { res.send("Hello World"); });

É necessário tato para seguir com um setup tão mínimo: não deixe seus módulos crescerem muito! Como uma boa referência, essa é a contagem de linhas para todos os arquivos no express:

11 ./index.js
643 ./lib/application.js
103 ./lib/express.js
36 ./lib/middleware/init.js
51 ./lib/middleware/query.js
489 ./lib/request.js
1053 ./lib/response.js
645 ./lib/router/index.js
176 ./lib/router/layer.js
210 ./lib/router/route.js
300 ./lib/utils.js
173 ./lib/view.js
3890 total

Os maiores arquivos são request e response, porque o core do framework está na forma em que extende as classes built-in do Node.js http.ClientRequest e http.ServerResponse. Mas nenhum dos métodos que adiciona as classes é muito longo ou complicado.

Mas a maior parte dos arquivos fica na linha das 300–400 linhas. Esse é o tamanho que deixo um módulo crescer antes de o quebrar. E o Node.js tem ótimo suporte para quebrar um módulo em vários. Inclusive essa listagem de arquivos mostra ele!

645 ./lib/router/index.js
176 ./lib/router/layer.js
210 ./lib/router/route.js

Em todo o código do interno de lib você pode se referir a router como um módulo só; usando require('./router'); e o require vai retornar './router/index.js'. Se algo começar a crescer sem parar, você pode inclusive incluir um package.json no diretório e sem nenhuma mudança no código fora do módulo que está alterando, vai ser como estivesse dando require em um pacote separado. Outras dependencias; potencialmente outra suite de testes, etc.

(O algoritmo de resolução completo está nessa parte da documentação do node.js)

1. O arquivo gigante com todas as rotas do sistema e todo o pipeline de middlewares quebrando o encapsulamento dos endpoints e fazendo uma sopa de business logic

O exemplo MVC do repositório do express usa um "loader" das rotas. Ele:

  • Lista os módulos em um diretório controllers
  • Pega métodos .show, .index, .create, etc. exportados
  • Os registra seguindo uma convenção (posts.show é registrado para GET /posts/:id)

O "loader" tem cerca de 60 linhas. Insisto em usar uma abstração desse tipo em qualquer projeto um pouco maior.

O express 4 quebrou um pouco esse modelo porque agora você pode definir Routers. Micro aplicações que podem ser exportadas e incluídas. Isso é muito útil, mas mesmo assim acho que eu tentaria criar uma classe Controller que lidasse com a lógica básica da aplicação e evitasse registrar rotas manualmente.

2. A história do menino que se perdeu no app.get('/stuff', function(req, res) { /* yada yada yada */ });

Muito foi escrito sobre a ideia de Thin Controller & Fat Model. Não vou discutir isso. É mais simples que isso para mim. Não podemos escrever funções que precisem de uma tela de 27 polegadas virada verticalmente com a fonte em tamanho 12 para enxergar inteiramente. Não podemos escrever código imprevisível; alguém vai ter que consertar.

Se está manipulando dados, o código pertence à camada de dados. Não misture regras de negócio, HTTP e modelagem de dados. Rotas com centenas de linhas provavelmente não deveriam estar lá. Teste e meça a qualidade do seu código. É muito mais fácil entender o que se sabe funcionar se souber o que tem testes e o que não tem (há mais sobre isso mais adiante).

var resultP = req.organization.checkMaxUsers()
.then(function() {
return Plan
.findById(_.last(req.organization.plans)
.populate('template')
.exec();
})
.then(function(plan) {
// [...] create member etc.
});

O bluebird é uma biblioteca de promessas rápida e cheia de helpers para limpar seu código. Ajuda a evitar o callback-hell com o mínimo de esforço possível. Vale a pena investir nessa biblioteca e na coleção de utilitários lodash. Evito o máximo possível lidar com controle de fluxo assíncrono manualmente. Uma alternativa é basear tudo em eventos, mas é muito mais difícil de testar seu fluxo. E o quão mais rápida ela é que a competição?

Veja http://bluebirdjs.com/docs/benchmarks.html para mais informações

bunyan é uma biblioteca para logging baseada na ideia de usar JSON no seus logs, ao invés de um formato customizado. Isso te salva o tempo de escrever um parser para os seus logs no futuro. Esse post do blog da StrongLoop compara o módulo com o winston. Para mim, é uma decisão simples. A inteligência sobre suas operações é algo muito delicado. Os fatos que não são coletados e analisados, nunca virão a tona. No caso de uma aplicação, se houver uma quantidade muito baixa de monitoramento os problemas aparecerão no limite, quando tudo estiver pegando fogo.

"Só podemos melhorar o que medimos"

Logs usando o “pretty printer” embutido e imprimindo JSON

Custa muito pouco ao construir uma plataforma do zero, se preparar para ter toda uma quantidade grande de informação sobre seu trabalho. O bunyan ajuda em deixar sua informação extremamente acessível. Você pode processar seus logs usando o jq, o executável bunyan incluso no pacote, um script escrito a mão ou um stack ELK (ElasticSearch, LogStash e Kibana). Todos requerem pouco esforço com logs estruturados.

O express-promise é uma pérola de boas interfaces usando promessas para mim. A ideia de tratar promessas como cidadãos de primeira classe no seu código é muito boa. O que ele faz é muito simples; adiciona suporte a promessas aos métodos de resposta do express. Do README do projeto, o que antes era:

app.get('/users/:userId', function(req, res) {
User.find(req.params.userId).then(function(user) {
Project.getMemo(req.params.userId).then(function(memo) {
res.json({
user: user,
memo: memo
});
});
});
});

Vira:

app.use(require('express-promise')());
app.get('/users/:userId', function(req, res) {
res.json({
user: User.find(req.params.userId),
memo: Project.getMemo(req.params.userId)
});
});

Se isso parece magia negra pra você, não se preocupe. O bluebird vem com métodos para fazer tarefas parecidas embutidos:

  • Promise.all — Recebe um Array de valores/promessas para valores ou uma promessa para um Array de valores e retorna uma promessa para o resultado de todas as promessas
  • Promise.props — Recebe um Objeto com valores/promessas para valores ou uma promessa para um Objeto com valores e retorna uma promessa para o resultado de todas as promessas

Interface para a Programação de Aplicações

O módulo que me incentivou a escrever esse post exporta duas interfaces:

  • Um executável standalone: github-trending-api
  • Uma instância de express.Router, que pode ser incluída em outro servidor como uma micro aplicação: app.use(require('github-trending-api'))

Acho que essa foi uma escolha muito razoável. Se tivesse mais partes no sistema, cada uma delas faria isso. Na verdade, é exatamente isso que irá acontecer. Esse módulo está destinado a ser uma peça em um sistema maior. Isolada, testada e facilmente substituível para uma melhor.

O sistema que pretendo escrever não usará Node.js, mas foi mais fácil e rápido escrever esse componente usando a plataforma. Se sua aplicação for composta de partes isoladas, alguns benefícios vão ficar claros em pouco tempo:

  • Suas métricas estarão isoladas. Benchmarks e erros serão relativos a um pedaço do sistema e mais simples de entender e melhorar.
  • Pessoas diferentes podem cuidar de serviços diferentes, usando stacks diferentes, organização diferentes etc.

A comunidade Node.js é extremamente influenciada pelo modelo UNIX onde cada peça faz uma coisa sem necessariamente saber o que a outras peças que interagem com ela fazem. Isso é muito positivo e frequentemente esquecido. Não tenha medo de novos package.jsons, novas configurações de projetos ou novos stacks. Ao invés disso, separe seu sistema de forma granular o suficiente para que as dependências sejam explícitas antes que elas sejam muito difíceis de compreender.

Não por isso acho que vale sair criando centenas de pacotes muito pequenos de cara. Apesar de que acho que o Node.js fornece as ferramentas para lidar com isso muito bem e que é mais difícil se encurralar dessa forma; mostrei uma delas enquanto falava do express.

Um pouco do código que escrevi para essa API:

function getRepositoriesCached(language) {
var cacheKey = ‘repositories’ + (language ? ‘.’ + language : ‘’);
var repositories = router.cache.get(cacheKey);
if(repositories) return repositories; if(router.enableLog) {
log.warn(‘Cache miss for `’ + cacheKey + ‘`. Hitting GitHub.’);
}
return (language ?
trendingAsync(language) :
trendingAsync()).tap(function(repositories) {
router.cache.set(cacheKey, repositories);
});
}
router.get(‘/repositories’, function(req, res) {
res.json(getRepositoriesCached(req.query.language));
});

Toda a lógica do handler está em uma função que não faz ideia do formato da resposta ou da requisição. O código:

  • Recebe uma requisição
  • Checa se tem a resposta em um cache
  • Responde com a resposta se a tiver
  • Senão, bate na API do GitHub, adiciona a resposta ao cache e responde com ela

Como a lógica está numa função separada, isolada do servidor. Digamos que queira evitar que um usuário lide com o cache não existindo (e tendo que esperar alguns segundos pela sua resposta). Posso executar essa função a cada "tempo de vida do cache menos alguns segundos" e vou estar perto de resolver meu problema.

setInterval(getRepositoriesCached, 1000);

Da mesma forma; poderia expor essa função para outra rota que retornasse outros dados. Apoio a ideia de pipelines enormemente.

Testes

O output do Reporter do mocha mocha-spec-cov-alt

Os testes são escritos usando:

Os primeiros três módulos dispensam apresentação. Acho que são bem conhecidos o suficiente. Se mais nada diria que o should é uma escolha melhor que o assert, porque tem erros melhores. Essa é minha razão para o usar, ao menos.

Esse é um módulo que escrevi a muito tempo, para forçar pessoas a escreverem testes. A ideia é que se a cobertura de todos os arquivos de um projeto ou de um arquivo específico estiverem abaixo de um certo threshold, os testes falham. Por padrão o threshold é 80%, o que mostra o quão obsessivo eu era quando escrevi esse pacote.

Acho que não precisamos ir tão longe, mas ainda hoje, ele é o módulo que me dá o relatório sumário mais agradável de se ver e com o setup mais simples. Basta rodar:

$ npm i -g mocha-spec-cov-alt
$ mocha-spec-cov-alt

E o bichinho deveria se configurar automaticamente pra você; esperando um npm test. Com mais uma linha no package.json o lcovOutput, nós ganhamos suporte para o coveralls.io (habilitar o travis e o coveralls automaticamente também é simples e uma issue aberta no repositório). O script também vai adicionar um script do NPM: npm run test-html-cov. Que gera relatório HTML da cobertura:

Se algo der pau; sabem quem culpar. É só dar um toque.

O nock é outro pacote maravilhoso do NPM. É um módulo que facilita o mocking de requisições HTTP. Ele tira todo o trabalho de adicionar um mock para um serviço externo. O uso é:

var nock = require('nock');
nock.recorder.rec();

E ele vai interceptar todas as requisições que seu código fizer enquanto o processo roda e imprimir o código necessário para criar mocks para elas. Ou seja… Você escreve seu código contra um serviço real (digamos um gateway de pagamentos) adiciona essas duas linhas para gerar o mock e joga em um módulo. Agora você pode trocar facilmente entre usar o mock ou interagir com o serviço real. É um hack rápido que pode melhorar muito seu processo de desenvolvimento.

TODO: Publicar o gist como um pacote (você pode ver ele usado no meu projeto node-olhovivo)

Esse é um pequeno helper para fazer stubs serem mais fáceis de escrever em uma suite do Mocha. Ele incentiva o uso de funções falsas descartáveis, ao invés de ficar tentando reproduzir uma interface completa.

Escrevi sobre ele no blog da Toggl alguns meses atrás.

Operações

A suite de testes é rodada no TravisCI que faz upload do relatório lcov de cobertura de testes gerado pelo mocha-spec-cov-alt para o coveralls. Um relatório HTML também pode ser gerado com npm run test-html-cov.

Isso me dá segurança de que minha aplicação funciona. Eu não sou inteligente o suficiente para escrever programas médios sem testes (talvez se for Haskell). O relatório de cobertura é uma forma boa de se incentivar a chegar a 100% de testes. Só de bater o olho, vemos que tipo de caminhos nossa aplicação correu.

Para mim, esse processo de ver um braço do controle de fluxo verde (coberto por testes) e outro vermelho (não coberto) e seguir para tentar cobrir todos os braços importantes, ajudou muitas vezes a diminuir o número de braços no fluxo e eliminar comportamento indefinido. Quase sempre, prefiro um erro a algo inesperado.

O DockerHub escuta por pushes no repositório e cria/publica uma imagem no registro público. Essa imagem tem um tag para cada versão publicada e pode ser botada em produção em qualquer máquina, sem nenhuma configuração. Ativei isso pela GUI do DockerHub na web. Parece que o serviço é de graça para repositórios públicos.

Estou convencido de que mesmo que não seja o Docker, a tendência para onde aponta é o futuro. Aguentem-me um pouco. Aprendi o que era um servidor, pelo AWS. Já dentro da era de cloud-computing e VPSs.

E quando penso nessa transição que não vivi, vejo um crescimento de escala e um avanço da técnica para lidar com ela. Como o vejo, até pouco, servidores eram configurados no bare-metal. Quando uma máquina física é um host, o hardware, drivers, rede, sistema operacional; tudo até o stack de "alto-nível" estão na mão das operações. Cada um desses componentes é único e difícil ou impossível de replicar perfeitamente.

Hoje, posso em menos de 10 minutos lançar 20 cópias idênticas de um servidor atrás de um load-balancer virtual, com armazenamento virtual, CPU virtual, memória virtual. Pago pela performance que uso, ligo e desligo máquinas programaticamente etc. Manejar hardware, drivers ou rede não são tarefa para mim; desde que a conta dos Web Services não seja alta o suficiente não preciso me preocupar com isso.

Um servidor configurado com as bibliotecas dinâmicas, o stack do sistema operacional e os programas necessários para um serviço rodar é algo muito particular, que demanda tempo e cuidado. A prova disso é que em arranjos tradicionais, migrar de uma infraestrutura para a outra é uma tarefa épica. Como garantir que o funcionamento de uma aplicação seja replicada de um servidor cuidadosamente configurado para outro?

O Docker vai um passo além do mainstream e compartimenta todo esse software em uma caixinha (ou muitas) que posso executar no meu MacBook e no meu cluster do EC2 exatamente da mesma forma. Um compartimento não interfere em nada com o outro. É outro stack de rede, outro sistema operacional, filesystem, bibliotecas compartilhadas e etc.

Talvez sejam familiares com a ideia de UniKernels. A ideia por trás deles é um sistema operacional como uma biblioteca. E se eu pudesse pegar todo o software e compilar em um binário publicável? Na minha máquina, as bibliotecas fazem um mock dessa interface e usam meu sistema operacional; na nuvem, usam o mínimo de software possível. Um programa que é responsável por seu stack completo, imutável, compacto e seguro. Não sei nada sobre isso. Mas sei que está por aí e faz sentido para mim. Coisas como o MirageOS.

Acho que entrei em um devaneio sobre algo que não tenho propriedade. Por isso pedi paciência.

Na prática, hoje, o Docker me proporciona a infraestrutura declarativa. Se você tem o Docker instalado na sua máquina, pode rodar o serviço que escrevi com:

$ docker run -it -p 3000:3000 yamadapc/github-trending-api

Fica mais divertido quando você pensa em uma linguagem compilada (como Haskell): que pode criar containers absurdamente pequenos que não contém nada além do mínimo necessário para rodar um binário com bibliotecas dinâmicas. Ou que você pode compilar seu projeto no Linux do seu MacBook em um comando e ter um cache dos assets manejados automaticamente. Ou que você pode escrever seu próprio Heroku. Ou fazer um pastebin que executa código.

Tento seguir os princípios do 12-factor-app lendo a configuração das variáveis do environment sempre que possível e deixando o mínimo de estado na memória. Não é uma boa ideia misturar lógica com configuração. Gosto de flexibilidade, custa pouco tempo, deixa as coisas mais fáceis de testar.

Onde eu chego com tudo isso?

Bom, esse post foi sobre uma série de coisas. Comecei com bibliotecas que uso, práticas que evito e apoio, passei raspando por metodologias de desenvolvimento que me ajudam e terminei com coisas que me animam e que estou testando no meu tempo livre.

Em alguma medida, essas coisas são interessantes para mim porque elas são divertidas, como o Haskell parecia mais divertido para meus daemons de Emacs do que o C ou o Python ou o Node.js. De qualquer forma, a maior parte do que toquei aqui foram coisas que eu levo comigo para exercer meu trabalho, não brincadeiras a toa.

Espero ter exposto algo útil para você.

Shameless Plug: HaskellBR

Tenho gastado bastante tempo com a HaskellBR. Seu site: http://haskellbr.com/. E principalmente seu blog: http://blog.haskellbr.com/.

Acho Haskell uma linguagem muito interessante hoje. E mais do que isso, acho uma linguagem útil. Assim que a série atual do blog acabar "24 dias de Hackage", a tradução dos posts do Franklin Chen sobre pacotes de Haskell, pretendo começar a focar em como a tecnologia se compara na prática contra outras alternativas.

Se é algo que te interessa, entre no blog, no site, no Slack, no IRC, na lista de e-mails, participe do nosso próximo encontro em São Paulo ou dê uma olhada no código no GitHub e na nossa instância privada do Gogs.

Fico devendo um artigo sobre Haskell para programadores JavaScript a fundo, já que acho uma comparação muito interessante, especialmente no suporte a concorrência e async. Um pouco disso está no primeiro post do blog, "Implementando fibonacci em Haskell", que tem um crash-course em Haskell com exemplos em JavaScript.

@yamadapc