[Escrevendo software para durar]

Aqui apresentarei alguns tópicos que acredito serem importantes na hora de desenvolver um software de forma que ele seja bastante resiliente e que dure por vários anos. Os tópicos não estão em uma ordem particular e são a minha opinião embasada pela minha experiência como desenvolvedor.

Encare esse texto como um conjunto de ideias que coletei pelos anos, eu tentei deixar elas o mais genéricas possível, mas não tenho a pretensão de que elas se apliquem a qualquer cenário. Acredito que elas se encaixam melhor se sua equipe é pequena e você precisa entregar rápido.

Eu tentei deixar o texto genérico, mas muitos dos tópicos são para Golang, minha linguagem favorita no momento.

Foco na simplicidade

Escreva o software da forma mais simples que você puder, com o mínimo de camadas e abstrações. O motivo é que absolutamente nada vem de graça, toda nova camada, toda nova abstração, tudo tem um preço, geralmente pago em tempo de desenvolvimento, mais tempo de manutenção, mais complexidade, e pior, tempo de down-time.

O software deve fazer estritamente o necessário para cumprir a sua função, nada além disso.

Mantenha o stack baixo

Quanto menos ferramentas necessárias para seu software funcionar, melhor.

Por exemplo, pode parecer fácil resolver possíveis problemas de escalabilidade com uma fila e workers que a consomem. Mas além de muitas vezes ser desnecessário colocar uma fila significa que você adicionou na sua estrutura vários itens que antes você não tinha que se preocupar. Agora você precisa lidar com conexões de rede, chaves de segurança para ler e escrever no serviço da fila, etc.

Claro que não significa que você nunca usará filas ou outras estruturas similares, mas é preciso considerar muito bem se é realmente necessário porque a carga de complexidade, código e problemas administrativos que não tem nenhuma relação com programação aumentarão consideravelmente.

Reduza a superfície de ataque

Manter o stack baixo também tem implicações fortes em segurança, pergunte para qualquer especialista em SRE se ele prefere lidar com a segurança de vários serviços interligados ou se ele prefere cuidar de um conjunto pequeno de serviços.

Esqueça o fantasma da escalabilidade

Sabe aquele ditado de que otimização é a raiz de todo mal? Otimizar para escalabilidade sem mensurar só porque “acho que precisa” é muito mais sério. Uma API REST feita em Go e gravando em um banco de dados como PostgreSQL bem escritos aceitam uma quantidade incrível de conexões por segundo mesmo estando em uma máquina simples com 1Gb RAM e 1 core. Se o banco estiver em uma máquina separada, ou melhor ainda se for contratado como SAAS a carga que esse pequeno servidor aguenta é enorme.

É muito comum um sistema ser projetado com vários microsserviços, filas, serviço para armazenar chave/valor in memory, um serviço de vault para centralizar as chaves dos vários serviços, etc.

Daí gasta uma fortuna em infra e um tempo enorme lidando com orquestração disso tudo. Precisará de um especialista em segurança para garantir que tudo está bem configurado e nada vazará.

E no fim servir poucas conexões simultâneas.

Prefira o monólito

Monólito, microsserviços, FAAS, são apenas escolhas de arquitetura. Todas têm vantagens e desvantagens, mas se for possível escolha o monólito, o motivo é que a comunicação entre as partes do sistema é muito mais simples no monólito.

As outras opções incluem camadas de comunicação que precisam ser gerenciadas, tem impacto na segurança, no desempenho e temos uma tendência muito grande de subestimar esses custos.

Sei o quanto é tentador ter vários microsserviços, cada um com uma responsabilidade. Mas se mal projetado você acaba com um “monólito distribuído”, ou seja, você tem todos os problemas do monólito, só que agora está tudo distribuído e no lugar de ter as vantagens dos microsserviços você ficou apenas com as desvantagens.

Quando for projetar camadas de serviços, sempre pense se o custo de lidar com segurança, rede, etc compensa a separação em serviços distintos e sempre seja muito crítico.

Por exemplo, faz todo sentido para uma empresa grande, com vários produtos, ter um microsserviço especializado em autenticação consultado por todos os outros, esse serviço fica atrás de um firewall, conta com load balance, tem várias Instâncias redundantes, etc.

Mas normalmente não é o que temos aqui no Brasil, o que mais vejo são pequenas empresas e start-ups que tem carga bem moderada.

Dividir o sistema por responsabilidades

Uma boa forma de organizar o sistema e criar bibliotecas independentes que possam ser reusadas é dividir o sistema por responsabilidades e cada peça de software ter apenas uma responsabilidade.

Isso também se aplica se você resolver criar microsserviços, cada um deve ter apenas uma responsabilidade. Pode parecer óbvio falando assim, mas no dia a dia, com as demandas chegando e a sempre crescente cobrança por velocidade de entrega, é importante não ceder à tentação de tomar atalhos.

Mesmo quando no monólito, bibliotecas que acumulam responsabilidades diferentes são ruins.

Evite dividir a equipe

Dividir o sistema por responsabilidades é bom, mas isso não se aplica a equipe, isso pode não ser possível dependendo do tamanho da empresa, mas é melhor ter uma única equipe em que todo mundo troque informações rapidamente.

Comunicação

Comunicação tem um grande impacto na qualidade do software. A falta de comunicação facilmente faz com que o esforço seja duplicado, é dessa forma que o Windows acabou com 6 controles de áudio diferentes. A boa comunicação faz com que nem todos entendam/saibam as prioridades da empresa e as dificuldades que os outros desenvolvedores estão passando.

Tudo bem ter equipes distintas se a empresa é grande o suficiente, mas é preciso lembrar que a comunicação entre equipes é mais custosa que comunicação entre membros da mesma equipe. Aliás, product owner, product manager, scrum master, ou seja lá como for chamado o cargo da pessoa que passa as demandas para os desenvolvedores, ele tem que ficar o mais próximo possível dos devs. de preferência estando junto deles o dia todo.

Existem formas de atenuar o problema da comunicação, por exemplo, uma coisa que funciona muito bem é todo mundo na mesma sala de chat, mesmo com as pessoas distantes fisicamente basta abrir o áudio e falar com as pessoas. Isso além de estreitar os laços da equipe, evita o “telefone sem fio”, evita marcar reuniões inúteis, etc.

Eu já testei essa dinâmica em duas empresas diferentes com muito sucesso, você inicia seu dia, entra na sala de chat, coloca seu microfone no mudo e fica trabalhando, se alguém te chama é a mesma dinâmica de chamar uma pessoa da mesa do lado.

Prefira tecnologia testada e tediosa

Temos uma tendência de querer usar as tecnologias mais novas e cintilantes. Programador gosta de novidades e tecnologia. Dai assiste uma palestra e fica doido para colocar a nova tecnologia em qualquer coisa.

Isso é péssimo para a resiliência do software que se está construindo. Idealmente toda tecnologia usada devia ser tediosa no sentido de que não traz nenhuma novidade, os programadores dominam ela quase que completamente, já foi testada em batalha inúmeras vezes e não tem o menor risco de deixar de ser mantida, ou ter uma guinada inesperada nos próximos anos.

Por exemplo, se você pegar um bom livro de PostgreSQL de cinco anos atrás ele provavelmente ainda será totalmente relevante hoje, um bom livro de Bash resiste uma década fácil e um bom livro de C é eterno. E eu realmente espero que Go siga a mesma linha.

Comandos do sistema operacional

Por falar em Bash, aprenda a usar bem os comandos do sistema operacional, muitas vezes é melhor escrever um pequeno script e usar o que já existe que desenvolver você mesmo.

Filas e Workers

Melhor que todo o peso de adicionar um serviço de filas na sua estrutura é usar o banco de dados que você já deve estar usando de qualquer forma.

No meu caso que o banco escolhido é o PostgreSQL principalmente por motivos históricos, eu uso uma combinação de select… for update e select… skip locked" para processar registros com segurança sem travas de locks de registro, também adiciono um simples campo para registrar o status atual do registro que precisa ser processado e pronto.

Outro mecanismo interessante, principalmente quando você precisa consumir serviços de terceiros para processar o registro, é criar um campo com o histórico do processamento.

Um campo texto que você concatena coisas como o resultado de uma chamada a uma API, erros, etc. É bem agradável o erro no mesmo registro que está causando ele.

Se o processamento não precisa ser rápido, é melhor chamar o worker via crontab do que ele ficar em loop esperando que algum registro precise ser processado. A grande vantagem de usar crontab é seu programa não ficar na memória o tempo todo, ele sempre inicia em um estado limpo e o próprio e você não corre o menor risco de um memory leak crescer a ponto de ser um problema.

FTS

No lugar de usar ElastcSearch tanto o PostgreSQL como o SQLite são ótimos para full text search. Recentemente precisei criar um pequeno servidor de logs para acumular logs de vários sistemas e o mais importante poder recuperar essa informação facilmente.

Usei o SQLite e um pequeno servidor Go e o resultado foi muito bom, uma única tabela tipo FTS recebe os registros com um campo o log em si, outro para armazenar a data e outro para o nome do sistema que produziu o log.

Com esse pequeno sistema ficou muito simples de procurar por ocorrências, erros e ter uma visão do estado geral do sistema.

Não use panic

Quando estiver programando em Go, trate todos os erros, eu sei o quanto é repetitivo, às vezes tratamos erros que se acontecerem é o sistema operacional que está com problemas, mas não sei dizer quantas vezes deixei de tratar um erro que parecia impossível de acontecer e claro que ele aconteceu.

Não recupere depois de panic

Às vezes escrevemos sistemas que não podem ficar fora do ar, essa é a justificativa para o sistema se recuperar de um panic.

Mas o panic serve para te dizer que alguma coisa muito errada aconteceu e precisa de sua atenção, idealmente quando um programa da panic o correto é escrever um teste que reproduza o problema e então tratar o erro.

Para o sistema continuar no ar, use um gerenciador de serviços como o supervisor, por exemplo.

Dessa forma, com o passar do tempo seu sistema vai se tornar mais e mais resiliente.

Trate o erro onde ele ocorre

Quando eu programava em C++ eu desabilitava o try catch direto no gcc com o parâmetro -fno-exceptions, é sempre preferível tratar erros onde eles ocorrem. Em Go, por exemplo, nunca use uma função para esconder o if err != nil. Isso criará uma indireção que tornará o código menos explícito.

Nunca confie em dados que você recebe

Não importa se é um API REST ou funções internas do seu programa, nunca confie no usuário, mesmo que esse usuário seja você no futuro. Todos os dados devem ser validados.

Sempre valide tipos de interface

Quando uma função Go recebe um parâmetro que é uma interface, o próprio compilador trata de assegurar que seja do tipo correto. O problema mesmo é quando usamos uma interface vazia. E daí fazemos cast para algum tipo específico. Se você não testar o tipo e ele não for o que você espera ou for nil o programa gerará um panic.

Além disso, internamente interfaces são ponteiros, então ela pode ser nil.

Sempre cheque nil

Sempre que receber um ponteiro ou uma interface, verifique se é nil antes de usar ou tentar converter.

Uma boa prática para detectar esses problemas é passar linters no código, o golangci-lint aplica vários linters que detectam esses e vários outros problemas.

Sempre verifique o tamanho de arrays

Geralmente programadores estão acostumados a verificar o tamanho de arrays em for loop, mas esquecem de verificar se recebem o array se estão interessandos apenas no primeiro item.

Não quebre sua API

Quando criar uma API seja minimalista, evite adicionar funcionalidades extras que possam parecer interessantes durante a implementação, mantenha uma especificação enxuta. Se você sobrecarrega sua API de funcionalidades, fica mais custoso fazer mudanças atualizações no seu código no futuro.

Não tenha versões da mesma API

Poucas coisas dão tanto trabalho quanto manter duas versões diferentes da mesma API, mas as coisas mudam, e muitas vezes é inevitável que você precise mudar alguma coisa que potencialmente quebre a API e você precise manter duas (ou mais) versões. No lugar disso, tente projetar a API de forma que consiga receber acréscimos, geralmente não há problema em adicionar um campo a mais em uma resposta de uma chamada ou mesmo adicionar uma funcionalidade nova desde que as existentes continuem funcionando e testadas. Os devs. (internos e dos clientes) vão agradecer.

Testes são fundamentais

Testes são fundamentais, mas foque primeiro em testar as funcionalidades não código.

Em linguagens compiladas a cobertura de testes não precisa ser muito grande, porque o compilador já pegou todos os erros de sintaxe, esquecimentos, etc.

Já em uma linguagem de script é preciso ser mais rigoroso.

Qualquer função que tenha um SQL dentro dela precisa ser completamente testada, o motivo é que mesmo se você estiver usando uma linguagem compilada o código SQL é um pedacinho de código interpretado dentro do seu programa compilado, o compilador não tem como saber se aquele trecho é válido.

Mantenha o cartão corporativo abastecido

Não tem nenhuma relação com programação, mas coloquei esse ponto aqui porque tenho certeza que vários programadores sênior vão rir ao ler isso e lembrar das vezes que ficaram procurando o motivo do sistema ter caído. Só para descobrir que era porque a pessoa encarregada do cartão corporativo deixou ele sem fundos.

Referências


Cesar Gimenes