Réingénierie logicielle : des spaghettis au design épuré
Publié: 2022-03-11Pouvez-vous jeter un œil à notre système ? Le gars qui a écrit le logiciel n'est plus là et nous avons eu un certain nombre de problèmes. Nous avons besoin de quelqu'un pour l'examiner et le nettoyer pour nous.
Quiconque travaille dans le domaine du génie logiciel depuis un certain temps sait que cette demande apparemment innocente est souvent le début d'un projet qui « a un désastre écrit partout ». Hériter du code de quelqu'un d'autre peut être un cauchemar, surtout lorsque le code est mal conçu et manque de documentation.
Ainsi, lorsque j'ai récemment reçu une demande d'un de nos clients pour examiner son application de serveur de chat socket.io existante (écrite en Node.js) et l'améliorer, j'étais extrêmement méfiant. Mais avant de courir vers les collines, j'ai décidé d'au moins accepter de jeter un œil au code.
Malheureusement, regarder le code n'a fait que réaffirmer mes préoccupations. Ce serveur de chat avait été implémenté sous la forme d'un seul gros fichier JavaScript. La réingénierie de ce fichier monolithique unique en un logiciel proprement architecturé et facilement maintenable serait en effet un défi. Mais j'aime les défis, alors j'ai accepté.
Le point de départ - Préparez-vous à la réingénierie
Le logiciel existant consistait en un seul fichier contenant 1 200 lignes de code non documenté. Ouais. De plus, il était connu pour contenir quelques bogues et avoir des problèmes de performances.
De plus, l'examen des fichiers journaux (toujours un bon point de départ pour hériter du code de quelqu'un d'autre) a révélé des problèmes potentiels de fuite de mémoire. À un moment donné, il a été signalé que le processus utilisait plus de 1 Go de RAM.
Compte tenu de ces problèmes, il est immédiatement devenu évident que le code devait être réorganisé et modularisé avant même d'essayer de déboguer ou d'améliorer la logique métier. À cette fin, certains des problèmes initiaux qui devaient être résolus comprenaient :
- Structure des codes. Le code n'avait aucune structure réelle, ce qui rendait difficile la distinction entre la configuration, l'infrastructure et la logique métier. Il n'y avait essentiellement aucune modularisation ou séparation des préoccupations.
- Code redondant. Certaines parties du code (telles que le code de gestion des erreurs pour chaque gestionnaire d'événements, le code pour effectuer des requêtes Web, etc.) ont été dupliquées plusieurs fois. Le code répliqué n'est jamais une bonne chose, ce qui rend le code beaucoup plus difficile à maintenir et plus sujet aux erreurs (lorsque le code redondant est corrigé ou mis à jour à un endroit mais pas à l'autre).
- Valeurs codées en dur. Le code contenait un certain nombre de valeurs codées en dur (rarement une bonne chose). Pouvoir modifier ces valeurs via des paramètres de configuration (plutôt que d'exiger des modifications des valeurs codées en dur dans le code) augmenterait la flexibilité et pourrait également faciliter les tests et le débogage.
- Enregistrement. Le système de journalisation était très basique. Cela générerait un seul fichier journal géant difficile et maladroit à analyser ou à analyser.
Objectifs architecturaux clés
Dans le processus de début de restructuration du code, en plus de résoudre les problèmes spécifiques identifiés ci-dessus, je voulais commencer à aborder certains des objectifs architecturaux clés qui sont (ou du moins devraient être) communs à la conception de tout système logiciel . Ceux-ci inclus:
- Maintenabilité. N'écrivez jamais un logiciel en vous attendant à être la seule personne qui aura besoin de le maintenir. Considérez toujours à quel point votre code sera compréhensible pour quelqu'un d'autre et à quel point il lui sera facile de le modifier ou de le déboguer.
- Extensibilité. Ne présumez jamais que la fonctionnalité que vous implémentez aujourd'hui est tout ce dont vous aurez besoin. Concevez votre logiciel de manière à ce qu'il soit facile de l'étendre.
- Modularité. Séparez les fonctionnalités en modules logiques et distincts, chacun avec son propre objectif et sa propre fonction.
- Évolutivité. Les utilisateurs d'aujourd'hui sont de plus en plus impatients, s'attendant à des temps de réponse immédiats (ou du moins presque immédiats). De mauvaises performances et une latence élevée peuvent entraîner l'échec de l'application la plus utile sur le marché. Comment votre logiciel fonctionnera-t-il à mesure que le nombre d'utilisateurs simultanés et les besoins en bande passante augmenteront ? Des techniques telles que la parallélisation, l'optimisation de la base de données et le traitement asynchrone peuvent aider à améliorer la capacité de votre système à rester réactif, malgré l'augmentation des demandes de charge et de ressources.
Restructuration du Code
Notre objectif est de passer d'un seul fichier de code source mongo monolithique à un ensemble modularisé de composants proprement architecturés. Le code résultant devrait être beaucoup plus facile à maintenir, à améliorer et à déboguer.
Pour cette application, j'ai décidé d'organiser le code dans les composants architecturaux distincts suivants :
- app.js - c'est notre point d'entrée, notre code s'exécutera à partir d'ici
- config - c'est là que résideront nos paramètres de configuration
- ioW - un "encapsuleur IO" qui contiendra toute la logique IO (et métier)
- logging - tout le code lié à la journalisation (notez que la structure de répertoires inclura également un nouveau dossier de
logs
qui contiendra tous les fichiers journaux) - package.json - la liste des dépendances de package pour Node.js
- node_modules - tous les modules requis par Node.js
Il n'y a rien de magique dans cette approche spécifique ; il pourrait y avoir de nombreuses façons différentes de restructurer le code. J'ai juste personnellement estimé que cette organisation était suffisamment propre et bien organisée sans être trop complexe.
L'organisation des répertoires et des fichiers qui en résulte est illustrée ci-dessous.
Enregistrement
Les packages de journalisation ont été développés pour la plupart des environnements et des langages de développement actuels, il est donc rare de nos jours que vous ayez besoin de « lancer votre propre » capacité de journalisation.
Puisque nous travaillons avec Node.js, j'ai sélectionné log4js-node, qui est essentiellement une version de la bibliothèque log4js à utiliser avec Node.js. Cette bibliothèque a des fonctionnalités intéressantes comme la possibilité d'enregistrer plusieurs niveaux de messages (AVERTISSEMENT, ERREUR, etc.) et nous pouvons avoir un fichier roulant qui peut être divisé, par exemple, sur une base quotidienne, donc nous n'avons pas à traiter des fichiers volumineux qui prendront beaucoup de temps à ouvrir et seront difficiles à analyser et à analyser.
Pour nos besoins, j'ai créé un petit wrapper autour de log4js-node pour ajouter des fonctionnalités supplémentaires spécifiques souhaitées. Notez que j'ai choisi de créer un wrapper autour de log4js-node que j'utiliserai ensuite dans mon code. Cela localise la mise en œuvre de ces capacités de journalisation étendues dans un emplacement unique, évitant ainsi la redondance et la complexité inutile dans mon code lorsque j'invoque la journalisation.
Puisque nous travaillons avec des E/S, et que nous aurions plusieurs clients (utilisateurs) qui engendreront plusieurs connexions (sockets), je veux pouvoir tracer l'activité d'un utilisateur spécifique dans les fichiers journaux, et je veux aussi savoir la source de chaque entrée de journal. Je m'attends donc à avoir des entrées de journal concernant l'état de l'application, et certaines qui sont spécifiques à l'activité de l'utilisateur.
Dans mon code wrapper de journalisation, je peux mapper l'ID utilisateur et les sockets, ce qui me permettra de suivre les actions qui ont été effectuées avant et après un événement ERROR. Le wrapper de journalisation me permettra également de créer différents enregistreurs avec différentes informations contextuelles que je peux transmettre aux gestionnaires d'événements afin de connaître la source de l'entrée de journal.
Le code du wrapper de journalisation est disponible ici.
Configuration
Il est souvent nécessaire de prendre en charge différentes configurations pour un système. Ces différences peuvent être soit des différences entre les environnements de développement et de production, soit même basées sur la nécessité d'afficher différents environnements client et scénarios d'utilisation.
Plutôt que d'exiger des modifications du code pour prendre en charge cela, la pratique courante consiste à contrôler ces différences de comportement au moyen de paramètres de configuration. Dans mon cas, j'avais besoin de pouvoir disposer de différents environnements d'exécution (staging et production), qui peuvent avoir des paramètres différents. Je voulais également m'assurer que le code testé fonctionnait bien à la fois en staging et en production, et si j'avais eu besoin de modifier le code à cette fin, cela aurait invalidé le processus de test.
À l'aide d'une variable d'environnement Node.js, je peux spécifier le fichier de configuration que je souhaite utiliser pour une exécution spécifique. J'ai donc déplacé tous les paramètres de configuration précédemment codés en dur dans des fichiers de configuration et créé un module de configuration simple qui charge le fichier de configuration approprié avec les paramètres souhaités. J'ai également classé tous les paramètres pour appliquer un certain degré d'organisation au fichier de configuration et pour faciliter la navigation.

Voici un exemple de fichier de configuration résultant :
{ "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" } }
Flux de codes
Jusqu'à présent, nous avons créé une structure de dossiers pour héberger les différents modules, nous avons mis en place un moyen de charger des informations spécifiques à l'environnement et créé un système de journalisation. Voyons donc comment nous pouvons lier toutes les pièces sans modifier le code spécifique à l'entreprise.
Grâce à notre nouvelle structure modulaire du code, notre point d'entrée app.js
est assez simple, ne contenant que du code d'initialisation :
var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);
Lorsque nous avons défini notre structure de code, nous avons dit que le dossier ioW
contiendrait le code lié à l'entreprise et à socket.io. Plus précisément, il contiendra les fichiers suivants (notez que vous pouvez cliquer sur l'un des noms de fichiers répertoriés pour afficher le code source correspondant) :
-
index.js
- gère l'initialisation et les connexions de socket.io ainsi que l'abonnement aux événements, ainsi qu'un gestionnaire d'erreurs centralisé pour les événements -
eventManager.js
- héberge toute la logique liée à l'entreprise (gestionnaires d'événements) -
webHelper.js
- méthodes d'assistance pour effectuer des requêtes Web. -
linkedList.js
- une classe utilitaire de liste liée
Nous avons refactorisé le code qui fait la requête Web et l'avons déplacé dans un fichier séparé, et nous avons réussi à garder notre logique métier au même endroit et sans modification.
Une remarque importante : à ce stade, eventManager.js
contient encore des fonctions d'assistance qui doivent vraiment être extraites dans un module séparé. Cependant, comme notre objectif dans cette première passe était de réorganiser le code tout en minimisant l'impact sur la logique métier, et que ces fonctions d'assistance sont trop étroitement liées à la logique métier, nous avons choisi de reporter cela à une passe ultérieure pour améliorer l'organisation de la code.
Étant donné que Node.js est asynchrone par définition, nous rencontrons souvent un peu "l'enfer des rappels" qui rend le code particulièrement difficile à naviguer et à déboguer. Pour éviter cet écueil, dans ma nouvelle implémentation, j'ai utilisé le modèle de promesses et j'utilise spécifiquement bluebird qui est une bibliothèque de promesses très agréable et rapide. Les promesses nous permettront de pouvoir suivre le code comme s'il était synchrone et fourniront également une gestion des erreurs et un moyen propre de standardiser les réponses entre les appels. Il existe un contrat implicite dans notre code selon lequel chaque gestionnaire d'événements doit renvoyer une promesse afin que nous puissions gérer la gestion et la journalisation centralisées des erreurs.
Tous les gestionnaires d'événements renverront une promesse (qu'ils fassent des appels asynchrones ou non). Avec cela en place, nous pouvons centraliser la gestion et la journalisation des erreurs et nous nous assurons que, si nous avons une erreur non gérée dans le gestionnaire d'événements, cette erreur est détectée.
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); }); };
Dans notre discussion sur la journalisation, nous avons mentionné que chaque connexion aurait son propre enregistreur contenant des informations contextuelles. Plus précisément, nous lions l'identifiant de socket et le nom de l'événement au journal lorsque nous le créons, donc lorsque nous transmettons ce journal au gestionnaire d'événements, chaque ligne de journal contiendra ces informations :
var sLogger = logging.createLogger(socket.id + ' - ' + eventName);
Un autre point mérite d'être mentionné concernant la gestion des événements : dans le fichier d'origine, nous avions un appel de fonction setInterval
qui se trouvait à l'intérieur du gestionnaire d'événements de l'événement de connexion socket.io, et nous avons identifié cette fonction comme un problème.
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); });
Ce code crée une minuterie avec un intervalle spécifié (dans notre cas, c'était 1 minute) pour chaque demande de connexion que nous recevons. Ainsi, par exemple, si à un moment donné nous avons 300 sockets en ligne, alors nous aurions 300 minuteries s'exécutant chaque minute. Le problème avec cela, comme vous pouvez le voir dans le code ci-dessus, est qu'il n'y a pas d'utilisation du socket ni d'aucune variable définie dans le cadre du gestionnaire d'événements. La seule variable utilisée est une variable messageHub
déclarée au niveau du module, ce qui signifie qu'elle est la même pour toutes les connexions. Il n'y a donc absolument pas besoin d'une minuterie séparée par connexion. Nous avons donc supprimé ceci du gestionnaire d'événements de connexion et l'avons inclus dans notre code d'initialisation général, qui dans ce cas est la fonction d' initialize
.
Enfin, dans notre traitement des réponses dans webHelper.js
, nous avons ajouté le traitement de toute réponse non reconnue qui enregistrera des informations qui seront ensuite utiles au processus de débogage :
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; }
La dernière étape consiste à configurer un fichier de journalisation pour l'erreur standard de Node.js. Ce fichier contiendra des erreurs non gérées que nous avons peut-être manquées. Pour définir le processus de nœud dans Windows (pas idéal mais vous savez…) en tant que service, nous utilisons un outil appelé nssm qui a une interface utilisateur visuelle qui vous permet de définir un fichier de sortie standard, un fichier d'erreur standard et des variables d'environnement.
À propos des performances de Node.js
Node.js est un langage de programmation monothread. Afin d'améliorer l'évolutivité, il existe plusieurs alternatives que nous pouvons utiliser. Il y a le module de cluster de nœuds ou simplement ajouter plus de processus de nœud et mettre un nginx au-dessus de ceux-ci pour faire le transfert et l'équilibrage de charge.
Dans notre cas, cependant, étant donné que chaque sous-processus de cluster de nœuds ou processus de nœud aura son propre espace mémoire, nous ne pourrons pas partager facilement les informations entre ces processus. Donc, pour ce cas particulier, nous devrons utiliser un magasin de données externe (comme redis) afin de garder les sockets en ligne disponibles pour les différents processus.
Conclusion
Avec tout cela en place, nous avons réalisé un nettoyage important du code qui nous a été remis à l'origine. Il ne s'agit pas de rendre le code parfait, mais plutôt de le réorganiser pour créer une base architecturale propre qui sera plus facile à prendre en charge et à maintenir, et qui facilitera et simplifiera le débogage.
En adhérant aux principes clés de conception de logiciels énumérés précédemment - maintenabilité, extensibilité, modularité et évolutivité - nous avons créé des modules et une structure de code qui identifiaient clairement et proprement les différentes responsabilités des modules. Nous avons également identifié certains problèmes dans l'implémentation d'origine qui entraînaient une consommation de mémoire élevée qui dégradait les performances.
J'espère que vous avez apprécié l'article, faites-moi savoir si vous avez d'autres commentaires ou questions.