Tente ao menos ser elegante

Outro dia desses, pensando sobre software, arquitetura de sistemas, gente e a natureza falível de tudo isso, enquanto tomava a primeira xícara de café do dia, recém coado, me lembrei de um trecho do livro ZeroMQ, do Pieter Hintjens:

Most people who speak of “reliability” don’t really know what they mean by it. We can only define reliability in terms of failure. That is, if we can handle a certain set of well defined and understood failures, we are reliable with respect to those failures. No more, no less.

ZeroMQ: Messaging for Many Applications, Pieter Hintjens, page 141.

O que eu, na modéstia do meu intelecto, reduzi à frase:

Um sistema é tão confiável quanto foi preparado para ser.

À qual, eu certamente poderia acrescentar “nem mais, nem menos” ao final. Mas não tenho tanta certeza de que “nem menos” é tão verdade assim. Principalmente, em se tratando de sistemas distribuídos; ainda que se tenha um projeto super minucioso. Vou deixar com está.

Foi daí que saiu a motivação de fazer este post. Porque este é o tipo de assunto que fica subentendido em muitas conversas que temos em nosso dia a dia, projetando, implementando e mantendo software em produção. Parece que todo mundo sabe do que está falando, presume que a pessoa ao lado sabe do que está falando e que estão todos na mesma página, falando sobre a mesma coisa, partindo das mesma premissas e reconhecendo as mesmas limitações. Ainda mais em tempos de cloud, microsserviços, serverless e essa coisa toda.

Entretanto, embora isto deva ser bem óbvio, acho que devo pecar pelo excesso aqui e dizer que não tenho a pretensão de escrever um post que esgote o assunto “confiabilidade” de software e sistemas. Aliás, muito longe disto. A literatura sobre confiabilidade de software e sistemas é vasta e impossível de se cobrir em um post. Eu nem mesmo já consumi toda a literatura de que tenho ciência da existência. Portanto, não assuma que isto é tudo.

A ideia é ser um pequeno aperitivo, que desperte o seu apetite e te leve a buscar por mais.

Confiabilidade de Software

Para começar, então, vamos trazer um pouco de definição à mesa.

Michael R. Lyu, no livro Handbook of Software Reliability Engineering, define confiabilidade de software como sendo:

The probability of failure-free software operation for a specified period of time in a specified environment [ANSI91].

Michael R. Lyu, Handbook of Software Reliability Engineering, page 5. [pdf]

Ele expande ainda um pouco mais esta ideia, logo em seguida, explicando que a confiabilidade de software é um dos atributos da qualidade de software ⎼ que por si só é uma propriedade multidimensional, que inclui outros fatores também ligados à satisfação do cliente, como funcionalidade, usabilidade, performance, suportabilidade, capacidade, instalabilidade, manutenibilidade e documentação.

Martin Kleppmann, no livro Designing Data-Intensive Applications, falando sobre confiabilidade de sistemas de software, diz o seguinte:

The system should continue to work correctly (performing the correct function at the desired level of performance) even in the face of adversity (hardware or software faults, and even human error).

Martin Kleppmann, Designing Data-Intensive Applications, page 6.

Mais adiante, ele fala sobre o que tipicamente se espera de um software ⎼ que ele realize as funcionalidade esperadas pelo usuário; tolere erros cometidos do usuário; tenha desempenho bom o bastante para os casos de uso suportados, dentro do volume de dados esperado; e que previna acessos não autorizados e abusivos. Resumindo, o que ele diz é que o software deve continuar funcionando corretamente, mesmo quando as circunstâncias são adversas.

Enquanto Lyu apresenta 3 pontos importantíssimos para qualquer discussão sobre confiabilidade de software:

– Probabilidade de ausência de falhas;
– Por período específico;
– Em ambiente específico.

Kleppmann fala sobre continuidade de funcionamento correto dos sistemas mesmo quando as circunstâncias não são favoráveis a isto.

Aos sistemas que toleram tais situações adversas, ou falhas, damos o rótulo de “tolerantes à falhas”. Mas isto não significa que sejam tolerantes a todas e quaisquer falhas, porque isto seria absolutamente impossível. Há sempre uma restrição imposta por tempo para se projetar, implementar e testar uma solução, custo de viabilização, habilidade humana para realização e tamanho do retorno sobre todo investimento empregado. Em outras palavras, ter “tudo” nunca é uma opção.

Este é o ponto chave da menção que fiz de Hintjens no início deste post: um sistema tolerante à falhas é um sistema que está preparado para lidar com aquelas falhas que são bem conhecidas e compreendidas, em cujo qual certo esforço foi empregado para prepará-lo para tal. Nem mais, nem menos.

Agora, seja lá qual for a régua que adotemos, a verdade fria da coisa é que a confiabilidade de software é um fator crucial na satisfação do cliente, uma vez que falhas de software podem tornar um sistema completamente inoperante.

E não estou me referindo apenas a sistemas de ultra missão crítica, que podem causar a morte de pessoas, destruir vidas de verdade, como alguns exemplos que Pan oferece em seu artigo, mas de coisas mais mundanas mesmo, como Kleppmann cita em seu livro. Quero dizer, bugs em aplicações de negócio, que fazem seus usuários perder produtividade; indisponibilidades em e-commerces que impedem que vendas sejam concluídas; produtos de software que são vendidos, mas nunca provisionados no prazo prometido; e por aí vai. Você provavelmente deve ter ótimos exemplos muito melhores do que estes.

É claro que há momentos em que, conscientemente, decidimos sacrificar a confiabilidade de um sistema para reduzir seu custo inicial de desenvolvimento, porque estamos, por exemplo, desenvolvendo um protótipo ou MVP, que pode acabar indo para o lixo; ou pode ser o caso de termos que manter o custo total de um produto super baixo, porque sua margem de lucro não justifica nada além do mínimo possível. Ou simplesmente porque aquela funcionalidade incrível, que ninguém pode viver mais um dia sem, era para ontem.

Quem sabe se não é essa tal funcionalidade incrível, ainda que meio bugada, que vai fazer a diferença entre o sucesso ou a ruína de um produto?

Temos que estar realmente muito conscientes das decisões que tomamos, que afetam direta ou indiretamente a confiabilidade dos softwares que de algum modo comercializamos, já que este é um fator tão fundamental à satisfação dos nossos clientes.

Precisamos estar seguros em relação a pelo menos estes seis pontos:

– De onde estamos cortando;
– Porque estamos cortando;
– Por quanto tempo estamos cortando;
– Se é um corte remediável no futuro;
– O quanto o usuário vai sofrer com os cortes;
– E o quanto estamos ganhando com tudo isto.

Não se pode ter tudo. Mas pode-se ter consciência sobre o que não se tem e suas consequências imediatas e futuras.

A triste realidade de software

Diferente de bens materiais, como um carro, uma torneira, uma fotocópia, ou um equipamento eletroeletrônico qualquer, um software não envelhece, não enferruja, não se desgasta e perde funcionalidade com o tempo. Dado que todas as condições externas ao software, às quais ele depende, sejam exatamente as mesmas, não importa quantos anos de existência tenha uma determinada versão do software, ela vai funcionar tão bem (ou tão mal) quanto da primeira vez. Nhmmmm, sort of…

A verdade deprimente é que softwares que rodam continuamente por longos períodos de tempo, fazendo qualquer coisa minimamente importante para alguém, ainda que sob as mesmas [supostas] condições de ambiente nos quais estão inseridos (hardware, sistema operacional, rede, etc), com o tempo, vão se degradando, porque estas [supostas] condições externas vão se alterando e assim também suas próprias condições internas, ainda que seu código fonte ou mesmo binário permaneçam absolutamente inalterados.

Daí a importância de se ter um time de operação capacitado, munido com ferramentas de gestão de configuração, monitoria, telemetria e todo o mais possível.

Isto é, tanto quanto o conteúdo do código fonte mantém-se intacto quando este é submetido à execução, também a sua qualidade permanece a mesma. O que significa que, quanto menor a qualidade do código fonte, maior a probabilidade de seu estado em execução se degradar em um menor espaço de tempo e resultar em incidentes em produção ⎼ o que, em última análise, se traduz em cliente insatisfeito e imagem da organização arranhada.

E isto não é tudo. Em um ensaio sobre confiabilidade de software da Carnegie Mellon University, Jiantao Pan escreve que a complexidade de um software está inversamente relacionada à sua confiabilidade; quanto maior sua complexidade, menor sua confiabilidade. Já a relação entre complexidade e funcionalidade tende a ser causal; quando maior o número de funcionalidades, maior sua complexidade.

Lembra-se da máxima de que menos é mais? E daquela estimativa de que 80% dos usuários usam apenas 20% das funcionalidades? Pois é, chunchar funcionalidades para competir pode ser não apenas uma estratégia ineficaz, como também um ofensor da confiabilidade do sistema como um todo.

Diferentemente do que acontece com bens materiais, como disse há pouco, que vão se deteriorando e sofrendo aumento na ocorrência de falhas ao longo de sua vida útil, softwares tendem a ter maior ocorrência de falhas em sua fase inicial e muito menos em sua fase de obsolescência.

Fonte: https://www.beningo.com/understanding-embedded-system-failure-rates/

Note que o que causa aumento da chance de falhas, com efeito levemente residual, é justamente a implantação de novas versões; em especial, se elas trouxerem consigo novas funcionalidades. Não que esta informação deva nos deixar acanhados de publicar novas versões, absolutamente. O que devemos, sim, é não ignorá-la. Precisamos ser mais responsáveis com o que publicamos em produção, investindo tempo em testes melhores, detectando falhas mais rapidamente e oferecendo maneiras de revertê-las o quanto antes.

A bem da verdade, até mesmo novas capacidades que visam aumentar a confiabilidade podem causar novas falhas. Senão por falhas no código em si, resultado do novo ciclo (seja ele de um dia ou um mês) de projeto, implementação e teste, pelo simples fato do ambiente sistêmico de produção não ser exatamente o mesmo de desenvolvimento. Eles podem até ser muito, muito, semelhantes um ao outro, mas frequentemente não são o mesmo. Muitas vezes, o ambiente de produção de ontem nem mesmo é o de hoje. Quem dirá, o de uma semana atrás.

Dispositivos móveis são um ótimo exemplo disto. Você nunca tem certeza sobre o estado que vai estar o dispositivo móvel quando seu aplicativo for instalado. Isso sem falar em sistemas embarcados.

Outro exemplo é o caso de softwares que compõem um sistema distribuído. Uma nova versão de um software coincide com uma nova versão de outro software, que torna-os de algum modo conflitantes e incompatíveis. Pau!

O fator “pessoas”

Eu acho um baita clichê quando dizem que tudo é sobre pessoas; ou alguma variação disso, com o mesmo significado. Mas no final das contas, a verdade é que é isso mesmo.

As pessoas projetam expectativas. As pessoas fazem promessas. As pessoas não conseguem cumprir suas promessas. As pessoas sequer conseguem fazer as coisas direito 100% do tempo. Porque no final das contas, como dizem, isto é ser humano, não é? É, pois é.

Em seu ensaio sobre confiabilidade de software, Pan faz referência ao paper de Peter A. Keiller e Douglas R. Miller, intitulado On the use and the performance of software reliability growth models, e escreve que falhas de software podem ser decorrente de erros, ambiguidades, omissões ou má interpretações da especificação; podem ser também descuido ou incompetência na escrita do código, teste inadequado, uso incorreto ou inesperado do software; ou outros problemas imprevistos. Em resumo, pessoas sendo pessoas.

E a despeito dele ter escrito isto em 1999, mesmo com o posterior advento do desenvolvimento de software ágil, ainda não erradicamos os fatores causadores de “falhas de software”. Diminuímos, sim, mas não eliminamos. Talvez porque, como ele diz, eles estejam intimamente relacionados a fatores humanos confusos e ao processo de projeto da coisa que estamos construindo, os quais ainda não temos um entendimento sólido. Pessoas, right?

O guard-rail

Se você chegou até aqui, talvez esteja se perguntando: se não dá para erradicar completamente, tem que dar ao menos para controlar a magnitude, mitigar de alguma maneira, certo?

Sim, certo. Nem tudo está perdido. Já vimos algumas dicas ao longo do caminho.

Em muitos casos, com processos de desenvolvimento sólidos, com ciclos curtos de projeto, implementação e teste; com práticas de engenharia cínicas, que sempre esperam pelo pior; com engenheiros de qualidade que caçam pêlo em ovo; com processos de governança super defensivos; e com real atenção à operação desde a concepção do que se está construindo.

Em outros casos, sentando e chorando. Okay, este último não é exatamente uma opção.

Ciclos menores

Promover ciclos menores de “concepção à produção” é uma ótima prática para se adotar, se não por tudo que vimos até aqui (especialmente a questão dos pacotes novo em produção), porque as pessoas tem limiares de atenção diferentes. Mas não é só isso.

Muitas vezes, a velocidade do negócio e suas incertezas nos obrigam a mudar de foco antes de conseguirmos concluir uma determinada atividade e isto é super frustrante, mesmo que você tente entrar naquele estado mental de “estamos fazendo isso pelo negócio”, “este é o melhor a se fazer”, “estamos aqui pelo cliente”. Yeah, I get it. Mas ainda assim é frustrante. Como seres humanos nos sentimos frustrados de começar algo e não concluir, seja por desinteresse momentâneo ou por força maior. Então, o quanto antes concluirmos, melhor.

Então, quanto menores? Tão pequenos quanto o suficiente para se fazer um bom projeto, implementação limpa e testes de verdade. Em alguns casos, tudo isto pode acontecer em horas; em outros, pode levar semanas. O importante é que se saiba bem o que se está programando e como testar que tudo está saindo conforme o esperado.

Quebrar grandes requisitos em diversos requisitos menores, que vão se complementando, é um caminho para se ter ciclos menores. Com certeza, é um bom começo.

Transparência

Aqui me refiro à transparência do software rodando em produção, como ensina Michael T. Nygard, em Release It, na página 226.

Nygard diz que um sistema sem transparência não pode sobreviver por muito tempo em produção. Ele diz que se os administradores do sistema não sabem o que os componentes do sistema estão fazendo, eles não podem otimizar este sistema. Bem, seria muito perigoso mexer no que não se tem controle. E não se pode ter controle do que não se faz a menor ideia.

Se os desenvolvedores não sabem “o que funciona” e “o que não funciona” em produção, eles não podem aumentar a confiabilidade e resiliência do sistema ao longo do tempo.

E quanto aos sponsors de negócio? Se eles não sabem se eles estão ganhando dinheiro com o sistema, eles não podem investir em trabalho futuro de melhoria, etc.

Ele conclui, então, em tom meio apocalíptico, penso eu, dizendo que um sistema sem transparência vai seguir em decadência, funcionando um pouco pior a cada nova versão.

Segundo Nygard ⎼ e tendo a concordar fortemente, depois de anos e anos escrevendo software e mantendo-os em produção ⎼, sistemas que amadurecem bem são sistemas que oferecem algum grau de transparência.

elegÂnCIA

Vimos que não é possível um sistema ser tolerante a toda e qualquer falha possível e imaginável. Devemos priorizar de modo consciente quais falhas toleraremos, para mantermos o funcionamento correto do sistema para o usuário final, se possível, sem que sequer se perceba que houve algo de errado.

Reliability means making systems work correctly, even when faults occur. Faults can be in hardware (typically random and uncorrelated), software (bugs are typically systematic and hard to deal with), and humans (who inevitably make mistakes from time to time). Fault-tolerance techniques can hide certain types of faults from the end user.

Martin Kleppmann, Designing Data-Intensive Applications, page 22.

Mas haverá momentos, como já vimos, em que não será possível manter funcionalidades completas do sistema diante de certas falhas, seja por decisão ou impossibilidade técnica. Nestes momentos, precisaremos recorrer a outros artifícios, se não quisermos torturar nosso cliente com tanta insatisfação. Graceful Degradation é um desses artifícios, mas ele não vem “de graça”.

One approach to achieving software robustness is graceful degradation. Graceful degradation is the property that individual component failures reduce system functionality rather than cause a complete system failure. However, current practice for achieving graceful degradation requires a specific engineering effort enumerating every failure mode to be handled at design time, and devising a specific procedure for each failure [Herlihy91].

Charles P. Shelton and Philip Koopman, Developing a Software Architecture for Graceful Degradation in an Elevator Control System. [pdf]

A “degradação elegante” de um sistema, chamemos assim, é um atributo que às vezes é confundido com a tolerância à falhas, mas há diferença entre eles.

Enquanto a tolerância à falhas diz respeito a lidar com componentes falhos do sistema, fazendo algum tipo de substituição, alguma manobra, que não cause degradação da experiência do usuário e continue a prover as funcionalidades esperadas, a degradação elegante, é a última instância do gerenciamento eficaz de falhas, quando a falha em componentes do sistema chega a um ponto em que não é mais possível manter as funcionalidades completas, com desempenho adequado, mas apenas parte delas, visando sobretudo evitar falhas generalizadas.

The purpose of graceful degradation is to prevent catastrophic failure. Ideally, even the simultaneous loss of multiple components does not cause downtime in a system with this feature. In graceful degradation, the operating efficiency or speed declines gradually as an increasing number of components fail.

Graceful Degradation, TechTarget. [link]

Então, quando tudo for inevitavelmente para o despenhadeiro, porque o seu sistema chegou ao limite do quão confiável foi preparado para ser, tente ao menos ser elegante.

Rust, Node.js e a raiz de todos os males

Eu costumo usar Node.js com certa frequência, tanto no trabalho quanto em coisas pessoais. É um script aqui, uma API ali, uma CLI acolá. Em parte porque eu gosto de escrever JavaScript; gosto de linguagens de programação com chaves e ponto-e-vírgula. Mas também porque é super prático, tem lib para tudo que é coisa que você queira fazer e a performance é geralmente entre aceitável e boa. Quero dizer, se você colocar na balança a velocidade de desenvolvimento, as ferramentas disponíveis e o footprint vs. a performance de execução, para muitos casos, Node.js é bom o bastante.

Não é de hoje esse meu “estranho” gosto por JavaScript, {} e ;.

Mas não me entenda mal, Node.js tem certamente seus downsides. O principal deles talvez nem seja técnico, mas sim o que meu amigo Phillip Calçado apresentou na GOTO 2013, que está relacionado à ausência de boas práticas de design de software ou simplesmente o emprego das menos recomendáveis. Acredito que, em muitos casos, não por desleixo, mas por pura falta de conhecimento mesmo, porque há coisa de 10 anos, quando Node.js veio ao mundo e começamos a usar JavaScript no servidor, o emprego de boas práticas de programação no browser ainda era insignificante. Isso de alguma forma foi parar no servidor.

Conheça as ferramentas do seu ofício

Além de aprender boas práticas de engenharia de software e design de código em diferentes paradigmas de programação (estruturado, oo e funcional, por exemplo), é importante que se aprenda também um pouco mais sobre como funcionam as plataformas em si. Neste caso, Node.js. Com certeza isso fará diferença na hora de implementar aplicações de verdade e operá-las em produção sem [tanta] dor de cabeça.

Em se tratando de Node.js, a coisa mais importante a se entender é o seu Event Loop, porque é onde a magia acontece ou a coisa engripa. Se você tem uma base de ciência da computação, vai logo se lembrar que event loop não é um conceito novo que o Node.js inventou. É na verdade um design pattern bem mais antigo. E provavelmente, você vai perceber que há ainda um outro design pattern na jogada, o reactor ⎼ que muitas vezes é tratado como se fosse a mesma coisa, mas não é.

Fonte: Building and understanding reactive microservices using Eclipse Vert.x and distributed tracing.
Fonte: Node.js event loop architecture.

Se você quiser aprender mais sobre o Event Loop do Node.js em especial, recomendo muito esta ótima série de artigos, este aqui e este outro aqui também.

Reconheça suas limitações

Superada as limitações de conhecimento de programação, da linguagem e da plataforma, chegamos então às verdadeiras limitações da plataforma.

Se você chegou até aqui, provavelmente vai concordar comigo que é uma generalização dizer que Node.js é single-threaded. Mas o fato é que, na prática, para todos os efeitos, isto é verdade. Por isso, deve-se fazer todo possível para não bloquear o event loop, do contrário, a aplicação vai ficar enroscada.

Isto faz com que Node.js não seja um bom candidato para rotinas com processamento pesado, com muitos cálculos e algoritmos complexos, pelo simples fato de que isso leva tempo para executar e bloqueia o event loop, o que no final das contas, acaba limitando o throughput da aplicação.

Temos algumas soluções aqui. Uma delas é escrever essas rotinas em outra linguagem mais poderosa, compilada, com suporte a multithreading, etc e executá-las totalmente apartadas. Outra solução é trazer o poder dessas linguagens para dentro da nossa aplicação Node.js.

Worker Threads parecem interessantes também. Mas tenho zero experiência com elas.

Peça ajuda

C++ Addons tornam possível escrever funções super otimizadas, multi-threaded e tudo mais, que podem ser usadas em Node.js de modo totalmente natural, como se fossem funções comuns do JavaScript.

Addons are dynamically-linked shared objects written in C++. The require() function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.

The Documentation [link]

Agora, o fato é que escrever em C++ não é uma tarefa simples. C++ é uma linguagem extraordinariamente poderosa, mas não é fácil de se domar. Quero dizer, programar bem em C++ não é coisa que se aprende em um mês ou dois de vídeos no YouTube.

É agora que recorremos à analogia das facas? C++ é a faca do sushiman, enquanto que JavaScript é a faquinha de rocambole.

Portanto, trata-se de uma otimização com custo alto.

Rust entra no jogo

Rust também não é uma linguagem simples de aprender. Sua curva de aprendizagem é íngreme. Bem íngrime. No entanto, ela é mais segura para quem está aprendendo do que C++, com toda certeza.

Com Rust é possível escrever código com performance compatível com C++, porém com memory safety e fearless concurrency, para usar os jargões da linguagem. O que, neste caso, tornaria o custo de otimização de um programa Node.js que chegou ao seu limite mais acessível.

Será que isto é possível? A resposta é sim. É possível escrever extensões nativas para Node.js em Rust já faz bastante tempo. Mas eu, só há umas duas semanas me dei conta disto e acabei descobrindo uma ferramenta que torna isto muito, muito, fácil mesmo.

O que Neon faz é oferecer um conjunto de ferramentas e bindings para facilitar escrever código em Rust, gerar uma biblioteca nativa e usar em Node.js como se fosse uma função JavaScript qualquer, exatamente como seria com C ou C++.

Diferente de algumas soluções em que se usa apenas FFI para fazer chamadas a bibliotecas nativas, que obviamente poderiam ser escritas em qualquer linguagem, Neon faz bind direto na API da V8, para interagir com JavaScript. O que para eles é um problema, porque quando a API da V8 muda, eles precisam mudar também. Por isso há uma iniciativa de implementar o bind via N-API.

Anatomia de uma extensão Neon

Para ter uma ideia de como é escrever uma extensão em Rust com a ajuda de Neon, fiz um projeto de teste, um contador de palavras básico. O processo foi muito simples e sem enroscos. Fiquei realmente surpreso.

Uma das coisas que me agradou bastante é que a ferramenta cria uma estrutura de projeto padronizada, bem organizada, onde você tem um diretório para a biblioteca Node.js e um para a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter

No diretório lib, você tem um arquivo index.js, que é o ponto de entrada da biblioteca, e que faz nada mais nada menos do que importar e exportar a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/lib/index.js

Já no diretório native, você tem um diretório padrão de projetos Cargo.

Fonte: https://github.com/leandrosilva/verbum-counter/tree/master/native

Em src, você tem um arquivo lib.rs onde a magia acontece.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/native/src/lib.rs

Okay. Neste caso, a magia não é tão encantadora assim.

O que acontece, aqui, é que eu exporto uma função que cria um tarefa de contagem de palavras, agenda ela para executar num futuro próximo e passa adiante o callback que recebeu do código JavaScript.

Esta função, depois, é usada no código JavaScript sem que fique aparente que ela é uma função externa, implementada em Rust.

Versão em puro JavaScript vs. com Addon em Rust.

Cuidado com a otimização prematura

Foi uma experiência bem legal escrever essa extensão e provavelmente devo escrever mais no futuro. Mas a lição mais importante que gostaria de deixar, aqui, neste post, é que você deveria procurar aprender cada vez mais sobre a sua profissão e suas ferramentas de trabalho, tanto as que usa hoje, quanto as que poderá usar amanhã ou depois, antes de qualquer outra coisa.

Em outras palavras, não é porque é possível e relativamente fácil escrever extensões nativas em Rust e usar transparentemente em Node.js, que você vai sair correndo reescrevendo tudo para fazer seu programa voar. Antes de partir para uma abordagem destas é preciso ter certeza de que você empregou o melhor design possível e esgotou todos os recursos “normais” da plataforma. Porque trazer para o jogo uma nova linguagem não é algo gratuito, muito embora pareça ser ⎼ e traz também consigo novos problemas.

Por exemplo, no caso deste contador de palavras, a versão que usa a biblioteca nativa em Rust (que inclusive usa Rayon para paralelismo) performa pior do que a versão em JavaScript puro, quando o arquivo texto não é grande o bastante. Isto porque a mudança de contexto entre JavaScript e Rust tem seu custo.

Há um limiar a partir do qual uma otimização mais hardcore é realmente necessária. Até que se ultrapasse esse limiar, o melhor que se tem a fazer é fazer o melhor com o que se tem nas mãos.

Como disse Donald Knuth: “A otimização prematura é a raiz de todos os males”.

Monolitos, microsserviços e os guardiões de dados

No meu post anterior, falei sobre dados e arquiteturas de sistemas, enfatizando a importância de se cuidar dos dados de uma organização desde sua forma mais fundamental, no momento de seu primeiro registro histórico, ainda em estado bruto, de modo a favorecer a evolução e incremento de seu uso nos negócios atuais da organização e do futuro que está além do horizonte, uma vez que dados tendem a ter longevidade maior do que aplicações.

We observed that database systems are characterized by longevity of data. They usually involve a large volume of data that can last for a considerable period of time. Furthermore, the longevity of data and its impact in long term made it a valuable asset that had to be well managed. Given the longevity of data it is difficult to foresee all its possible uses, and hence it is impracticable to define a specific interface that would satisfy all applications developed during its lifetime.

Jack Campin, Daniel Chan, and David Harper, Specifications of Database Systems, page 316.

O ponto chave aqui é que, ao registrar os dados que entram e que nascem em uma organização, deve-se levar em conta não apenas o uso que se faz deles hoje, mas que se poderá fazer no futuro. Muitos produtos e oportunidades de negócios já nasceram a partir de dados que foram coletados e armazenados com uma finalidade, mas que em um outro momento, mostraram-se úteis para muito mais do que se pensou.

Portanto, cuide dos dados de sua organização tão bem quanto possível.

Se não pelas possíveis oportunidades de negócios futuras, pela própria evolução dos sistemas que o sucesso do seu negócio atual deve cobrar, cedo ou tarde.

O nascimento de um sistema

Fazer um startup com um sistema monolítico (a.k.a. monolito) é provavelmente a abordagem mais sensata, com raras exceções. Uma delas seria o caso do mercado de APIs, porque a própria natureza do negócio tende a favorecer certa granularidade.

O problema de se começar um projeto orientado a serviços (a.k.a. microsserviços, no dias atuais) é o investimento inicial que se precisa fazer em análise e design, para se definir contextos, fronteiras, correndo o risco de se incorrer no erro de big design up front, granularidade exagerada dos componentes de sistema e terminar com uma arquitetura mais complexa do que o necessário para suportar o momento atual do negócio. Pior ainda, pode-se terminar com uma arquitetura mais complexa do que se possa manter com a equipe atual. Incidentes à vista!

So my primary guideline would be don’t even consider microservices unless you have a system that’s too complex to manage as a monolith. The majority of software systems should be built as a single monolithic application. Do pay attention to good modularity within that monolith, but don’t try to separate it into separate services.

Martin Fowler, Microservice Premium [link]

Especialmente para startups, além de favorecer a simplicidade de design e implementação, começar um projeto como monolito trás a vantagem de abreviar o tempo que se tem entre a ideia e o lançamento do produto; e depois, o aprendizado que se tem ao longo de seu amadurecimento. À medida que o negócio vai escalando, a demanda do sistema vai aumentando, pedindo não somente por disponibilidade, performance, escalabilidade, resiliência, devido ao seu sucesso, mas também por velocidade de implementação de novas funcionalidades para o usuário final, com qualidade e sem afetar o que já existe.

Este aprendizado e experiência com o sistema em produção é fundamental para o amadurecimento do time (não somente como indivíduos, mas como grupo) e o sucesso de uma empreitada de microsserviços.

Fonte: https://martinfowler.com/bliki/MonolithFirst.html

Porque o time que trabalhava junto, com todas as mãos na mesma massa, agora, por força do aumento de demanda do negócio e complexidade do sistema que se formou, vai ter que se dividir para conquistar, se quiser recuperar a produtividade.

Fonte: https://martinfowler.com/bliki/MicroservicePremium.html

Cada pequeno grupo, agora, vai ter que assumir a responsabilidade por uma parte do todo; vai ter que caminhar de forma autônoma, com desafios e objetivos próprios, porém interagindo e contribuindo com os demais grupos por um bem comum ⎼ que em última análise é a missão da organização.

Voltamos então à questão dos dados.

Evolução e escala dependem de dados

Quando os times começam a se dividir e, com isto, dividir também o monolito que os trouxe até o ponto de sucesso onde estão, um dos primeiros problemas com que precisam lidar diz respeito aos dados.

Dentre as principais motivações para se implementar uma arquitetura baseada em microsserviços é que cada time tenha autonomia para evoluir seus microsserviços e implantá-los em produção sem depender rigidamente de outros times. Idealmente, cada microsserviço deve estar fracamente acoplado a outros microsserviços. Isto não é possível no padrão tradicionalmente adotado por monolitos, onde um único banco de dados está no centro da arquitetura, porque qualquer mudança feita no schema deste, para atender a um determinado microsserviço, pode impactar diretamente outros tantos microsserviços.

Acredite em mim quando digo que não é preciso ser muito azarado para causar um problema deste tipo em produção. Anos e anos de integração de aplicações via banco de dados já provaram isso.

Mesmo que mudanças no schema do banco de dados não sejam um problema para sua organização, porque ela tem um processo de governança super rígido e centralizado, que possibilite controlar a menor mudança que seja no banco de dados e supostamente garantir que não ocorra incidentes decorrentes delas, ainda assim, haveria o problema de escalabilidade, disponibilidade e performance.

Afinal de contas, você não vai querer que o uso que um microsserviço faz do banco de dados penalize outros microsserviços. Ou que uma falha no banco de dados afete todos os microsserviços que estão conectados a ele. Isto não seria muito melhor que um monolito. Seria ok para um primeiro passo, mas aconselhável dar o próximo.

Dividir uma instância de banco de dados não é por si só um problema, desde que se selecione os vizinhos com cuidado, para não promover o chamado noisy neighbor effect. Na verdade, pode ser uma estratégia.

É preciso então considerar alternativas, caminhos que progressivamente viabilizem o desacoplamento.

Modelos de arquitetura de sistemas

No post anterior, vimos um modelo de arquitetura de sistemas centrado em um fluxo de dados imutáveis, que são perpetuamente registrados (como fatos históricos) e constantemente propagados para quem possa interessar. Na prática, um log.

Como escrevi lá e repito aqui: log stream é apenas um dos modelos possíveis. Há muitos modelos de arquitetura documentados, que dão conta de dados e integração de sistemas, como 123 e 4, por exemplo. É importante conhecer pelos menos alguns e ter senso crítico na hora da adoção.

Portanto, hoje, quero apresentar um outro modelo, um padrão que pode ser implementado sozinho ou combinado com log stream.

Dados pedem um guardião

Se você já foi exposto a sistemas distribuídos por algum tempo, certamente já se deu conta do quão verdade é a primeira lei da distribuição de objetos.

First Law of Distributed Object Design: Don’t distribute your objects!

Martin Fowler, Patterns of Enterprise Application Architecture [link].

O mesmo vale para dados em sua forma mais primária; especialmente quando podem ser modificados por mais de uma via. Basta permitir que múltiplas aplicações modifiquem a mesma fonte de dados (uma tabela de RDBMS, por exemplo) e o inferno está instaurado na terra. Com direito a limbo e tudo mais. Digo, cache.

Portanto, o princípio fundamental deste modelo apresentado aqui, conhecido como Database per Service, é que um dado deve pertencer a um e somente um microsserviço; e somente ele deve ter poder para modificá-lo.

Neste modelo, todo acesso a uma fonte de dados é rigidamente guardado por um microsserviço, que deve oferecer uma API de acesso aos interessados. Este padrão de isolamento também é conhecido como Application Database.

Obviamente, como você pode imaginar, há vantagens e desvantagens neste modelo. Vamos ver algumas.

Vantagens

– O acesso direto ao banco de dados é feito por uma única via;
– A consistência dos dados é feita em um ponto único;
– Pode-se modificar o schema do banco sem afetar outros microsserviços;
– É possível escolher o banco mais especializado para a estrutura de dados;
– Maior agilidade da ideia à produção.

Desvantagens

– Mais complexo de gerenciar, pois tem mais componentes;
– Transações precisam ser coordenadas entre múltiplos microsserviços;
– Consultas precisam agregar múltiplas origens de dados de microsserviços;
– Modificações nos dados precisam ser notificadas aos interessados;
– Performance de operações simples podem ser impactadas.

Como você pode ver, são ótimas vantagens, é verdade. Mas as desvantagens não são poucas. No entanto, o ponto chave aqui é que a complexidade é inerente ao problema em mãos. Frente à natureza do problema, qualquer solução que se proponha vai ser complexa de implementar e trazerá consigo desafios.

Que tal pensar em como superar os desafios decorrentes?

Possíveis soluções para os problemas apontados

1. Mais difícil de gerenciar

É nesta hora que automação de processos de operação, infraestrutura como código, serverless e práticas de devops vem bem a calhar.

2. Transações precisam ser coordenadas entre múltiplos microsserviços

Este é um dos maiores desafios de uma arquitetura com database per service. Não é um desafio novo, no entanto. Transações distribuídas tem sido um problema há muitos anos. Desde que os dados precisaram ser distribuídos, provavelmente.

Uma maneira de lidar com esse problema é usando o padrão Saga.

A saga is a sequence of local transactions. Each local transaction updates the database and publishes a message or event to trigger the next local transaction in the saga. If a local transaction fails because it violates a business rule then the saga executes a series of compensating transactions that undo the changes that were made by the preceding local transactions.

Chris Richardson, Saga Pattern [link]

É bom lembrar que, para que isto seja possível, é preciso aceitar a possibilidade de consistência eventual. Temos a tendência de pensar em ACID o tempo todo, mas a verdade é que nem todos os processos de negócio precisam ser assim.

3. Consultas precisam agregar múltiplas origens de dados de microsserviços

Uma das soluções possíveis para este desafio é usar o padrão conhecido como Back-end for Front-end (BFF), que resolve o problema de agregação de dados, criando uma fachada especializada para cada aplicação cliente.

Este padrão resolve não somente o problema prático de se fazer agregação de dados, para promover uma experiência rica nas aplicações clientes, mas principalmente, dois problemas diretamente derivados dele no contexto de sistemas distribuídos: verbosidade e latência de rede que se tem ao se fazer múltiplas requisições a microsserviços para compor um conjunto de dados para uma aplicação cliente.

Fonte: https://philcalcado.com/2015/09/18/the_back_end_for_front_end_pattern_bff.html

Uma outra alternativa bem conhecida é o padrão Command Query Responsibility Segregation (CQRS). A abordagem aqui é criar views read-only, cuidadosamente modeladas (a.k.a. desnormalizadas) para atender ao domínio de negócio local a um microsserviço.

Geralmente, para manter os dados destas views atualizados, se faz uso de eventos, que são publicados pelos microsserviços ao modificarem os dados que guardam ou por mecanismos de change data capture.

4. Modificações nos dados precisam ser notificadas aos interessados

Quando se incorpora os princípios de uma arquitetura orientada a eventos, este problema meio que é a regra do jogo, então não há muito o que dizer a respeito.

O item anterior toca brevemente neste ponto.

Obviamente, não precisa haver uma arquitetura totalmente orientada a eventos para se beneficiar da troca de mensagens, pub/sub & Cia. Basta ter um middleware simples de mensageria e vòila. Sempre que um microsserviço modificar um dado na base, ele publica a novidade em um tópico. Quem tiver interesse no assunto, se inscreve no tópico e faz algo a respeito.

5. Performance de operações simples podem ser impactadas

Sim, fazer um join bem feito entre duas, três, quatro tabelas em um banco de dados relacional, com índices bem construídos, é bem performático na maioria dos casos. É difícil competir com isto. Mas colocando de lado a complexidade de implementação do padrão CQRS, consultas em tabelas desnormalizadas tendem a ser bem mais rápidas.

Tudo a seu tempo

É difícil pensar em uma arquitetura de microsserviços em que o banco de dados seja livremente compartilhado por todos. Especialmente depois de tudo que vimos até aqui. Mas há algumas estratégias para se compartilhar o banco de dados e ainda assim promover algum desacoplamento entre os serviços.

Eu gosto de pensar nestas estratégias como as marchas de um carro, que você vai engatando conforme vai desenvolvendo velocidade. Você até pode pular uma marcha ou outra, mas não adianta sair da primeira direto para a quarta ou quinta. É preciso estar a certa velocidade para tirar proveito de cada uma delas.

Analogamente, eu penso que você pode usar essas estratégias enquanto caminha de um extremo ao outro, de uma arquitetura monolítica para uma com um banco de dados por microsserviço, conforme a velocidade de crescimento da sua organização, modelo de negócios, demanda imposta aos sistemas e habilidades atuais do time.

Mesmo schema, diferentes usuários

Nesta abordagem, cada microsserviço tem seu usuário em um único servidor de banco de dados e compartilha o schema com os demais. Privilégios de leitura e escrita são controlados por usuário.

Evolução dos schemas ainda são um problema e requer um bom trabalho de governança para mantê-lo, bem como os privilégios dos microsserviços. Mas pode ser um caminho para começar a criar uma barreira que delimita quem tem a guarda do que.

Múltiplos schemas, mesmo servidor de banco de dados

Aqui, cada microsserviço tem seu próprio schema, realiza transações em seu próprio schema e participa de sagas. Muito embora não seja o ideal, enquanto o time não tiver condições para implementar Saga, as transações podem ser estendidas aos schemas dos vizinhos.

Nesta abordagem, quando um microsserviço precisa de dados de outro, ele os obtém a partir da API do microsserviço que tem a guarda dos dados desejados. Novamente, embora não seja o ideal, em um primeiro momento, pode-se até fazer um UNION ALL com os schemas dos outros.

É um nível de desacoplamento bem interessante, que pode te levar longe até que a próxima estratégia se faça necessária. Com os microsserviços restritos ao seus próprios schemas, sendo monitorados individualmente, torna-se relativamente fácil migrá-los para instâncias dedicadas, havendo justificativa para isso.

Arrematando

Monolito não é xingamento. É geralmente o melhor começo.

Arquitetura de microsserviços pode estar em voga e todo mundo estar comentando, mas não é fácil de implementar e nem é a solução para todos. Na verdade, ela trás consigo seus próprios desafios. Portanto, deve ser considerada somente quando o estágio do negócio e a demanda que este impõe em seus sistemas de software estejam gritando por isto.

– A organização está em sucesso crescente;
– O time está crescendo para atender a demanda;
– O monolito está super complexo, difícil de manter e evoluir;
– A produtividade está em declínio;
– Boas práticas de automação e devops estão ficando inviáveis;
– Escalabilidade, disponibilidade e performance estão em cheque.

Quando esta hora chegar, lembre-se de seus dados. Ou os incidentes te lembrarão de que você se esqueceu deles.

Idealmente, é bom que cada domínio de dados tenha seu próprio microsserviço guardião e que só ele tenha poder de modificá-los.

ReactiveJ: uma brincadeirinha com Evented I/O

Estive estudando e brincando um pouco com evented I/O nas últimas duas semanas e resolvi então fazer uma implementação disso em Java, usando a Java NIO, que biblioteca de I/O não-blocante da plataforma Java, e seguindo o pattern Reactor.

Bem, o resultado dessa brincandeira pode ser visto aqui, no projeto ReactiveJ.

Ainda é um projeto de brincandeira, uma coisa didática, sem qualquer otimização extra de performance. Portanto, não é production ready. Quem sabe algum dia, caso eu leve a brincadeira a sério.

Se gostarem da brincadeira e quiserem continuar explorando o assunto, tenho mais uns links pra vocês:

Divirtam-se!

GoF Patterns em Ruby

[Novo endereço: leandrosilva.com.br.]

Quem é que nunca ouviu falar do livro Design Patterns da Gang of Four? Muito provavelmente, só todos os programadores do mundo – espero estar certo.

Pois muito bem, acabei de encontrar um link super legal com a implementação desses famosos patterns em Ruby.

E viva o Ruby Way!