Software Reengineering: dagli spaghetti al design pulito

Pubblicato: 2022-03-11

Puoi dare un'occhiata al nostro sistema? Il ragazzo che ha scritto il software non è più in giro e abbiamo avuto una serie di problemi. Abbiamo bisogno di qualcuno che lo esamini e lo pulisca per noi.

Chiunque sia stato nell'ingegneria del software per un ragionevole lasso di tempo sa che questa richiesta apparentemente innocente è spesso l'inizio di un progetto che "ha un disastro scritto dappertutto". Ereditare il codice di qualcun altro può essere un incubo, specialmente quando il codice è progettato male e manca di documentazione.

Quindi, quando di recente ho ricevuto una richiesta da uno dei nostri clienti di esaminare la sua applicazione del server di chat socket.io esistente (scritta in Node.js) e di migliorarla, ero estremamente diffidente. Ma prima di correre per le colline, ho deciso almeno di accettare di dare un'occhiata al codice.

Sfortunatamente, guardare il codice ha solo riaffermato le mie preoccupazioni. Questo server di chat è stato implementato come un unico file JavaScript di grandi dimensioni. Reingegnerizzare questo singolo file monolitico in un software progettato in modo pulito e facilmente manutenibile sarebbe davvero una sfida. Ma mi piace una sfida, quindi ho accettato.

reingegnerizzazione del software

Il punto di partenza - Prepararsi per la riprogettazione

Il software esistente consisteva in un unico file contenente 1.200 righe di codice non documentato. Yikes. Inoltre, era noto che conteneva alcuni bug e presentava alcuni problemi di prestazioni.

Inoltre, l'esame dei file di registro (sempre un buon punto di partenza quando si eredita il codice di qualcun altro) ha rivelato potenziali problemi di perdita di memoria. Ad un certo punto, è stato riferito che il processo utilizzava più di 1 GB di RAM.

Dati questi problemi, è diventato immediatamente chiaro che il codice avrebbe dovuto essere riorganizzato e modularizzato prima ancora di tentare di eseguire il debug o migliorare la logica aziendale. A tal fine, alcuni dei problemi iniziali che dovevano essere affrontati includevano:

  • Struttura del codice. Il codice non aveva alcuna struttura reale, rendendo difficile distinguere la configurazione dall'infrastruttura dalla logica aziendale. Non c'era essenzialmente alcuna modularizzazione o separazione delle preoccupazioni.
  • Codice ridondante. Alcune parti del codice (come il codice di gestione degli errori per ogni gestore di eventi, il codice per effettuare richieste Web, ecc.) sono state duplicate più volte. Il codice replicato non è mai una buona cosa, rendendo il codice molto più difficile da mantenere e più soggetto a errori (quando il codice ridondante viene corretto o aggiornato in un posto ma non nell'altro).
  • Valori codificati. Il codice conteneva una serie di valori hardcoded (raramente una buona cosa). Essere in grado di modificare questi valori tramite parametri di configurazione (piuttosto che richiedere modifiche ai valori codificati nel codice) aumenterebbe la flessibilità e potrebbe anche aiutare a facilitare il test e il debug.
  • Registrazione. Il sistema di registrazione era molto semplice. Genererebbe un unico file di registro gigantesco che era difficile e goffo da analizzare o analizzare.

Obiettivi architettonici chiave

Nel processo di inizio della ristrutturazione del codice, oltre ad affrontare le problematiche specifiche sopra individuate, ho voluto iniziare ad affrontare alcuni degli obiettivi chiave dell'architettura che sono (o almeno dovrebbero essere) comuni alla progettazione di qualsiasi sistema software . Questi includono:

  • Manutenibilità. Non scrivere mai software aspettandoti di essere l'unica persona che avrà bisogno di mantenerlo. Considera sempre quanto sarà comprensibile il tuo codice per qualcun altro e quanto sarà facile per loro modificare o eseguire il debug.
  • Estensibilità. Non dare mai per scontato che la funzionalità che stai implementando oggi sia tutto ciò che sarà mai necessario. Progetta il tuo software in modi che saranno facili da estendere.
  • Modularità. Separare le funzionalità in moduli logici e distinti, ognuno con il proprio scopo e funzione chiari.
  • Scalabilità. Gli utenti di oggi sono sempre più impazienti e si aspettano tempi di risposta immediati (o almeno quasi immediati). Scarse prestazioni e latenza elevata possono causare il fallimento anche dell'applicazione più utile sul mercato. Come funzionerà il tuo software con l'aumento del numero di utenti simultanei e dei requisiti di larghezza di banda? Tecniche come la parallelizzazione, l'ottimizzazione del database e l'elaborazione asincrona possono aiutare a migliorare la capacità del sistema di rimanere reattivo, nonostante l'aumento del carico e delle richieste di risorse.

Ristrutturazione del codice

Il nostro obiettivo è passare da un unico file di codice sorgente mongo monolitico a un insieme modulare di componenti dall'architettura pulita. Il codice risultante dovrebbe essere notevolmente più semplice da mantenere, migliorare ed eseguire il debug.

Per questa applicazione, ho deciso di organizzare il codice nei seguenti componenti architetturali distinti:

  • app.js - questo è il nostro punto di ingresso, il nostro codice verrà eseguito da qui
  • config - qui risiederanno le nostre impostazioni di configurazione
  • ioW - un "involucro IO" che conterrà tutta la logica IO (e aziendale).
  • logging - tutto il codice relativo alla registrazione (notare che la struttura della directory includerà anche una nuova cartella dei logs che conterrà tutti i file di registro)
  • package.json - l'elenco delle dipendenze del pacchetto per Node.js
  • node_modules - tutti i moduli richiesti da Node.js

Non c'è nulla di magico in questo approccio specifico; potrebbero esserci molti modi diversi per ristrutturare il codice. Personalmente ho ritenuto che questa organizzazione fosse sufficientemente pulita e ben organizzata senza essere eccessivamente complessa.

La directory risultante e l'organizzazione dei file sono mostrate di seguito.

codice ristrutturato

Registrazione

I pacchetti di registrazione sono stati sviluppati per la maggior parte degli ambienti di sviluppo e dei linguaggi odierni, quindi al giorno d'oggi è raro che sia necessario "roll propria" la capacità di registrazione.

Dato che stiamo lavorando con Node.js, ho selezionato log4js-node, che è fondamentalmente una versione della libreria log4js da utilizzare con Node.js. Questa libreria ha alcune caratteristiche interessanti come la possibilità di registrare diversi livelli di messaggi (AVVISO, ERRORE, ecc.) e possiamo avere un file scorrevole che può essere diviso, ad esempio, su base giornaliera, quindi non dobbiamo gestire file di grandi dimensioni che richiederanno molto tempo per essere aperti e saranno difficili da analizzare e analizzare.

Per i nostri scopi, ho creato un piccolo wrapper attorno a log4js-node per aggiungere alcune specifiche funzionalità aggiuntive desiderate. Nota che ho scelto di creare un wrapper attorno a log4js-node che userò quindi nel mio codice. Questo localizza l'implementazione di queste funzionalità di registrazione estese in un'unica posizione evitando così la ridondanza e la complessità non necessaria in tutto il codice quando invoco la registrazione.

Dal momento che stiamo lavorando con I/O e avremmo diversi client (utenti) che genereranno diverse connessioni (socket), voglio essere in grado di tracciare l'attività di un utente specifico nei file di registro e voglio anche sapere l'origine di ogni voce di registro. Mi aspetto quindi di avere alcune voci di registro relative allo stato dell'applicazione e alcune specifiche dell'attività dell'utente.

Nel mio codice wrapper di registrazione, sono in grado di mappare l'ID utente e i socket, che mi consentiranno di tenere traccia delle azioni eseguite prima e dopo un evento ERROR. Il wrapper di registrazione mi consentirà anche di creare diversi logger con diverse informazioni contestuali che posso passare ai gestori di eventi in modo da conoscere l'origine della voce di registro.

Il codice per il wrapper di registrazione è disponibile qui.

Configurazione

Spesso è necessario supportare diverse configurazioni per un sistema. Queste differenze possono essere differenze tra gli ambienti di sviluppo e produzione o anche basate sulla necessità di visualizzare diversi ambienti del cliente e scenari di utilizzo.

Anziché richiedere modifiche al codice per supportare ciò, la pratica comune consiste nel controllare queste differenze di comportamento tramite parametri di configurazione. Nel mio caso, avevo bisogno della capacità di avere diversi ambienti di esecuzione (staging e produzione), che potevano avere impostazioni diverse. Volevo anche assicurarmi che il codice testato funzionasse bene sia in fase di staging che in produzione e, se avessi avuto bisogno di modificare il codice a questo scopo, avrebbe invalidato il processo di test.

Utilizzando una variabile di ambiente Node.js, posso specificare quale file di configurazione voglio utilizzare per un'esecuzione specifica. Ho quindi spostato tutti i parametri di configurazione precedentemente codificati nei file di configurazione e creato un semplice modulo di configurazione che carica il file di configurazione corretto con le impostazioni desiderate. Ho anche classificato tutte le impostazioni per imporre un certo grado di organizzazione al file di configurazione e per semplificare la navigazione.

Ecco un esempio di un file di configurazione risultante:

 { "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" } }

Flusso di codice

Finora abbiamo creato una struttura di cartelle per ospitare i diversi moduli, abbiamo impostato un modo per caricare informazioni specifiche dell'ambiente e creato un sistema di registrazione, quindi vediamo come possiamo collegare tutti i pezzi senza modificare il codice specifico dell'azienda.

Grazie alla nostra nuova struttura modulare del codice, il nostro punto di ingresso app.js è abbastanza semplice e contiene solo il codice di inizializzazione:

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

Quando abbiamo definito la nostra struttura del codice, abbiamo affermato che la cartella ioW conterrebbe il codice relativo a business e socket.io. Nello specifico, conterrà i seguenti file (si noti che è possibile fare clic su uno qualsiasi dei nomi di file elencati per visualizzare il codice sorgente corrispondente):

  • index.js : gestisce l'inizializzazione e le connessioni di socket.io, nonché la sottoscrizione di eventi, oltre a un gestore di errori centralizzato per gli eventi
  • eventManager.js – ospita tutta la logica relativa al business (gestori di eventi)
  • webHelper.js – metodi di supporto per eseguire richieste web.
  • linkedList.js – una classe di utilità per elenchi collegati

Abbiamo rifattorizzato il codice che effettua la richiesta web e lo abbiamo spostato in un file separato, e siamo riusciti a mantenere la nostra logica aziendale nello stesso posto e non modificata.

Una nota importante: in questa fase, eventManager.js contiene ancora alcune funzioni di supporto che dovrebbero essere estratte in un modulo separato. Tuttavia, poiché il nostro obiettivo in questo primo passaggio era riorganizzare il codice riducendo al minimo l'impatto sulla logica aziendale e queste funzioni di supporto sono troppo legate alla logica aziendale, abbiamo deciso di rinviarlo a un passaggio successivo per migliorare l'organizzazione del codice.

Dal momento che Node.js è asincrono per definizione, spesso incontriamo un po' di un "inferno di callback" che rende il codice particolarmente difficile da navigare ed eseguire il debug. Per evitare questa trappola, nella mia nuova implementazione, ho utilizzato il modello delle promesse e sto sfruttando specificamente bluebird che è una libreria di promesse molto bella e veloce. Le promesse ci consentiranno di seguire il codice come se fosse sincrono e forniranno anche una gestione degli errori e un modo pulito per standardizzare le risposte tra le chiamate. C'è un contratto implicito nel nostro codice secondo cui ogni gestore di eventi deve restituire una promessa in modo da poter gestire la gestione centralizzata degli errori e la registrazione.

Tutti i gestori di eventi restituiranno una promessa (indipendentemente dal fatto che effettuino chiamate asincrone o meno). Con questo in atto, possiamo centralizzare la gestione e la registrazione degli errori e ci assicuriamo che, se abbiamo un errore non gestito all'interno del gestore di eventi, quell'errore venga rilevato.

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

Nella nostra discussione sulla registrazione, abbiamo menzionato che ogni connessione avrebbe il proprio logger con informazioni contestuali al suo interno. In particolare, stiamo legando l'id del socket e il nome dell'evento al logger quando lo creiamo, quindi quando passiamo quel logger al gestore dell'evento, ogni riga di registro conterrà quelle informazioni al suo interno:

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

Un altro punto degno di nota per quanto riguarda la gestione degli eventi: nel file originale, avevamo una chiamata alla funzione setInterval che era all'interno del gestore di eventi dell'evento di connessione socket.io e abbiamo identificato questa funzione come 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); });

Questo codice sta creando un timer con un intervallo specificato (nel nostro caso era 1 minuto) per ogni singola richiesta di connessione che riceviamo. Quindi, ad esempio, se in un dato momento abbiamo 300 socket online, allora avremmo 300 timer in esecuzione ogni minuto. Il problema con questo, come puoi vedere nel codice sopra, è che non c'è alcun utilizzo del socket né di alcuna variabile che è stata definita nell'ambito del gestore di eventi. L'unica variabile utilizzata è una variabile messageHub dichiarata a livello di modulo, il che significa che è la stessa per tutte le connessioni. Non è quindi assolutamente necessario un timer separato per ogni connessione. Quindi lo abbiamo rimosso dal gestore dell'evento di connessione e lo abbiamo incluso nel nostro codice di inizializzazione generale, che in questo caso è la funzione di initialize .

Infine, nella nostra elaborazione delle risposte In webHelper.js , abbiamo aggiunto l'elaborazione per qualsiasi risposta non riconosciuta che registrerà informazioni che saranno quindi utili per il processo di debug:

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

Il passaggio finale consiste nell'impostare un file di registrazione per l'errore standard di Node.js. Questo file conterrà errori non gestiti che potremmo aver perso. Per impostare il processo del nodo in Windows (non è l'ideale, ma sai...) come servizio, utilizziamo uno strumento chiamato nssm che ha un'interfaccia utente visiva che consente di definire un file di output standard, un file di errore standard e variabili ambientali.

Informazioni sulle prestazioni di Node.js

Node.js è un linguaggio di programmazione a thread singolo. Per migliorare la scalabilità, ci sono diverse alternative che possiamo impiegare. C'è il modulo del cluster di nodi o semplicemente aggiungendo più processi di nodo e metti un nginx sopra quelli per eseguire l'inoltro e il bilanciamento del carico.

Nel nostro caso, tuttavia, dato che ogni sottoprocesso del cluster di nodi o processo del nodo avrà il proprio spazio di memoria, non saremo in grado di condividere facilmente le informazioni tra quei processi. Quindi per questo caso particolare, dovremo utilizzare un datastore esterno (come redis) per mantenere i socket online disponibili per i diversi processi.

Conclusione

Con tutto questo in atto, abbiamo ottenuto una pulizia significativa del codice che ci era stato originariamente consegnato. Non si tratta di rendere perfetto il codice, ma piuttosto di riprogettare lo stesso per creare una base architettonica pulita che sarà più facile da supportare e mantenere e che faciliterà e semplificherà il debug.

Aderendo ai principi chiave di progettazione del software enumerati in precedenza - manutenibilità, estensibilità, modularità e scalabilità - abbiamo creato moduli e una struttura di codice che identificava in modo chiaro e chiaro le diverse responsabilità del modulo. Abbiamo anche identificato alcuni problemi nell'implementazione originale che portano a un consumo elevato di memoria che peggiorava le prestazioni.

Spero che l'articolo ti sia piaciuto, fammi sapere se hai ulteriori commenti o domande.