https://a.storyblok.com/f/270183/147548/339c855fd5/part-three.png

Melhore seu projeto de software – Parte III: Aprimoramentos de nível avançado

Publicado em December 8, 2022

Tempo de leitura: 14 minutos

Você já assumiu o controle de uma base de código e percebeu que não está satisfeito com a forma como o código está escrito ou organizado? É uma situação comum, mas que pode causar muitas dores de cabeça. A dívida técnica pode crescer como uma bola de neve, tornando exponencialmente mais difícil entender o código e adicionar novos recursos.

Nesta série de três partes, vou abordar alguns dos principais passos que você vai querer seguir para ficar mais satisfeito com seu (antigo) projeto. Para dar alguns exemplos concretos, vou integrar tudo explicando como refatorei e aprimorei o SDK do Vonage em Python de código aberto SDK do Vonage para Python, uma biblioteca que faz chamadas HTTP às APIs da Vonage, mas os princípios se aplicam a qualquer tipo de projeto de software.

Os exemplos desta postagem serão escritos em Python, mas esses princípios se aplicam a projetos em qualquer linguagem. Há também uma lista de verificação útil a ser seguida se você estiver tentando corrigir especificamente um projeto em Python.

A série, em seções

  1. Parte 1: Compreendendo uma base de código

  2. Parte II: Fazendo mudanças

  3. Parte III: Aprimoramentos de nível avançado (este artigo)

Se você acompanhou a Parte Um e Parte Dois desta série, você já deve ter uma boa compreensão do seu projeto e talvez já tenha feito alguma refatoração, adicionado funcionalidades e lançado novas versões.

O que a Parte III aborda?

Na Parte Três, vamos falar sobre:

  • Aprimorando seu projeto

  • Ferramentas que você pode usar

  • Automação

  • Melhores práticas para transferir um projeto para outra pessoa

Melhorias que você pode fazer

Ao pensar nas melhorias que podem ser feitas em uma base de código, elas se dividem em dois grupos:

  • Melhorias que beneficiam diretamente o usuário, e

  • Melhorias que beneficiam o mantenedor.

Vamos começar discutindo algumas melhorias para o usuário.

Tratamento personalizado de erros

Quando um usuário se depara com um erro, a utilidade desse erro para ajudá-lo a descobrir o que está errado pode variar enormemente. Vamos considerar dois exemplos distintos.

O Anexo A mostra uma maneira de escrever uma função que verifica se um parâmetro de entrada de um método é válido. O método em questão permite que um usuário envie mensagens por meio de canais como SMS, MMS, WhatsApp, Messenger e Viber com a Messages API for Vonage. Essa verificação garante que o usuário tenha especificado um canal válido.

def _check_valid_message_channel(self, params):
    if params['channel'] not in Messages.valid_message_channels:
        raise Exception

Nesse caso, se o usuário não especificar um canal de mensagem válido, ele simplesmente verá que uma exceção foi gerada. Ele não terá nenhuma informação específica nesse momento e precisará examinar a pilha de chamadas para descobrir o que causou o erro.

The exception a user will see if they run the above code

O Anexo B mostra outra maneira de escrever esse código.

from .errors import MessagesError

def _check_valid_message_channel(self, params):
    if params['channel'] not in Messages.valid_message_channels:
        raise MessagesError(f"""
          '{params['channel']}' is an invalid message channel. 
          Must be one of the following types: {self.valid_message_channels}'
        """)

Nesse caso, criei um erro personalizado relacionado à Messages API do Vonage. Especifiquei uma mensagem de erro que descreve exatamente o problema no código do usuário e o que ele pode fazer para corrigi-lo. Isso fica muito mais claro para o usuário e pode economizar muito tempo de depuração!

The (more useful) exception a user will see if they run the new code with the custom error class

Podemos ver acima que o usuário tentou enviar uma mensagem por “pombo-correio” por meio da Messages API, que é um canal não suportado. Esse exemplo mostra o quanto você pode ajudar seus usuários ao criar exceções personalizadas para auxiliar na depuração.

Validação de entradas

Se seus usuários precisarem passar dados para funções em seu código, talvez seja interessante considerar quais verificações você está realizando nesses dados de entrada. Se você estiver usando uma abordagem baseada em classes fortemente tipadas, como o Java orientado a objetos, seu código tentará organizar os dados de entrada em uma estrutura apropriada. Se estiver usando uma abordagem menos rígida, talvez seja recomendável validar a entrada do usuário para retornar um erro o mais rápido possível caso haja algum problema.

Vamos dar uma olhada em alguns exemplos reais. Este é um trecho de código do SDK que envia um SMS:

def send_message(self, params):
    ...
    return self._client.post(
        self._client.host(), 
        "/sms/json", 
        params, # This is the user's input!
        supports_signature_auth=True,
        **Sms.defaults,
    )

Se você chamar esse método, acontecerá o seguinte:

  1. params são passados para a sms.send_message função pelo usuário

  2. Esses valores são imediatamente passados para outra função, o post método da client classe

  3. O post método faz uma solicitação POST e retorna a resposta ao usuário.

Durante esse processo, a entrada do usuário é atribuída imediatamente ao params objeto sem qualquer validação. Isso é adequado para casos simples, mas se a API com a qual estamos nos comunicando aceitar muitas combinações de opções, talvez seja melhor considerar a validação das entradas do usuário.

Por que se dar ao trabalho de validar os dados inseridos?

Ótima pergunta. Se, de qualquer jeito, vamos apenas gerar um erro, por que nos dar ao trabalho? Bem, esse é um exemplo perfeito da abordagem “fail-fast”: detectar erros na origem do problema facilita muito a depuração e significa que menos recursos são usados para fazer solicitações que serão rejeitadas.

Aqui está outro exemplo, desta vez da Messages API da Vonage:

def send_message(self, params: dict):        
    self.validate_send_message_input(params) # This calls the function below
    ...
    return self._client.post(
        self._client.api_host(), 
        "/v1/messages",
        params, # This is still the user's input, but if we get here, we know it's valid!
        auth_type=self._auth_type,
        )

def validate_send_message_input(self, params):
    # Each of these lines calls a different check on the user's input
    # An error is thrown if any of the checks fail
    self._check_input_is_dict(params)
    self._check_valid_message_channel(params)
    self._check_valid_message_type(params)
    self._check_valid_recipient(params)
    self._check_valid_sender(params)
    self._channel_specific_checks(params)
    self._check_valid_client_ref(params)

Podemos ver que, desta vez, a entrada do usuário é cuidadosamente verificada para que não enviemos uma solicitação incorreta.

Embora a verificação manual seja eficaz, também vale a pena considerar uma abordagem baseada em classes ou modelos caso você precise validar muitas entradas do usuário. Algumas linguagens implementam essa função por meio de classes fortemente tipadas, nas quais o construtor de uma classe espera uma entrada específica para criar uma instância dessa classe. Nesse caso, fazer com que o usuário crie classes válidas e as passe para suas outras funções pode garantir que ele forneça os dados corretos. Em Python, não temos um sistema de tipagem pronto para uso que funcione dessa maneira, mas existem bibliotecas como o Pydantic que podem criar modelos para fazer isso por você.

Reescrevi o código acima usando uma abordagem baseada em modelos com o Pydantic para utilizar modelos na validação de entradas:

# I created models (that look like classes) that inherit from Pydantic's BaseModel class.
# I'm able to specify specific constraints, including the type and length of parameters, and specify defaults.
class Message(BaseModel):
    to: constr(min_length=7, max_length=15)
    sender: constr(min_length=1)
    client_ref: Optional[str]
    
class SmsMessage(Message): # Inherits the properties of the "Message" model
    channel = Field(default='sms', const=True)
    message_type = Field(default='text', const=True)
    text: constr(max_length=1000)

... # More classes for each type of message that the Messages API can send

class Messages: # Class that contains the code to call the Messages API
... # Skipping showing the constructor etc. here
    def send_message_from_model(self, message: Message):
        params = message.dict()
        ...
        return self._client.post(
            self._client.api_host(), 
            "/v1/messages",
            params,
            auth_type=self._auth_type,
        )

Essa versão pode parecer mais complicada do que a anterior, mas nos poupa de ter que escrever manualmente todas as verificações. Agora, se um usuário quiser enviar uma mensagem e cometer um erro em parte do que digitou, receberá uma mensagem de erro clara que indica o que ele pode ter feito de errado.

The exception generated by Pydantic

Atualmente, a validação está intimamente ligada à instanciação de classes. Na implementação anterior, a validação precisava ser escrita manualmente e não era obrigatória. Ao utilizar essa abordagem baseada em modelos com o Pydantic, podemos garantir que não haja mais nenhuma possibilidade de que entradas inválidas sejam passadas adiante.

Em resumo, ao lidar com entradas do usuário, considere validá-las. A forma como você realiza essa validação depende da linguagem de programação utilizada e da abordagem adotada, mas ter algum tipo de validação pode economizar muito tempo para seus usuários.

Tornando-o assíncrono

A última melhoria voltada para o usuário que gostaria de destacar diz respeito ao código assíncrono. A menos que seu projeto envolva operações limitadas pela E/S, talvez você nem precise levar isso em consideração — nesse caso, simplesmente pule para a próxima seção.

O que “assíncrono” significa, na verdade?

Código assíncrono é aquele em que as operações podem ceder o controle de uma thread para permitir que outras coisas aconteçam. Compare isso com o código síncrono, que aguarda a conclusão de cada operação antes de iniciar a próxima. Algumas linguagens (por exemplo, Node.js) são assíncronas por padrão, mas outras possuem recursos assíncronos que podem ser usados quando necessário. Se você é um desenvolvedor JavaScript, provavelmente pode pular esta seção.

Se o seu código faz uma solicitação e precisa esperar muito tempo por uma resposta, talvez valha a pena escrevê-lo de forma assíncrona e permitir que outras tarefas sejam executadas até que você receba a resposta. No caso do SDK do Vonage para Python, estamos fazendo solicitações HTTP a um servidor remoto. Estamos fazendo isso de forma síncrona, então vale a pena considerar se criar uma versão assíncrona de parte do SDK traria benefícios para meus usuários. Podemos supor que criar um método assíncrono tornaria possível enviar mais solicitações de uma só vez com o SDK... mas por que ficar na suposição? Vamos fazer um experimento.

Devemos usar o async? Um exemplo da vida real

Para investigar se a criação de alguns métodos assíncronos reduziria o tempo necessário para realizar solicitações, escrevi dois trechos de código. Um deles utilizava uma função do SDK do Vonage para Python, como de costume, para realizar 100 solicitações HTTP à Number Insight API , e o outro utilizava uma versão assíncrona da função que eu mesmo criei. Analisei o desempenho de ambas as versões do código (usando o método de análise de desempenho que descrevi na Parte Um desta série, aqui) e podemos ver que a maior parte do tempo gasto no programa é dedicada à realização de solicitações HTTP.

A primeira imagem abaixo é um gráfico de “icicle” que mostra o topo da pilha de chamadas do nosso SDK enquanto ele realiza 100 solicitações a uma API da Vonage.

The top of an icicle plot of a series of synchronous SDK operations

A imagem a seguir mostra a parte mais inferior da pilha de chamadas. Como você pode ver aqui, a maior parte do tempo que o programa leva para ser executado (2,78/3,42 segundos, ou 81%!) é gasta apenas aguardando conexões SSL entre nosso código e o servidor remoto. E essa é apenas uma parte do processo em que precisamos aguardar ao fazer chamadas de sincronização.

The bottom level of the same icicle plot, showing that most of the time is spent waiting for connections

Isso sugere que, se o código pudesse abrir mão do controle da thread até que as conexões fossem estabelecidas, o tempo de execução poderia ser bem menor! Abaixo estão os dados de uma versão assíncrona do código, que realiza as mesmas 100 solicitações à mesma API.

An icicle plot of a series of asynchronous SDK operations - much faster

Podemos ver no gráfico acima que toda a tarefa foi concluída em 0,33 s, cerca de 10 vezes mais rápido do que a versão síncrona! Nesse caso, faz sentido para mim explorar se devo tornar meu código assíncrono.

O último parágrafo parece bastante evasivo, considerando que acabei de tornar o código 10 vezes mais rápido. Por que eu não iria querer começar imediatamente a tornar meu código assíncrono? Bem, isso pode tornar as coisas muito mais complicadas.

Desvantagens da programação assíncrona — devo usá-la?

Embora o código assíncrono funcione bem em muitos casos, há desvantagens significativas. Para tornar meu código assíncrono, eu teria que reescrever grande parte dele. Em Python, as corrotinas assíncronas se comportam de maneira muito diferente dos métodos comuns; elas precisam ser chamadas e tratadas de maneira bem diferente.

Pior do que isso é a questão do suporte. Se eu tivesse que reescrever completamente toda a biblioteca para torná-la assíncrona e lançar uma nova versão principal do projeto (como discutimos na Parte 2), eu forçaria meus usuários a reescrever todo o código que usa meu SDK! Se eu não quisesse submeter meus usuários a essa provação, precisaria manter versões síncronas e assíncronas do mesmo código, efetivamente dobrando o tamanho da base de código. Isso significa o dobro de código para testar e, se eu quisesse adicionar novos recursos, teria que fazê-lo duas vezes.

Existem maneiras de aliviar a carga, mas adicionar suporte à programação assíncrona ainda representaria um investimento significativo de tempo. No geral, a programação assíncrona é muito poderosa, mas avalie cuidadosamente quais são os casos de uso para sua base de código. Se você achar que haveria um benefício muito significativo, considere tornar as coisas assíncronas, mas pense muito bem nisso antes de se comprometer a implementá-la. E se você é um programador de JavaScript que leu esta seção mesmo sabendo que seu código já funciona assim, espero que isso tenha sido esclarecedor ou, pelo menos, divertido. 🤷

Configuração de ferramentas automatizadas

Se você deseja investir na saúde do seu projeto a longo prazo, provavelmente vai querer implementar ferramentas que o ajudem a escrever seu código ou que lhe forneçam insights sobre determinados aspectos dele. Mencionei algumas ferramentas na Primeira Parte desta série , mas vamos falar agora de forma mais prática sobre como aplicar ferramentas automatizadas ao seu código.

Supondo que seu código utilize controle de versão, é possível configurar ferramentas para serem executadas quando o código for enviado ou quando forem criadas solicitações de pull, etc. Existem muitas ferramentas para fazer isso. No meu caso, o SDK do Python da Vonage usa o GitHub Actions, que é gratuito para projetos de código aberto hospedados no GitHub e até mesmo para repositórios privados do GitHub abaixo de uma determinada cota de uso.

Execução de testes e cobertura de código

No meu repositório, configurei uma GitHub Action que executa testes sempre que é feito um push ou uma solicitação de pull (PR) e calcula a cobertura de código. A vantagem de usar automação para isso é que posso testar em várias plataformas e versões do Python sem precisar configurar manualmente uma máquina virtual para cada plataforma e um novo ambiente virtual para cada versão do Python. Recomendo configurar seus testes para serem executados dessa forma, pois assim você pode detectar erros antes que eles cheguem ao seu ambiente de produção.

Part of the GitHub Action that runs my tests on multiple platforms and multiple versions of Python, whenever I push code to the repo or make a PR

Pontuação de mutação

Na Primeira Parte desta série , discutimos brevemente os benefícios que os testes de mutação podem trazer. É fácil cair na armadilha da cobertura de código, buscando aumentá-la a qualquer custo. A Lei de Goodhart afirma que “quando uma medida se torna uma meta, ela deixa de ser uma boa medida”. Desenvolvedores que se dedicam demais às métricas de cobertura de código tendem a sacrificar a qualidade dos testes em prol da quantidade de cobertura. A pontuação de mutação é uma forma de evitar que isso aconteça.

A pontuação de mutação está relacionada à capacidade dos seus testes de se adaptarem a mudanças. Conforme discutimos na Parte Um, os testes de mutação funcionam alterando o código de maneiras sutis e, em seguida, aplicando os testes unitários a essas novas versões “mutantes” do código.

A execução de testes de mutação pode levar algum tempo em uma base de código maior. Felizmente, porém, como se trata de um método de teste automatizado, é possível integrar os testes de mutação a um pipeline de compilação/lançamento. Decidi fazer isso para o SDK Python da Vonage, utilizando uma biblioteca de mutação em Python chamada mutmut.

Criei uma ação do GitHub chamada “Mutation Test” que executa um teste de mutação no código-fonte, conforme mostrado a seguir:

GitHub Actions console showing my mutation test workflow and some previously run jobs

Este fluxo de trabalho possui um gatilho de execução manual. Isso porque uma execução automatizada ao enviar código ou ao criar um PR levaria mais tempo do que eu gostaria para ser concluída. O fato de o fluxo de trabalho ser acionado manualmente significa que, sempre que eu quiser ter uma visão geral do estado da minha base de código, posso executá-lo.

The mutation testing workflow is manually triggered

O fluxo de trabalho do teste de mutações gera um arquivo HTML, que fica disponível para download dentro da execução específica do teste. Esse arquivo contém um arquivo de índice que apresenta uma visão geral e, em seguida, uma lista das mutações que escaparam à detecção para cada módulo.

The mutation test run produces an artifact containing the results in HTML format

Results from the run

Podemos ver aqui que detectamos 383 das 522 versões mutantes do código, ou seja, cerca de 74%. Esse é um bom número, mas podemos observar algumas discrepâncias entre os módulos e talvez seja interessante investigar a causa delas. Nem sempre é produtivo buscar a pontuação mais alta (lembre-se da Lei de Goodhart!), mas podemos usar essas métricas para entender melhor o que nossos testes estão fazendo. Ter uma pontuação de mutação que melhora constantemente (mesmo que muito lentamente) é mais importante do que ter uma pontuação alta.

Análise de vulnerabilidades

Se o seu projeto utiliza dependências, você deve ter certeza de que está usando versões que não comprometam a segurança dos seus usuários. Existem várias ferramentas automatizadas que podem verificar isso para você, por exemplo: o Mend para GitHub.com, que verifica periodicamente seu código em busca de vulnerabilidades e abre tickets e PRs para tentar corrigi-las.

An issue automatically raised by mend-for-github-bot that highlights a potential vulnerability and remediation steps

É importante utilizar uma ferramenta que monitore bancos de dados de vulnerabilidades e alertas de segurança, já que novas ameaças são descobertas o tempo todo.

Entrega do projeto

Esta série tem se concentrado principalmente na situação em que você começou a trabalhar em um projeto legado, mas provavelmente não será o responsável por esse projeto para sempre. Em algum momento, você provavelmente passará o código para outra pessoa, e é uma boa prática aproveitar suas últimas semanas no projeto para garantir que a transição ocorra da maneira mais tranquila possível. Você talvez já tenha ouvido falar da regra adaptada dos escoteiros por Bob Martin: deixe o código em um estado melhor do que você o encontrou.

Faltando duas semanas para a transferência de responsabilidades, é hora de parar de aceitar novos trabalhos. Sua tarefa neste momento deve ser garantir uma transição tranquila. Conclua ou interrompa o desenvolvimento de quaisquer funcionalidades e faça a integração ou feche quaisquer PRs em aberto. O ideal é começar o importante processo de documentar tudo o mais rápido possível.

Documente o estado do código. Isso inclui garantir que os arquivos README e a documentação estejam atualizados, caso o código fique sem atualizações por um tempo, mas também: redija um documento de transferência! Você não quer que seu sucessor tenha que vasculhar vários branches abertos com código não confirmado para descobrir o que você estava planejando. Seu documento de transferência deve incluir:

  • Uma visão geral da base de código

  • Como começar a desenvolver no projeto

  • Visão geral dos testes

  • O trabalho que você começou, mas não terminou

  • Trabalho que você planejou realizar e o motivo

  • Qualquer outra coisa que não esteja documentada ou que não seja óbvia

Por fim, seu sucessor pode entrar em contato com você para discutir o código. Considere interagir com ele, se tiver tempo. É bom ser gentil!

Considerações finais

Se você está lendo isso, parabéns! Você está em uma ótima posição para tornar o seu projeto o mais incrível possível.

Se você tiver alguma dúvida ou quiser compartilhar suas ideias, entre em contato conosco no nosso Slack da Comunidade Vonage ou nos enviar uma mensagem no Twitter.

Obrigado por me acompanharem nessa jornada e boa sorte em todos os seus projetos futuros.

Compartilhar:

https://a.storyblok.com/f/270183/400x400/92109caf6a/max-kahan.png
Max KahanEx-funcionários da Vonage

Max é um ex-membro da equipe da Vonage. Ele atuou como Promotor de Desenvolvedores Python e Engenheiro de Software, com interesse em APIs de comunicação, aprendizado de máquina, experiência do desenvolvedor e dança! Ele é formado em Física, mas atualmente trabalha em projetos de código aberto e cria soluções para facilitar a vida dos desenvolvedores.