Reinginerie software: de la spaghetti la design curat
Publicat: 2022-03-11Puteți arunca o privire asupra sistemului nostru? Tipul care a scris software-ul nu mai este prin preajmă și am avut o serie de probleme. Avem nevoie de cineva care să o examineze și să o curețe pentru noi.
Oricine a lucrat în inginerie software pentru o perioadă rezonabilă de timp știe că această solicitare aparent inocentă este adesea începutul unui proiect care „are dezastru scris peste tot”. Moștenirea codului altcuiva poate fi un coșmar, mai ales când codul este prost proiectat și lipsește documentația.
Așa că, când am primit recent o solicitare de la unul dintre clienții noștri de a căuta aplicația lui existentă de server de chat socket.io (scrisă în Node.js) și de a o îmbunătăți, am fost extrem de precaut. Dar înainte de a alerga spre dealuri, m-am hotărât să fiu măcar de acord să arunc o privire asupra codului.
Din păcate, privirea codului nu a făcut decât să reafirme preocupările mele. Acest server de chat a fost implementat ca un singur fișier JavaScript mare. Reproiectarea acestui singur fișier monolitic într-un software curat arhitecturat și ușor de întreținut ar fi într-adevăr o provocare. Dar îmi place o provocare, așa că am fost de acord.
Punctul de plecare - Pregătiți-vă pentru reinginerire
Software-ul existent consta dintr-un singur fișier care conține 1.200 de linii de cod nedocumentat. Da. Mai mult, se știa că conține unele bug-uri și are unele probleme de performanță.
În plus, examinarea fișierelor jurnal (întotdeauna un loc bun pentru a începe când moșteniți codul altcuiva) a relevat potențiale probleme de scurgere de memorie. La un moment dat, procesul a fost raportat că folosește mai mult de 1 GB de RAM.
Având în vedere aceste probleme, a devenit imediat clar că codul ar trebui să fie reorganizat și modularizat înainte chiar de a încerca să depaneze sau să îmbunătățească logica de afaceri. În acest scop, unele dintre problemele inițiale care trebuiau abordate includ:
- Structura codului. Codul nu avea deloc o structură reală, ceea ce face dificilă distingerea configurației de infrastructură de logica de afaceri. În esență, nu a existat modularizare sau separare a preocupărilor.
- Cod redundant. Unele părți ale codului (cum ar fi codul de gestionare a erorilor pentru fiecare handler de evenimente, codul pentru efectuarea de solicitări web etc.) au fost duplicate de mai multe ori. Codul replicat nu este niciodată un lucru bun, făcând codul mult mai greu de întreținut și mai predispus la erori (când codul redundant este remediat sau actualizat într-un loc, dar nu în celălalt).
- Valori hardcoded. Codul conținea un număr de valori hardcoded (rar un lucru bun). Posibilitatea de a modifica aceste valori prin parametrii de configurare (în loc să necesite modificări ale valorilor codificate în cod) ar crește flexibilitatea și ar putea ajuta, de asemenea, la facilitarea testării și depanării.
- Logare. Sistemul de înregistrare a fost foarte simplu. Ar genera un singur fișier jurnal gigant care era dificil și neîndemânatic de analizat sau analizat.
Obiective arhitecturale cheie
În procesul de începere a restructurarii codului, pe lângă abordarea problemelor specifice identificate mai sus, am vrut să încep să abordez unele dintre obiectivele arhitecturale cheie care sunt (sau cel puțin, ar trebui să fie) comune pentru proiectarea oricărui sistem software. . Acestea includ:
- Mentenabilitatea. Nu scrieți niciodată software, așteptându-vă să fiți singura persoană care va trebui să-l întrețină. Luați în considerare întotdeauna cât de ușor de înțeles va fi codul dvs. pentru altcineva și cât de ușor va fi pentru ei să modifice sau să depaneze.
- Extensibilitate. Nu presupuneți niciodată că funcționalitatea pe care o implementați astăzi este tot ceea ce va fi nevoie vreodată. Arhitectați-vă software-ul în moduri care vor fi ușor de extins.
- Modularitate. Separați funcționalitatea în module logice și distincte, fiecare având propriul scop și funcția clară.
- Scalabilitate. Utilizatorii de astăzi sunt din ce în ce mai nerăbdători, așteaptă timpi de răspuns imediat (sau cel puțin aproape imediat). Performanța slabă și latența ridicată pot cauza chiar și cea mai utilă aplicație să eșueze pe piață. Cum va funcționa software-ul dvs. pe măsură ce numărul de utilizatori concurenți și cerințele de lățime de bandă cresc? Tehnici precum paralelizarea, optimizarea bazelor de date și procesarea asincronă pot ajuta la îmbunătățirea capacității sistemului dvs. de a rămâne receptiv, în ciuda creșterii solicitărilor de încărcare și resurse.
Restructurarea Codului
Scopul nostru este să trecem de la un singur fișier de cod sursă mongo monolitic la un set modular de componente cu arhitectură curată. Codul rezultat ar trebui să fie semnificativ mai ușor de întreținut, îmbunătățit și depanat.
Pentru această aplicație, am decis să organizez codul în următoarele componente arhitecturale distincte:
- app.js - acesta este punctul nostru de intrare, codul nostru va rula de aici
- config - aici se vor afla setările noastre de configurare
- ioW - un „înveliș IO” care va conține toată logica IO (și de afaceri).
- logging - tot codul legat de jurnal (rețineți că structura directorului va include, de asemenea, un nou folder de
logs
, care va conține toate fișierele jurnal) - package.json - lista de dependențe de pachet pentru Node.js
- node_modules - toate modulele cerute de Node.js
Nu există nimic magic în această abordare specifică; ar putea exista multe moduri diferite de a restructura codul. Personal am simțit că această organizație a fost suficient de curată și bine organizată, fără a fi prea complexă.
Directorul rezultat și organizarea fișierelor sunt prezentate mai jos.
Logare
Pachetele de jurnalizare au fost dezvoltate pentru majoritatea mediilor și limbilor de dezvoltare actuale, așa că este rar în zilele noastre să fiți nevoit să vă „rolați propria” capacitate de înregistrare.
Deoarece lucrăm cu Node.js, am selectat log4js-node, care este practic o versiune a bibliotecii log4js pentru utilizare cu Node.js. Această bibliotecă are câteva caracteristici interesante, cum ar fi capacitatea de a înregistra mai multe niveluri de mesaje (AVERTISMENT, EROARE etc.) și putem avea un fișier rulant care poate fi împărțit, de exemplu, în fiecare zi, astfel încât să nu fie nevoie se ocupă de fișiere uriașe care vor dura mult timp pentru a se deschide și vor fi dificil de analizat și analizat.
În scopurile noastre, am creat un mic wrapper în jurul log4js-node pentru a adăuga câteva capabilități suplimentare specifice dorite. Rețineți că am ales să creez un wrapper în jurul log4js-node pe care îl voi folosi apoi în codul meu. Acest lucru localizează implementarea acestor capabilități extinse de înregistrare într-o singură locație, evitând astfel redundanța și complexitatea inutilă în codul meu atunci când invoc înregistrarea în jurnal.
Deoarece lucrăm cu I/O și am avea mai mulți clienți (utilizatori) care vor genera mai multe conexiuni (socket-uri), vreau să pot urmări activitatea unui anumit utilizator în fișierele jurnal și, de asemenea, vreau să știu sursa fiecărei intrări de jurnal. Prin urmare, mă aștept să am câteva intrări de jurnal privind starea aplicației și unele care sunt specifice activității utilizatorului.
În codul meu wrapper de înregistrare, pot mapa ID-ul utilizatorului și socket-urile, ceea ce îmi va permite să țin evidența acțiunilor care au fost efectuate înainte și ulterior unui eveniment EROARE. Wrapper-ul de înregistrare îmi va permite, de asemenea, să creez diferiți loggeri cu diferite informații contextuale pe care le pot transmite gestionarilor de evenimente, astfel încât să știu sursa intrării de jurnal.
Codul pentru wrapper-ul de înregistrare este disponibil aici.
Configurare
Este adesea necesar să se suporte diferite configurații pentru un sistem. Aceste diferențe pot fi fie diferențe între mediile de dezvoltare și producție, fie chiar bazate pe necesitatea de a afișa diferite medii ale clienților și scenarii de utilizare.
În loc să necesite modificări ale codului pentru a sprijini acest lucru, practica comună este de a controla aceste diferențe de comportament prin intermediul parametrilor de configurare. În cazul meu, aveam nevoie de capacitatea de a avea diferite medii de execuție (înscenare și producție), care pot avea setări diferite. De asemenea, am vrut să mă asigur că codul testat funcționează bine atât în scenă, cât și în producție și, dacă aș fi trebuit să schimb codul în acest scop, ar fi invalidat procesul de testare.
Folosind o variabilă de mediu Node.js, pot specifica ce fișier de configurare vreau să folosesc pentru o anumită execuție. Prin urmare, am mutat toți parametrii de configurare codificați anterior în fișiere de configurare și am creat un modul de configurare simplu care încarcă fișierul de configurare corespunzător cu setările dorite. De asemenea, am clasificat toate setările pentru a impune un anumit grad de organizare pe fișierul de configurare și pentru a facilita navigarea.

Iată un exemplu de fișier de configurare rezultat:
{ "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" } }
Fluxul codului
Până acum am creat o structură de foldere pentru a găzdui diferitele module, am creat o modalitate de a încărca informații specifice mediului și am creat un sistem de înregistrare, așa că haideți să vedem cum putem lega toate piesele fără a schimba codul specific afacerii.
Datorită noii structuri modulare a codului, punctul nostru de intrare app.js
este destul de simplu, conținând doar codul de inițializare:
var config = require('./config'); var logging = require('./logging'); var ioW = require('./ioW'); var obj = config.getCurrent(); logging.initialize(obj.logging); ioW.initialize(config);
Când am definit structura codului nostru, am spus că folderul ioW
va deține codul business și socket.io. Mai exact, va conține următoarele fișiere (rețineți că puteți face clic pe oricare dintre numele fișierelor enumerate pentru a vedea codul sursă corespunzător):
-
index.js
– gestionează inițializarea și conexiunile socket.io, precum și abonamentul la evenimente, plus un handler centralizat de erori pentru evenimente -
eventManager.js
– găzduiește toată logica legată de afaceri (gestionarele de evenimente) -
webHelper.js
– metode de ajutor pentru a face cereri web. -
linkedList.js
– o clasă de utilitate pentru listă conectată
Am refactorizat codul care face cererea web și l-am mutat într-un fișier separat și am reușit să ne păstrăm logica de afaceri în același loc și nemodificată.
O notă importantă: în această etapă, eventManager.js
conține încă câteva funcții de ajutor care ar trebui să fie extrase într-un modul separat. Cu toate acestea, deoarece obiectivul nostru în această primă trecere a fost să reorganizam codul minimizând în același timp impactul asupra logicii de afaceri, iar aceste funcții de ajutor sunt prea complicat legate de logica de afaceri, am optat să amânăm acest lucru pentru o trecere ulterioară la îmbunătățirea organizării cod.
Deoarece Node.js este asincron prin definiție, deseori întâlnim un pic de „iad de apel invers”, ceea ce face codul deosebit de greu de navigat și de depanat. Pentru a evita această capcană, în noua mea implementare, am folosit modelul promises și folosesc în mod special bluebird, care este o bibliotecă de promisiuni foarte drăguță și rapidă. Promisele ne vor permite să putem urmări codul ca și cum ar fi sincron și, de asemenea, să oferim gestionarea erorilor și o modalitate curată de a standardiza răspunsurile între apeluri. Există un contract implicit în codul nostru conform căruia fiecare handler de evenimente trebuie să returneze o promisiune, astfel încât să putem gestiona gestionarea și înregistrarea centralizată a erorilor.
Toți gestionanții de evenimente vor returna o promisiune (fie că fac apeluri asincrone sau nu). Cu acest lucru în loc, putem centraliza gestionarea erorilor și înregistrarea în jurnal și ne asigurăm că, dacă avem o eroare netratată în gestionarea evenimentelor, acea eroare este prinsă.
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); }); };
În discuția noastră despre înregistrare, am menționat că fiecare conexiune ar avea propriul său logger cu informații contextuale în ea. Mai exact, legăm id-ul socket-ului și numele evenimentului de logger atunci când îl creăm, așa că atunci când transmitem acel logger la handler-ul de evenimente, fiecare linie de jurnal va avea acele informații în ea:
var sLogger = logging.createLogger(socket.id + ' - ' + eventName);
Un alt punct demn de menționat în ceea ce privește gestionarea evenimentelor: în fișierul original, am avut un apel de funcție setInterval
care se afla în handlerul de evenimente al evenimentului de conexiune socket.io și am identificat această funcție ca fiind o problemă.
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); });
Acest cod creează un cronometru cu un interval specificat (în cazul nostru a fost de 1 minut) pentru fiecare cerere de conectare pe care o primim. Deci, de exemplu, dacă la un moment dat avem 300 de prize online, atunci am avea 300 de temporizatoare care se execută în fiecare minut. Problema cu aceasta, după cum puteți vedea în codul de mai sus, este că nu există nicio utilizare a socket-ului și nici vreo variabilă care a fost definită în domeniul de aplicare al handler-ului de evenimente. Singura variabilă care este utilizată este o variabilă messageHub
care este declarată la nivel de modul, ceea ce înseamnă că este aceeași pentru toate conexiunile. Prin urmare, nu este absolut nevoie de un temporizator separat pentru fiecare conexiune. Așa că am eliminat acest lucru din handlerul de evenimente de conexiune și l-am inclus în codul nostru general de inițializare, care în acest caz este funcția de initialize
.
În cele din urmă, în procesarea răspunsurilor în webHelper.js
, am adăugat procesare pentru orice răspuns nerecunoscut care va înregistra informații care vor fi apoi utile procesului de depanare:
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; }
Pasul final este configurarea unui fișier de jurnal pentru eroarea standard a Node.js. Acest fișier va conține erori nerezolvate pe care este posibil să le fi ratat. Pentru setarea procesului nodului în Windows (nu este ideal, dar știți...) ca serviciu, folosim un instrument numit nssm care are o interfață de utilizare vizuală care vă permite să definiți un fișier de ieșire standard, un fișier de eroare standard și variabile de mediu.
Despre Node.js Performance
Node.js este un limbaj de programare cu un singur thread. Pentru a îmbunătăți scalabilitatea, există mai multe alternative pe care le putem folosi. Există modulul cluster de noduri sau pur și simplu adăugarea mai multor procese de noduri și puneți un nginx peste acestea pentru a face redirecționarea și echilibrarea încărcăturii.
În cazul nostru, totuși, având în vedere că fiecare subproces de cluster de noduri sau proces de nod va avea propriul spațiu de memorie, nu vom putea partaja cu ușurință informații între acele procese. Deci, pentru acest caz particular, va trebui să folosim un depozit de date extern (cum ar fi redis) pentru a menține socket-urile online disponibile diferitelor procese.
Concluzie
Cu toate acestea la loc, am realizat o curățare semnificativă a codului care ne-a fost înmânat inițial. Nu este vorba despre a face codul perfect, ci este mai degrabă despre reproiectarea lui pentru a crea o fundație arhitecturală curată, care va fi mai ușor de susținut și întreținut și care va facilita și simplifica depanarea.
Aderând la principiile cheie de proiectare a software-ului enumerate mai devreme – mentenabilitatea, extensibilitatea, modularitatea și scalabilitatea – am creat module și o structură de cod care a identificat clar și curat diferitele responsabilități ale modulelor. De asemenea, am identificat unele probleme în implementarea inițială care duc la un consum mare de memorie care a degradat performanța.
Sper că v-a plăcut articolul, spuneți-mi dacă mai aveți comentarii sau întrebări.