Reingeniería de software: del espagueti al diseño limpio

Publicado: 2022-03-11

¿Puedes echar un vistazo a nuestro sistema? El tipo que escribió el software ya no está y hemos tenido varios problemas. Necesitamos que alguien lo revise y lo limpie por nosotros.

Cualquiera que haya estado en ingeniería de software durante un tiempo razonable sabe que esta solicitud aparentemente inocente es a menudo el comienzo de un proyecto que "tiene un desastre escrito por todas partes". Heredar el código de otra persona puede ser una pesadilla, especialmente cuando el código está mal diseñado y carece de documentación.

Entonces, cuando recientemente recibí una solicitud de uno de nuestros clientes para revisar su aplicación de servidor de chat socket.io existente (escrita en Node.js) y mejorarla, estaba extremadamente cauteloso. Pero antes de correr hacia las colinas, decidí al menos acceder a echar un vistazo al código.

Desafortunadamente, mirar el código solo reafirmó mis preocupaciones. Este servidor de chat se había implementado como un único archivo JavaScript de gran tamaño. La reingeniería de este único archivo monolítico en una pieza de software de arquitectura limpia y fácil de mantener sería un verdadero desafío. Pero me gustan los desafíos, así que acepté.

reingeniería de software

El punto de partida - Prepárese para la reingeniería

El software existente constaba de un solo archivo que contenía 1.200 líneas de código no documentado. ¡Ay! Además, se sabía que contenía algunos errores y tenía algunos problemas de rendimiento.

Además, el examen de los archivos de registro (que siempre es un buen punto de partida cuando se hereda el código de otra persona) reveló posibles problemas de pérdida de memoria. En algún momento, se informó que el proceso usaba más de 1 GB de RAM.

Dados estos problemas, quedó claro de inmediato que el código tendría que ser reorganizado y modularizado antes incluso de intentar depurar o mejorar la lógica empresarial. Con ese fin, algunos de los problemas iniciales que debían abordarse incluían:

  • Estructura del código. El código no tenía ninguna estructura real, lo que dificultaba distinguir la configuración de la infraestructura de la lógica empresarial. Esencialmente no hubo modularización o separación de preocupaciones.
  • Código redundante. Algunas partes del código (como el código de manejo de errores para cada controlador de eventos, el código para realizar solicitudes web, etc.) se duplicaron varias veces. El código replicado nunca es bueno, lo que hace que el código sea significativamente más difícil de mantener y más propenso a errores (cuando el código redundante se corrige o actualiza en un lugar pero no en el otro).
  • Valores codificados. El código contenía una serie de valores codificados (rara vez algo bueno). Ser capaz de modificar estos valores a través de parámetros de configuración (en lugar de requerir cambios en los valores codificados en el código) aumentaría la flexibilidad y también podría ayudar a facilitar las pruebas y la depuración.
  • Inicio sesión. El sistema de registro era muy básico. Generaría un solo archivo de registro gigante que era difícil y torpe de analizar o analizar.

Objetivos arquitectónicos clave

En el proceso de comenzar a reestructurar el código, además de abordar los problemas específicos identificados anteriormente, quería comenzar a abordar algunos de los objetivos arquitectónicos clave que son (o al menos deberían ser) comunes al diseño de cualquier sistema de software. . Éstos incluyen:

  • Mantenibilidad. Nunca escriba software esperando ser la única persona que necesitará mantenerlo. Siempre considere qué tan comprensible será su código para otra persona y qué tan fácil será para ellos modificarlo o depurarlo.
  • Extensibilidad. Nunca asuma que la funcionalidad que está implementando hoy es todo lo que necesitará. Diseñe su software de manera que sea fácil de ampliar.
  • Modularidad. Separe la funcionalidad en módulos lógicos y distintos, cada uno con su propio propósito y función claros.
  • Escalabilidad. Los usuarios de hoy son cada vez más impacientes y esperan tiempos de respuesta inmediatos (o al menos casi inmediatos). El bajo rendimiento y la alta latencia pueden hacer que incluso la aplicación más útil falle en el mercado. ¿Cómo funcionará su software a medida que aumente la cantidad de usuarios simultáneos y los requisitos de ancho de banda? Las técnicas como la paralelización, la optimización de bases de datos y el procesamiento asincrónico pueden ayudar a mejorar la capacidad de su sistema para seguir respondiendo, a pesar de las crecientes demandas de carga y recursos.

Reestructuración del Código

Nuestro objetivo es pasar de un único archivo de código fuente mongo monolítico a un conjunto modular de componentes de arquitectura limpia. El código resultante debería ser significativamente más fácil de mantener, mejorar y depurar.

Para esta aplicación, he decidido organizar el código en los siguientes componentes arquitectónicos distintos:

  • app.js : este es nuestro punto de entrada, nuestro código se ejecutará desde aquí
  • config - aquí donde residirán nuestros ajustes de configuración
  • ioW : un "envoltorio de IO" que contendrá toda la lógica de IO (y de negocios)
  • registro : todo el código relacionado con el registro (tenga en cuenta que la estructura del directorio también incluirá una nueva carpeta de logs que contendrá todos los archivos de registro)
  • package.json : la lista de dependencias de paquetes para Node.js
  • node_modules : todos los módulos requeridos por Node.js

No hay nada mágico en este enfoque específico; podría haber muchas formas diferentes de reestructurar el código. Personalmente, sentí que esta organización estaba lo suficientemente limpia y bien organizada sin ser demasiado compleja.

El directorio resultante y la organización de archivos se muestran a continuación.

código reestructurado

Inicio sesión

Los paquetes de registro se han desarrollado para la mayoría de los entornos y lenguajes de desarrollo actuales, por lo que hoy en día es raro que necesite "hacer rodar su propia" capacidad de registro.

Como estamos trabajando con Node.js, seleccioné log4js-node, que es básicamente una versión de la biblioteca log4js para usar con Node.js. Esta biblioteca tiene algunas funciones interesantes, como la capacidad de registrar varios niveles de mensajes (ADVERTENCIA, ERROR, etc.) y podemos tener un archivo continuo que se puede dividir, por ejemplo, diariamente, para que no tengamos que lidiar con archivos enormes que llevarán mucho tiempo abrir y serán difíciles de analizar y analizar.

Para nuestros propósitos, he creado un pequeño contenedor alrededor de log4js-node para agregar algunas capacidades específicas adicionales deseadas. Tenga en cuenta que elegí crear un contenedor alrededor del nodo log4js que luego usaré en todo mi código. Esto localiza la implementación de estas capacidades de registro extendidas en una sola ubicación, evitando así la redundancia y la complejidad innecesaria en todo mi código cuando invoco el registro.

Como estamos trabajando con E/S y tendríamos varios clientes (usuarios) que generarán varias conexiones (sockets), quiero poder rastrear la actividad de un usuario específico en los archivos de registro y también quiero saber el origen de cada entrada de registro. Por lo tanto, espero tener algunas entradas de registro relacionadas con el estado de la aplicación y algunas que son específicas de la actividad del usuario.

En mi código contenedor de registro, puedo asignar la ID de usuario y los sockets, lo que me permitirá realizar un seguimiento de las acciones que se realizaron antes y después de un evento de ERROR. El contenedor de registro también me permitirá crear diferentes registradores con diferente información contextual que puedo pasar a los controladores de eventos para conocer el origen de la entrada de registro.

El código para el contenedor de registro está disponible aquí.

Configuración

A menudo es necesario admitir diferentes configuraciones para un sistema. Estas diferencias pueden ser diferencias entre los entornos de desarrollo y producción, o incluso basarse en la necesidad de mostrar diferentes entornos de clientes y escenarios de uso.

En lugar de requerir cambios en el código para admitir esto, la práctica común es controlar estas diferencias de comportamiento mediante parámetros de configuración. En mi caso, necesitaba la capacidad de tener diferentes entornos de ejecución (puesta en escena y producción), que pueden tener diferentes configuraciones. También quería asegurarme de que el código probado funcionara bien tanto en la puesta en escena como en la producción, y si hubiera tenido que cambiar el código para este propósito, habría invalidado el proceso de prueba.

Usando una variable de entorno de Node.js, puedo especificar qué archivo de configuración quiero usar para una ejecución específica. Por lo tanto, moví todos los parámetros de configuración previamente codificados a archivos de configuración y creé un módulo de configuración simple que carga el archivo de configuración adecuado con la configuración deseada. También clasifiqué todas las configuraciones para imponer cierto grado de organización en el archivo de configuración y facilitar la navegación.

Aquí hay un ejemplo de un archivo de configuración 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" } }

Flujo de código

Hasta ahora, hemos creado una estructura de carpetas para alojar los diferentes módulos, hemos configurado una forma de cargar información específica del entorno y hemos creado un sistema de registro, así que veamos cómo podemos unir todas las piezas sin cambiar el código específico de la empresa.

Gracias a nuestra nueva estructura modular del código, nuestro punto de entrada app.js es bastante simple y solo contiene el código de inicialización:

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

Cuando definimos nuestra estructura de código, dijimos que la carpeta ioW contendría el código relacionado con el negocio y socket.io. Específicamente, contendrá los siguientes archivos (tenga en cuenta que puede hacer clic en cualquiera de los nombres de archivo enumerados para ver el código fuente correspondiente):

  • index.js : maneja la inicialización y las conexiones de socket.io, así como la suscripción de eventos, además de un controlador de errores centralizado para eventos
  • eventManager.js : aloja toda la lógica relacionada con el negocio (controladores de eventos)
  • webHelper.js : métodos auxiliares para realizar solicitudes web.
  • linkedList.js : una clase de utilidad de lista enlazada

Refactorizamos el código que hace la solicitud web y lo movimos a un archivo separado, y logramos mantener nuestra lógica comercial en el mismo lugar y sin modificaciones.

Una nota importante: en esta etapa, eventManager.js todavía contiene algunas funciones auxiliares que realmente deberían extraerse en un módulo separado. Sin embargo, dado que nuestro objetivo en este primer paso era reorganizar el código y minimizar el impacto en la lógica comercial, y estas funciones auxiliares están demasiado vinculadas a la lógica comercial, optamos por postergar esto para un paso posterior para mejorar la organización de la código.

Dado que Node.js es asíncrono por definición, a menudo nos encontramos con un nido de ratas de "infierno de devolución de llamada" que hace que el código sea particularmente difícil de navegar y depurar. Para evitar este escollo, en mi nueva implementación, he empleado el patrón de promesas y estoy aprovechando específicamente bluebird, que es una biblioteca de promesas muy agradable y rápida. Promises nos permitirá poder seguir el código como si fuera sincrónico y también brindará una gestión de errores y una forma limpia de estandarizar las respuestas entre llamadas. Hay un contrato implícito en nuestro código de que cada controlador de eventos debe devolver una promesa para que podamos administrar el registro y el manejo de errores centralizados.

Todos los controladores de eventos devolverán una promesa (ya sea que realicen llamadas asincrónicas o no). Con esto en su lugar, podemos centralizar el manejo y registro de errores y nos aseguramos de que, si tenemos un error no manejado dentro del controlador de eventos, ese error sea 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); }); };

En nuestra discusión sobre el registro, mencionamos que cada conexión tendría su propio registrador con información contextual. Específicamente, vinculamos la identificación del socket y el nombre del evento al registrador cuando lo creamos, de modo que cuando pasemos ese registrador al controlador de eventos, cada línea de registro tendrá esa información:

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

Otro punto que vale la pena mencionar con respecto al manejo de eventos: en el archivo original, teníamos una llamada a la función setInterval que estaba dentro del controlador de eventos del evento de conexión socket.io, y hemos identificado esta función como un 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á creando un temporizador con un intervalo específico (en nuestro caso fue de 1 minuto) para cada solicitud de conexión que recibimos. Entonces, por ejemplo, si en un momento dado tenemos 300 sockets en línea, entonces tendríamos 300 temporizadores ejecutándose cada minuto. El problema con esto, como puede ver en el código anterior, es que no se usa el socket ni ninguna variable definida dentro del alcance del controlador de eventos. La única variable que se utiliza es una variable messageHub que se declara en el nivel del módulo, lo que significa que es la misma para todas las conexiones. Por lo tanto, no hay absolutamente ninguna necesidad de un temporizador separado por conexión. Así que lo hemos eliminado del controlador de eventos de conexión y lo hemos incluido en nuestro código de inicialización general, que en este caso es la función de initialize .

Finalmente, en nuestro procesamiento de respuestas en webHelper.js , agregamos procesamiento para cualquier respuesta no reconocida que registrará información que luego será útil para el proceso de depuración:

 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; }

El paso final es configurar un archivo de registro para el error estándar de Node.js. Este archivo contendrá errores no controlados que podemos haber pasado por alto. Para configurar el proceso de nodo en Windows (no es lo ideal, pero ya sabe...) como un servicio, usamos una herramienta llamada nssm que tiene una interfaz de usuario visual que le permite definir un archivo de salida estándar, un archivo de error estándar y variables ambientales.

Acerca del rendimiento de Node.js

Node.js es un lenguaje de programación de un solo subproceso. Para mejorar la escalabilidad existen varias alternativas que podemos emplear. Está el módulo de clúster de nodos o simplemente agregando más procesos de nodos y colocando un nginx encima de ellos para hacer el reenvío y el equilibrio de carga.

En nuestro caso, sin embargo, dado que cada subproceso de clúster de nodo o proceso de nodo tendrá su propio espacio de memoria, no podremos compartir información entre esos procesos fácilmente. Entonces, para este caso particular, necesitaremos usar un almacén de datos externo (como redis) para mantener los sockets en línea disponibles para los diferentes procesos.

Conclusión

Con todo esto en su lugar, hemos logrado una limpieza significativa del código que se nos entregó originalmente. No se trata de hacer que el código sea perfecto, sino de rediseñarlo para crear una base arquitectónica limpia que sea más fácil de soportar y mantener, y que facilite y simplifique la depuración.

Cumpliendo con los principios clave de diseño de software enumerados anteriormente (mantenibilidad, extensibilidad, modularidad y escalabilidad), creamos módulos y una estructura de código que identificaba clara y limpiamente las diferentes responsabilidades de los módulos. También identificamos algunos problemas en la implementación original que generaban un alto consumo de memoria que degradaba el rendimiento.

Espero que hayas disfrutado el artículo, avísame si tienes más comentarios o preguntas.