Software-Reengineering: Von Spaghetti zu Clean Design
Veröffentlicht: 2022-03-11Können Sie sich unser System ansehen? Der Typ, der die Software geschrieben hat, ist nicht mehr da und wir haben eine Reihe von Problemen. Wir brauchen jemanden, der sich das ansieht und es für uns aufräumt.
Jeder, der schon länger im Software-Engineering ist, weiß, dass diese scheinbar harmlose Anfrage oft der Beginn eines Projekts ist, das „überall in der Katastrophe steht“. Das Erben des Codes einer anderen Person kann ein Albtraum sein, insbesondere wenn der Code schlecht entworfen ist und keine Dokumentation enthält.
Als ich kürzlich eine Anfrage von einem unserer Kunden erhielt, seine bestehende Socket.io-Chatserveranwendung (geschrieben in Node.js) zu überprüfen und zu verbessern, war ich äußerst vorsichtig. Aber bevor ich in die Berge rannte, beschloss ich, zumindest zuzustimmen, einen Blick auf den Code zu werfen.
Leider bestätigte der Blick auf den Code nur meine Bedenken. Dieser Chat-Server wurde als einzelne, große JavaScript-Datei implementiert. Die Umgestaltung dieser einzelnen monolithischen Datei in eine Software mit sauberer Architektur und einfacher Wartung wäre in der Tat eine Herausforderung. Aber ich genieße eine Herausforderung, also habe ich zugestimmt.
Der Ausgangspunkt – Bereiten Sie sich auf das Reengineering vor
Die vorhandene Software bestand aus einer einzigen Datei mit 1.200 Zeilen nicht dokumentiertem Code. Huch. Darüber hinaus war bekannt, dass es einige Fehler enthielt und einige Leistungsprobleme hatte.
Darüber hinaus ergab die Untersuchung der Protokolldateien (immer ein guter Anfang, wenn der Code eines anderen geerbt wird) potenzielle Probleme mit Speicherlecks. Irgendwann wurde berichtet, dass der Prozess mehr als 1 GB RAM verwendet.
Angesichts dieser Probleme wurde sofort klar, dass der Code neu organisiert und modularisiert werden musste, bevor überhaupt versucht wurde, die Geschäftslogik zu debuggen oder zu verbessern. Zu diesem Zweck waren einige der anfänglichen Probleme, die angegangen werden mussten, unter anderem:
- Codestruktur. Der Code hatte überhaupt keine wirkliche Struktur, was es schwierig machte, die Konfiguration von der Infrastruktur von der Geschäftslogik zu unterscheiden. Es gab im Wesentlichen keine Modularisierung oder Trennung von Anliegen.
- Redundanter Code. Einige Teile des Codes (z. B. Fehlerbehandlungscode für jeden Event-Handler, der Code für Webanfragen usw.) wurden mehrfach dupliziert. Replizierter Code ist nie eine gute Sache, was die Wartung von Code deutlich schwieriger macht und anfälliger für Fehler ist (wenn der redundante Code an einer Stelle repariert oder aktualisiert wird, an der anderen jedoch nicht).
- Fest codierte Werte. Der Code enthielt eine Reihe von hartcodierten Werten (selten eine gute Sache). Die Möglichkeit, diese Werte über Konfigurationsparameter zu ändern (statt Änderungen an fest codierten Werten im Code zu erfordern), würde die Flexibilität erhöhen und könnte auch dazu beitragen, das Testen und Debuggen zu erleichtern.
- Protokollierung. Das Protokollierungssystem war sehr einfach. Es würde eine einzelne riesige Protokolldatei erzeugen, die schwierig und umständlich zu analysieren oder zu parsen war.
Wichtige architektonische Ziele
Bei der beginnenden Umstrukturierung des Codes wollte ich neben der Behandlung der oben genannten spezifischen Probleme damit beginnen, einige der wichtigsten architektonischen Ziele anzusprechen, die dem Design eines jeden Softwaresystems gemeinsam sind (oder zumindest sein sollten). . Diese schließen ein:
- Wartbarkeit. Schreiben Sie niemals Software in der Erwartung, die einzige Person zu sein, die sie pflegen muss. Denken Sie immer daran, wie verständlich Ihr Code für andere ist und wie einfach es für sie ist, ihn zu ändern oder zu debuggen.
- Erweiterbarkeit. Gehen Sie niemals davon aus, dass die Funktionalität, die Sie heute implementieren, alles ist, was jemals benötigt wird. Gestalten Sie Ihre Software so, dass sie einfach zu erweitern ist.
- Modularität. Trennen Sie die Funktionalität in logische und unterschiedliche Module, jedes mit seinem eigenen klaren Zweck und seiner eigenen Funktion.
- Skalierbarkeit. Die Benutzer von heute sind zunehmend ungeduldig und erwarten sofortige (oder zumindest nahezu sofortige) Reaktionszeiten. Schlechte Leistung und hohe Latenz können dazu führen, dass selbst die nützlichste Anwendung auf dem Markt scheitert. Wie wird Ihre Software funktionieren, wenn die Anzahl der gleichzeitigen Benutzer und die Bandbreitenanforderungen steigen? Techniken wie Parallelisierung, Datenbankoptimierung und asynchrone Verarbeitung können dazu beitragen, die Fähigkeit Ihres Systems zu verbessern, trotz steigender Last- und Ressourcenanforderungen reaktionsfähig zu bleiben.
Neustrukturierung des Kodex
Unser Ziel ist es, von einer einzelnen monolithischen Mongo-Quellcodedatei zu einem modularisierten Satz von Komponenten mit sauberer Architektur überzugehen. Der resultierende Code sollte wesentlich einfacher zu warten, zu verbessern und zu debuggen sein.
Für diese Anwendung habe ich mich entschieden, den Code in die folgenden unterschiedlichen Architekturkomponenten zu organisieren:
- app.js - dies ist unser Einstiegspunkt, unser Code wird von hier aus ausgeführt
- config - hier befinden sich unsere Konfigurationseinstellungen
- ioW – ein „IO-Wrapper“, der die gesamte IO- (und Geschäfts-) Logik enthält
- Protokollierung – sämtlicher protokollierungsbezogener Code (beachten Sie, dass die Verzeichnisstruktur auch einen neuen
logs
enthält, der alle Protokolldateien enthält) - package.json – die Liste der Paketabhängigkeiten für Node.js
- node_modules – alle von Node.js benötigten Module
Dieser spezifische Ansatz hat nichts Magisches; Es könnte viele verschiedene Möglichkeiten geben, den Code umzustrukturieren. Ich persönlich hatte einfach das Gefühl, dass diese Organisation ausreichend sauber und gut organisiert war, ohne übermäßig komplex zu sein.
Die resultierende Verzeichnis- und Dateiorganisation ist unten dargestellt.
Protokollierung
Logging-Pakete wurden für die meisten heutigen Entwicklungsumgebungen und Sprachen entwickelt, daher ist es heutzutage selten, dass Sie Ihre eigene Logging-Funktion „rollen“ müssen.
Da wir mit Node.js arbeiten, habe ich log4js-node ausgewählt, das im Grunde eine Version der log4js-Bibliothek zur Verwendung mit Node.js ist. Diese Bibliothek hat einige coole Funktionen wie die Möglichkeit, mehrere Nachrichtenebenen (WARNUNG, FEHLER usw.) zu protokollieren, und wir können eine fortlaufende Datei haben, die beispielsweise täglich geteilt werden kann, sodass wir dies nicht müssen Umgang mit riesigen Dateien, deren Öffnen viel Zeit in Anspruch nimmt und die schwer zu analysieren und zu parsen sind.
Für unsere Zwecke habe ich einen kleinen Wrapper um den log4js-Knoten erstellt, um einige spezifische zusätzliche gewünschte Funktionen hinzuzufügen. Beachten Sie, dass ich mich dafür entschieden habe, einen Wrapper um den log4js-Knoten zu erstellen, den ich dann in meinem gesamten Code verwenden werde. Dadurch wird die Implementierung dieser erweiterten Protokollierungsfunktionen an einem einzigen Ort lokalisiert, wodurch Redundanz und unnötige Komplexität in meinem gesamten Code vermieden werden, wenn ich die Protokollierung aufrufe.
Da wir mit E/A arbeiten und mehrere Clients (Benutzer) haben, die mehrere Verbindungen (Sockets) erzeugen, möchte ich die Aktivität eines bestimmten Benutzers in den Protokolldateien nachverfolgen können und auch wissen die Quelle jedes Protokolleintrags. Ich erwarte daher einige Protokolleinträge zum Status der Anwendung und einige, die sich auf die Benutzeraktivität beziehen.
In meinem Logging-Wrapper-Code kann ich Benutzer-ID und Sockets zuordnen, wodurch ich die Aktionen nachverfolgen kann, die vor und nach einem ERROR-Ereignis ausgeführt wurden. Der Logging-Wrapper ermöglicht mir auch, verschiedene Logger mit unterschiedlichen Kontextinformationen zu erstellen, die ich an die Event-Handler übergeben kann, damit ich die Quelle des Logeintrags kenne.
Der Code für den Logging-Wrapper ist hier verfügbar.
Aufbau
Häufig ist es notwendig, unterschiedliche Konfigurationen für ein System zu unterstützen. Diese Unterschiede können entweder Unterschiede zwischen der Entwicklungs- und der Produktionsumgebung sein oder sogar auf der Notwendigkeit beruhen, unterschiedliche Kundenumgebungen und Nutzungsszenarien darzustellen.
Anstatt Änderungen am Code zu erfordern, um dies zu unterstützen, besteht die gängige Praxis darin, diese Verhaltensunterschiede über Konfigurationsparameter zu steuern. In meinem Fall brauchte ich die Möglichkeit, verschiedene Ausführungsumgebungen (Staging und Produktion) zu haben, die möglicherweise unterschiedliche Einstellungen haben. Ich wollte auch sicherstellen, dass der getestete Code sowohl in der Staging- als auch in der Produktionsumgebung gut funktioniert, und hätte ich den Code zu diesem Zweck ändern müssen, hätte dies den Testprozess ungültig gemacht.
Mit einer Node.js-Umgebungsvariablen kann ich angeben, welche Konfigurationsdatei ich für eine bestimmte Ausführung verwenden möchte. Ich habe daher alle zuvor hartcodierten Konfigurationsparameter in Konfigurationsdateien verschoben und ein einfaches Konfigurationsmodul erstellt, das die richtige Konfigurationsdatei mit den gewünschten Einstellungen lädt. Ich habe auch alle Einstellungen kategorisiert, um ein gewisses Maß an Organisation in der Konfigurationsdatei zu erzwingen und die Navigation zu erleichtern.

Hier ist ein Beispiel für eine resultierende Konfigurationsdatei:
{ "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" } }
Codefluss
Bisher haben wir eine Ordnerstruktur erstellt, um die verschiedenen Module zu hosten, wir haben eine Möglichkeit eingerichtet, umgebungsspezifische Informationen zu laden, und ein Protokollierungssystem erstellt, also lassen Sie uns sehen, wie wir alle Teile zusammenfügen können, ohne den geschäftsspezifischen Code zu ändern.
Dank unserer neuen modularen Struktur des Codes ist unser Einstiegspunkt app.js
einfach genug und enthält nur den Initialisierungscode:
var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);
Als wir unsere Codestruktur definiert haben, sagten wir, dass der ioW
Ordner Business- und socket.io-bezogenen Code enthalten würde. Insbesondere enthält es die folgenden Dateien (beachten Sie, dass Sie auf einen der aufgelisteten Dateinamen klicken können, um den entsprechenden Quellcode anzuzeigen):
-
index.js
– handhabt die Socket.io-Initialisierung und -Verbindungen sowie das Ereignisabonnement sowie einen zentralisierten Fehlerhandler für Ereignisse -
eventManager.js
– hostet die gesamte geschäftsbezogene Logik (Event-Handler) -
webHelper.js
– Hilfsmethoden für Webanfragen. -
linkedList.js
– eine Hilfsklasse für verknüpfte Listen
Wir haben den Code, der Webanfragen stellt, umgestaltet und in eine separate Datei verschoben, und wir haben es geschafft, unsere Geschäftslogik am selben Ort und unverändert zu lassen.
Ein wichtiger Hinweis: Zu diesem Zeitpunkt enthält eventManager.js
noch einige Hilfsfunktionen, die wirklich in ein separates Modul extrahiert werden sollten. Da unser Ziel in diesem ersten Durchgang jedoch darin bestand, den Code neu zu organisieren und gleichzeitig die Auswirkungen auf die Geschäftslogik zu minimieren, und diese Hilfsfunktionen zu kompliziert in die Geschäftslogik eingebunden sind, haben wir uns entschieden, dies auf einen späteren Durchgang zur Verbesserung der Organisation des zu verschieben Code.
Da Node.js per Definition asynchron ist, stoßen wir oft auf eine Art Rattennest der „Callback-Hölle“, wodurch der Code besonders schwer zu navigieren und zu debuggen ist. Um diese Falle zu vermeiden, habe ich in meiner neuen Implementierung das Promises-Muster verwendet und nutze speziell Bluebird, eine sehr schöne und schnelle Promises-Bibliothek. Promises ermöglichen es uns, dem Code so zu folgen, als ob er synchron wäre, und bieten außerdem eine Fehlerverwaltung und eine saubere Möglichkeit, Antworten zwischen Anrufen zu standardisieren. Es gibt einen impliziten Vertrag in unserem Code, dass jeder Event-Handler ein Versprechen zurückgeben muss, damit wir die zentrale Fehlerbehandlung und Protokollierung verwalten können.
Alle Ereignishandler geben ein Versprechen zurück (unabhängig davon, ob sie asynchrone Aufrufe durchführen oder nicht). Damit können wir die Fehlerbehandlung und -protokollierung zentralisieren und sicherstellen, dass ein nicht behandelter Fehler im Ereignishandler abgefangen wird.
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); }); };
In unserer Erörterung der Protokollierung haben wir erwähnt, dass jede Verbindung einen eigenen Protokollierer mit darin enthaltenen Kontextinformationen haben würde. Insbesondere binden wir die Socket-ID und den Ereignisnamen an den Logger, wenn wir ihn erstellen. Wenn wir also diesen Logger an den Event-Handler übergeben, enthält jede Protokollzeile diese Informationen:
var sLogger = logging.createLogger(socket.id + ' - ' + eventName);
Ein weiterer erwähnenswerter Punkt in Bezug auf die Ereignisbehandlung: In der Originaldatei hatten wir einen setInterval
Funktionsaufruf, der sich innerhalb des Ereignishandlers des Socket.io-Verbindungsereignisses befand, und wir haben diese Funktion als Problem identifiziert.
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); });
Dieser Code erstellt einen Timer mit einem bestimmten Intervall (in unserem Fall war es 1 Minute) für jede einzelne Verbindungsanfrage , die wir erhalten. Wenn wir zum Beispiel zu einem bestimmten Zeitpunkt 300 Online-Sockets haben, dann würden wir 300 Timer haben, die jede Minute ausgeführt werden. Das Problem dabei, wie Sie im obigen Code sehen können, besteht darin, dass weder der Socket noch eine Variable verwendet wird, die im Bereich des Ereignishandlers definiert wurde. Die einzige Variable, die verwendet wird, ist eine messageHub
Variable, die auf Modulebene deklariert wird, was bedeutet, dass sie für alle Verbindungen gleich ist. Ein separater Timer pro Anschluss ist somit absolut nicht erforderlich. Daher haben wir dies aus dem Verbindungsereignishandler entfernt und in unseren allgemeinen Initialisierungscode aufgenommen, der in diesem Fall die initialize
ist.
Schließlich haben wir bei unserer Verarbeitung von Antworten in webHelper.js
eine Verarbeitung für jede nicht erkannte Antwort hinzugefügt, die Informationen protokolliert, die dann für den Debugging-Prozess hilfreich sind:
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; }
Der letzte Schritt besteht darin, eine Protokolldatei für den Standardfehler von Node.js einzurichten. Diese Datei enthält unbehandelte Fehler, die wir möglicherweise übersehen haben. Um den Knotenprozess in Windows (nicht ideal, aber wissen Sie …) als Dienst festzulegen, verwenden wir ein Tool namens nssm, das über eine visuelle Benutzeroberfläche verfügt, mit der Sie eine Standardausgabedatei, eine Standardfehlerdatei und Umgebungsvariablen definieren können.
Über die Leistung von Node.js
Node.js ist eine Singlethread-Programmiersprache. Um die Skalierbarkeit zu verbessern, gibt es mehrere Alternativen, die wir verwenden können. Es gibt das Node-Cluster-Modul oder einfach nur mehr Node-Prozesse hinzufügen und ein nginx auf diese setzen, um die Weiterleitung und den Lastausgleich durchzuführen.
Da in unserem Fall jedoch jeder Node-Cluster-Unterprozess oder Node-Prozess über seinen eigenen Speicherplatz verfügt, können wir Informationen zwischen diesen Prozessen nicht einfach austauschen. Für diesen speziellen Fall müssen wir also einen externen Datenspeicher (z. B. Redis) verwenden, um die Online-Sockets für die verschiedenen Prozesse verfügbar zu halten.
Fazit
Mit all dem haben wir eine erhebliche Bereinigung des Codes erreicht, der uns ursprünglich übergeben wurde. Dabei geht es nicht darum, den Code zu perfektionieren, sondern vielmehr darum, ihn umzugestalten, um eine saubere architektonische Grundlage zu schaffen, die leichter zu unterstützen und zu warten ist und die das Debuggen erleichtert und vereinfacht.
Unter Einhaltung der zuvor aufgezählten Schlüsselprinzipien des Softwaredesigns – Wartbarkeit, Erweiterbarkeit, Modularität und Skalierbarkeit – haben wir Module und eine Codestruktur erstellt, die die verschiedenen Modulverantwortlichkeiten klar und sauber identifiziert. Wir haben auch einige Probleme in der ursprünglichen Implementierung identifiziert, die zu einem hohen Speicherverbrauch führten, der die Leistung beeinträchtigte.
Ich hoffe, Ihnen hat der Artikel gefallen. Lassen Sie mich wissen, wenn Sie weitere Kommentare oder Fragen haben.