Reengenharia de software: do espaguete ao design limpo

Publicados: 2022-03-11

Você pode dar uma olhada no nosso sistema? O cara que escreveu o software não existe mais e estamos tendo vários problemas. Precisamos de alguém para dar uma olhada e limpá-lo para nós.

Qualquer um que esteja em engenharia de software por um período razoável de tempo sabe que esse pedido aparentemente inocente é muitas vezes o início de um projeto que “tem desastre escrito por toda parte”. Herdar o código de outra pessoa pode ser um pesadelo, especialmente quando o código é mal projetado e carece de documentação.

Então, quando recentemente recebi uma solicitação de um de nossos clientes para examinar seu aplicativo de servidor de bate-papo socket.io existente (escrito em Node.js) e melhorá-lo, fiquei extremamente cauteloso. Mas antes de correr para as colinas, decidi pelo menos concordar em dar uma olhada no código.

Infelizmente, olhar para o código apenas reafirmou minhas preocupações. Esse servidor de bate-papo foi implementado como um único e grande arquivo JavaScript. A reengenharia desse único arquivo monolítico em um software de arquitetura limpa e de fácil manutenção seria realmente um desafio. Mas eu gosto de um desafio, então eu concordei.

reengenharia de software

O ponto de partida - Prepare-se para a reengenharia

O software existente consistia em um único arquivo contendo 1.200 linhas de código não documentado. Caramba. Além disso, era conhecido por conter alguns bugs e ter alguns problemas de desempenho.

Além disso, o exame dos arquivos de log (sempre um bom ponto de partida ao herdar o código de outra pessoa) revelou possíveis problemas de vazamento de memória. Em algum momento, foi relatado que o processo estava usando mais de 1 GB de RAM.

Diante desses problemas, ficou imediatamente claro que o código precisaria ser reorganizado e modularizado antes mesmo de tentar depurar ou aprimorar a lógica de negócios. Para esse fim, algumas das questões iniciais que precisavam ser abordadas incluíam:

  • Estrutura do código. O código não tinha nenhuma estrutura real, tornando difícil distinguir configuração de infraestrutura de lógica de negócios. Não houve essencialmente nenhuma modularização ou separação de interesses.
  • Código redundante. Algumas partes do código (como o código de tratamento de erros para cada manipulador de eventos, o código para fazer solicitações da Web etc.) foram duplicadas várias vezes. Código replicado nunca é uma coisa boa, tornando o código significativamente mais difícil de manter e mais propenso a erros (quando o código redundante é corrigido ou atualizado em um lugar, mas não no outro).
  • Valores codificados. O código continha vários valores codificados (raramente uma coisa boa). Ser capaz de modificar esses valores por meio de parâmetros de configuração (em vez de exigir alterações nos valores codificados permanentemente no código) aumentaria a flexibilidade e também poderia ajudar a facilitar o teste e a depuração.
  • Exploração madeireira. O sistema de registro era muito básico. Isso geraria um único arquivo de log gigante que era difícil e desajeitado de analisar ou analisar.

Principais objetivos arquitetônicos

No processo de começar a reestruturar o código, além de abordar as questões específicas identificadas acima, eu queria começar a abordar alguns dos principais objetivos arquiteturais que são (ou pelo menos deveriam ser) comuns ao projeto de qualquer sistema de software . Esses incluem:

  • Manutenibilidade. Nunca escreva software esperando ser a única pessoa que precisará mantê-lo. Sempre considere o quão compreensível seu código será para outra pessoa e quão fácil será para ela modificar ou depurar.
  • Extensibilidade. Nunca assuma que a funcionalidade que você está implementando hoje é tudo o que será necessário. Arquitete seu software de maneira que seja fácil de estender.
  • Modularidade. Separe a funcionalidade em módulos lógicos e distintos, cada um com seu próprio propósito e função claros.
  • Escalabilidade. Os usuários de hoje estão cada vez mais impacientes, esperando tempos de resposta imediatos (ou pelo menos próximos). O baixo desempenho e a alta latência podem fazer com que até mesmo o aplicativo mais útil falhe no mercado. Qual será o desempenho do seu software à medida que o número de usuários simultâneos e os requisitos de largura de banda aumentarem? Técnicas como paralelização, otimização de banco de dados e processamento assíncrono podem ajudar a melhorar a capacidade de seu sistema permanecer responsivo, apesar do aumento da carga e das demandas de recursos.

Reestruturação do Código

Nosso objetivo é ir de um único arquivo de código-fonte mongo monolítico para um conjunto modularizado de componentes arquitetados de forma limpa. O código resultante deve ser significativamente mais fácil de manter, aprimorar e depurar.

Para este aplicativo, decidi organizar o código nos seguintes componentes arquiteturais distintos:

  • app.js - este é o nosso ponto de entrada, nosso código será executado a partir daqui
  • config - é onde nossas definições de configuração residirão
  • ioW - um “empacotador de E/S” que conterá toda a lógica de E/S (e de negócios)
  • logging - todo o código relacionado ao log (observe que a estrutura do diretório também incluirá uma nova pasta de logs que conterá todos os arquivos de log)
  • package.json - a lista de dependências de pacotes para Node.js
  • node_modules - todos os módulos exigidos pelo Node.js

Não há nada de mágico nessa abordagem específica; pode haver muitas maneiras diferentes de reestruturar o código. Pessoalmente, senti que esta organização era suficientemente limpa e bem organizada sem ser excessivamente complexa.

O diretório resultante e a organização de arquivos são mostrados abaixo.

código reestruturado

Exploração madeireira

Os pacotes de registro foram desenvolvidos para a maioria dos ambientes e linguagens de desenvolvimento atuais, portanto, é raro hoje em dia que você precise "fazer o seu próprio" recurso de registro.

Como estamos trabalhando com Node.js, selecionei log4js-node, que é basicamente uma versão da biblioteca log4js para uso com Node.js. Esta biblioteca tem alguns recursos interessantes como a capacidade de registrar vários níveis de mensagens (AVISO, ERRO, etc.) e podemos ter um arquivo rolante que pode ser dividido, por exemplo, diariamente, para que não precisemos lidar com arquivos enormes que levarão muito tempo para serem abertos e serão difíceis de analisar e analisar.

Para nossos propósitos, criei um pequeno wrapper em torno do nó log4js para adicionar alguns recursos desejados adicionais específicos. Observe que optei por criar um wrapper em torno do nó log4js que usarei em todo o meu código. Isso localiza a implementação desses recursos de log estendidos em um único local, evitando assim redundância e complexidade desnecessária em todo o meu código quando invoco o log.

Como estamos trabalhando com I/O, e teríamos vários clientes (usuários) que vão gerar várias conexões (sockets), quero poder rastrear a atividade de um usuário específico nos arquivos de log, e também quero saber a origem de cada entrada de log. Portanto, espero ter algumas entradas de log relacionadas ao status do aplicativo e algumas específicas da atividade do usuário.

No meu código de wrapper de log, posso mapear o ID do usuário e os soquetes, o que me permitirá acompanhar as ações que foram executadas antes e depois de um evento ERROR. O wrapper de log também me permitirá criar diferentes registradores com diferentes informações contextuais que posso passar para os manipuladores de eventos para que eu saiba a origem da entrada de log.

O código para o wrapper de log está disponível aqui.

Configuração

Muitas vezes é necessário suportar diferentes configurações para um sistema. Essas diferenças podem ser diferenças entre os ambientes de desenvolvimento e produção ou até mesmo com base na necessidade de exibir diferentes ambientes de clientes e cenários de uso.

Em vez de exigir alterações no código para dar suporte a isso, a prática comum é controlar essas diferenças de comportamento por meio de parâmetros de configuração. No meu caso, eu precisava ter a capacidade de ter diferentes ambientes de execução (staging e produção), que podem ter configurações diferentes. Eu também queria garantir que o código testado funcionasse bem tanto na preparação quanto na produção, e se eu precisasse alterar o código para essa finalidade, isso teria invalidado o processo de teste.

Usando uma variável de ambiente Node.js, posso especificar qual arquivo de configuração quero usar para uma execução específica. Portanto, movi todos os parâmetros de configuração previamente codificados para os arquivos de configuração e criei um módulo de configuração simples que carrega o arquivo de configuração adequado com as configurações desejadas. Também categorizei todas as configurações para impor algum grau de organização no arquivo de configuração e facilitar a navegação.

Aqui está um exemplo de um arquivo de configuração resultante:

 { "app": { "port": 8889, "invRepeatInterval":1000, "invTimeOut":300000, "chatLogInterval":60000, "updateUsersInterval":600000, "dbgCurrentStatusInterval":3600000, "roomDelimiter":"_", "roomPrefix":"/" }, "webSite":{ "host": "mysite.com", "port": 80, "friendListHandler":"/MyMethods.aspx/FriendsList", "userCanChatHandler":"/MyMethods.aspx/UserCanChat", "chatLogsHandler":"/MyMethods.aspx/SaveLogs" }, "logging": { "appenders": [ { "type": "dateFile", "filename": "logs/chat-server", "pattern": "-yyyy-MM-dd", "alwaysIncludePattern": false } ], "level": "DEBUG" } }

Fluxo de código

Até agora, criamos uma estrutura de pastas para hospedar os diferentes módulos, configuramos uma maneira de carregar informações específicas do ambiente e criamos um sistema de log, então vamos ver como podemos juntar todas as peças sem alterar o código específico do negócio.

Graças à nossa nova estrutura modular do código, nosso app.js de ponto de entrada é bastante simples, contendo apenas o código de inicialização:

 var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);

Quando definimos nossa estrutura de código, dissemos que a pasta ioW conteria código relacionado a negócios e socket.io. Especificamente, ele conterá os seguintes arquivos (observe que você pode clicar em qualquer um dos nomes de arquivo listados para visualizar o código-fonte correspondente):

  • index.js – manipula a inicialização e as conexões do socket.io, bem como a assinatura de eventos, além de um manipulador de erros centralizado para eventos
  • eventManager.js – hospeda toda a lógica relacionada ao negócio (manipuladores de eventos)
  • webHelper.js – métodos auxiliares para fazer solicitações da web.
  • linkedList.js – uma classe de utilitário de lista vinculada

Refatoramos o código que faz a solicitação da web e o movemos para um arquivo separado, e conseguimos manter nossa lógica de negócios no mesmo lugar e sem modificações.

Uma observação importante: neste estágio, eventManager.js ainda contém algumas funções auxiliares que realmente devem ser extraídas em um módulo separado. No entanto, como nosso objetivo neste primeiro passo era reorganizar o código minimizando o impacto na lógica de negócios, e essas funções auxiliares estão muito intrinsecamente ligadas à lógica de negócios, optamos por adiar isso para um passo subsequente para melhorar a organização do código.

Como o Node.js é assíncrono por definição, geralmente encontramos um ninho de ratos de “inferno de retorno de chamada”, o que torna o código particularmente difícil de navegar e depurar. Para evitar essa armadilha, em minha nova implementação, empreguei o padrão de promessas e estou aproveitando especificamente o bluebird, que é uma biblioteca de promessas muito boa e rápida. As promessas nos permitirão seguir o código como se fosse síncrono e também fornecer gerenciamento de erros e uma maneira limpa de padronizar as respostas entre as chamadas. Há um contrato implícito em nosso código de que todo manipulador de eventos deve retornar uma promessa para que possamos gerenciar o tratamento e o registro de erros centralizados.

Todos os manipuladores de eventos retornarão uma promessa (independentemente de fazerem chamadas assíncronas ou não). Com isso em vigor, podemos centralizar o tratamento e o registro de erros e nos certificamos de que, se tivermos um erro não tratado dentro do manipulador de eventos, esse erro será detectado.

 function execEventHandler(socket, eventName, eventHandler, data){ var sLogger = logging.createLogger(socket.id + ' - ' + eventName); sLogger.info(''); eventHandler(socket, data, sLogger).then(null, function(err){ sLogger.error(err.stack); }); };

Em nossa discussão sobre registro, mencionamos que cada conexão teria seu próprio registrador com informações contextuais. Especificamente, estamos vinculando o id do soquete e o nome do evento ao logger quando o criamos, portanto, quando passamos esse logger para o manipulador de eventos, cada linha de log terá essa informação:

 var sLogger = logging.createLogger(socket.id + ' - ' + eventName);

Outro ponto que vale a pena mencionar em relação ao tratamento de eventos: No arquivo original, tínhamos uma chamada de função setInterval que estava dentro do manipulador de eventos do evento de conexão socket.io, e identificamos essa função como um problema.

 io.on('connection', function (socket) { ... Several event handlers .... setInterval(function() { try { var date = Date.now(); var tmp = []; while (0 < messageHub.count() && messageHub.head().date < date) { var item = messageHub.remove(); tmp.push(item); } ... Post Data to an external web service... } catch (e) { log('ERROR: ex: ' + e); } }, CHAT_LOGS_INTERVAL); });

Este código está criando um temporizador com um intervalo especificado (no nosso caso foi 1 minuto) para cada solicitação de conexão que recebemos. Assim, por exemplo, se em um determinado momento tivermos 300 soquetes online, teríamos 300 cronômetros executando a cada minuto. O problema com isso, como você pode ver no código acima, é que não há uso do socket nem de qualquer variável que tenha sido definida no escopo do manipulador de eventos. A única variável que está sendo usada é uma variável messageHub que é declarada no nível do módulo, o que significa que é a mesma para todas as conexões. Portanto, não há absolutamente nenhuma necessidade de um temporizador separado por conexão. Portanto, removemos isso do manipulador de eventos de conexão e o incluímos em nosso código de inicialização geral, que neste caso é a função de initialize .

Por fim, em nosso processamento de respostas em webHelper.js , adicionamos processamento para qualquer resposta não reconhecida que registrará informações que serão úteis para o processo de depuração:

 if (!res || !res.d || !res.d.IsValid){ logger.debug(sendData); logger.debug(data); reject(new Error('Request failed. Path ' + params.path + ' . Invalid return data.')); return; }

A etapa final é configurar um arquivo de log para o erro padrão do Node.js. Este arquivo conterá erros não tratados que podemos ter perdido. Para definir o processo do nó no Windows (não é o ideal, mas você sabe…) como um serviço, usamos uma ferramenta chamada nssm que possui uma interface do usuário visual que permite definir um arquivo de saída padrão, arquivo de erro padrão e variáveis ​​ambientais.

Sobre o desempenho do Node.js

Node.js é uma linguagem de programação single-thread. Para melhorar a escalabilidade, existem várias alternativas que podemos empregar. Existe o módulo de cluster de nós ou simplesmente adicionar mais processos de nós e colocar um nginx em cima deles para fazer o encaminhamento e balanceamento de carga.

No nosso caso, porém, dado que cada subprocesso de cluster de nó ou processo de nó terá seu próprio espaço de memória, não poderemos compartilhar informações entre esses processos facilmente. Portanto, para este caso específico, precisaremos usar um armazenamento de dados externo (como redis) para manter os soquetes online disponíveis para os diferentes processos.

Conclusão

Com tudo isso no lugar, conseguimos uma limpeza significativa do código que foi originalmente entregue a nós. Não se trata de tornar o código perfeito, mas de reengenharia para criar uma base arquitetônica limpa que será mais fácil de suportar e manter e que facilitará e simplificará a depuração.

Aderindo aos principais princípios de design de software enumerados anteriormente – manutenibilidade, extensibilidade, modularidade e escalabilidade – criamos módulos e uma estrutura de código que identificava de forma clara e clara as diferentes responsabilidades do módulo. Também identificamos alguns problemas na implementação original que levavam a um alto consumo de memória que degradava o desempenho.

Espero que você tenha gostado do artigo, deixe-me saber se você tiver mais comentários ou perguntas.