O mai bună gestionare a erorilor în NodeJS cu clase de eroare
Publicat: 2022-03-10error
și cum să îl utilizați pentru o modalitate mai bună și mai eficientă de a gestiona erorile în aplicațiile dvs.Gestionarea erorilor este una dintre acele părți ale dezvoltării software care nu primesc prea mult atenția pe care o merită cu adevărat. Cu toate acestea, construirea de aplicații robuste necesită gestionarea corectă a erorilor.
Vă puteți descurca în NodeJS fără a gestiona corect erorile, dar datorită naturii asincrone a lui NodeJS, manipularea necorespunzătoare sau erorile vă pot provoca dureri destul de curând - mai ales când depanați aplicațiile.
Înainte de a continua, aș dori să subliniez tipul de erori despre care vom discuta despre cum să folosim clasele de erori.
Erori operaționale
Acestea sunt erori descoperite în timpul rulării unui program. Erorile operaționale nu sunt erori și pot apărea din când în când, mai ales din cauza unuia sau a unei combinații a mai multor factori externi, cum ar fi expirarea timpului unui server de bază de date sau un utilizator care decide să facă o încercare de injectare SQL introducând interogări SQL într-un câmp de intrare.
Mai jos sunt mai multe exemple de erori operaționale:
- Nu s-a putut conecta la un server de baze de date;
- Intrări nevalide ale utilizatorului (serverul răspunde cu un cod de răspuns
400
); - Solicitare timeout;
- Resursa nu a fost găsită (serverul răspunde cu un cod de răspuns 404);
- Serverul revine cu un răspuns de
500
.
De asemenea, este demn de remarcat să discutăm pe scurt contrapartida erorilor operaționale.
Erori de programator
Acestea sunt erori din program care pot fi rezolvate prin schimbarea codului. Aceste tipuri de erori nu pot fi gestionate deoarece apar ca urmare a spargerii codului. Exemple de aceste erori sunt:
- Încercarea de a citi o proprietate a unui obiect care nu este definit.
const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
- Invocarea sau apelarea unei funcții asincrone fără apel invers.
- Trecerea unui șir acolo unde era așteptat un număr.
Acest articol este despre gestionarea erorilor operaționale în NodeJS. Gestionarea erorilor în NodeJS este semnificativ diferită de gestionarea erorilor în alte limbi. Acest lucru se datorează naturii asincrone a JavaScript și deschiderii JavaScript cu erori. Lasă-mă să explic:
În JavaScript, instanțe ale clasei de error
nu sunt singurul lucru pe care îl puteți arunca. Puteți arunca literalmente orice tip de date, această deschidere nu este permisă de alte limbi.
De exemplu, un dezvoltator JavaScript poate decide să introducă un număr în loc de o instanță de obiect de eroare, astfel:
// bad throw 'Whoops :)'; // good throw new Error('Whoops :)')
Este posibil să nu vedeți problema în aruncarea altor tipuri de date, dar acest lucru va duce la o depanare mai dificilă, deoarece nu veți obține o urmă de stivă și alte proprietăți pe care obiectul Error le expune și care sunt necesare pentru depanare.
Să ne uităm la câteva modele incorecte în gestionarea erorilor, înainte de a arunca o privire la modelul clasei Error și la modul în care este o modalitate mult mai bună de tratare a erorilor în NodeJS.
Model greșit de gestionare a erorilor #1: Utilizarea greșită a apelurilor inverse
Scenariul real : codul dvs. depinde de un API extern care necesită un apel invers pentru a obține rezultatul pe care îl așteptați să îl returneze.
Să luăm fragmentul de cod de mai jos:
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();
Până la NodeJS 8 și mai sus, codul de mai sus era legitim, iar dezvoltatorii pur și simplu declanșau și uitau comenzile. Aceasta înseamnă că dezvoltatorii nu au fost obligați să ofere un apel invers la astfel de apeluri de funcție și, prin urmare, ar putea omite gestionarea erorilor. Ce se întâmplă când writeFolder
nu a fost creat? Apelul la writeFile
nu va fi făcut și nu am ști nimic despre el. Acest lucru ar putea duce, de asemenea, la starea de cursă, deoarece prima comandă s-ar putea să nu se fi terminat când a doua comandă a pornit din nou, nu ați ști.
Să începem să rezolvăm această problemă prin rezolvarea condiției de cursă. Am face acest lucru dând un apel înapoi la prima comandă mkdir
pentru a ne asigura că directorul există într-adevăr înainte de a scrie în el cu a doua comandă. Deci codul nostru ar arăta ca cel de mai jos:
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();
Deși am rezolvat starea cursei, încă nu am terminat. Codul nostru este încă problematic deoarece, deși am folosit un apel invers pentru prima comandă, nu avem de unde să știm dacă folderul writeFolder
a fost creat sau nu. Dacă folderul nu a fost creat, atunci al doilea apel va eșua din nou, dar totuși, am ignorat încă o dată eroarea. Rezolvăm asta prin...
Gestionarea erorilor cu apeluri inverse
Pentru a gestiona corect erorile cu apeluri inverse, trebuie să vă asigurați că utilizați întotdeauna abordarea care primește eroarea. Acest lucru înseamnă că ar trebui să verificați mai întâi dacă există o eroare returnată de la funcție înainte de a continua să utilizați orice date (dacă există) returnate. Să vedem modul greșit de a face asta:
'use strict'; // Wrong const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (data) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); else callback(err) }); } write(console.log);
Modelul de mai sus este greșit, deoarece uneori API-ul pe care îl apelați ar putea să nu returneze nicio valoare sau ar putea returna o valoare falsă ca valoare returnată validă. Acest lucru v-ar face să ajungeți într-un caz de eroare, chiar dacă aparent ați putea avea un apel reușit al funcției sau al API-ului.
Modelul de mai sus este, de asemenea, rău, deoarece utilizarea sa ar consuma eroarea dvs. (erorile dvs. nu vor fi apelate chiar dacă s-ar fi putut întâmpla). De asemenea, nu veți avea nicio idee despre ce se întâmplă în codul dvs. ca urmare a acestui tip de model de gestionare a erorilor. Deci, modalitatea corectă pentru codul de mai sus ar fi:
'use strict'; // Right const fs = require('fs'); const write = function (callback) { fs.mkdir('./writeFolder', (err, data) => { if (err) return callback(err) fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write(console.log);
Modelul greșit de gestionare a erorilor #2: Utilizarea greșită a promisiunilor
Scenariul din lumea reală : Așa că ați descoperit Promises și credeți că sunt mult mai bune decât apelurile înapoi din cauza iadului de callback și v-ați decis să promiteți un API extern de care depindea codul dumneavoastră. Sau consumați o promisiune de la un API extern sau un API de browser, cum ar fi funcția fetch().
În zilele noastre nu folosim cu adevărat apeluri înapoi în bazele noastre de coduri NodeJS, folosim promisiuni. Deci, să reimplementăm codul nostru exemplu cu o promisiune:
'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!') }).catch((err) => { // catch all potential errors console.error(err) }) }
Să punem codul de mai sus la microscop - putem vedea că ramificăm promisiunea fs.mkdir
într-un alt lanț de promisiuni (apelul la fs.writeFile) fără măcar să gestionăm acel apel de promisiune. S-ar putea să credeți că o modalitate mai bună de a face acest lucru ar fi:
'use strict'; const fs = require('fs').promises; const write = function () { return fs.mkdir('./writeFolder').then(() => { fs.writeFile('./writeFolder/foobar.txt', 'Hello world!').then(() => { // do something }).catch((err) => { console.error(err); }) }).catch((err) => { // catch all potential errors console.error(err) }) }
Dar cele de mai sus nu s-ar scala. Acest lucru se datorează faptului că, dacă avem mai multe lanțuri de promisiuni de apelat, am ajunge la ceva asemănător cu iadul callback pe care promisiunile au fost făcute pentru a-l rezolva. Aceasta înseamnă că codul nostru va continua să se indenteze în dreapta. Am avea o promisiune iad pe mâini.
Promiterea unui API bazat pe callback
De cele mai multe ori, ați dori să promiteți singur un API bazat pe callback, pentru a gestiona mai bine erorile din acel API. Cu toate acestea, acest lucru nu este chiar ușor de făcut. Să luăm un exemplu de mai jos pentru a explica de ce.
function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }
Din cele de mai sus, dacă arg
nu este true
și nu avem o eroare de la apelul la funcția doATask
, atunci această promisiune se va bloca, ceea ce este o scurgere de memorie în aplicația dvs.
Erori de sincronizare înghițite în promisiuni
Utilizarea constructorului Promise are mai multe dificultăți, una dintre aceste dificultăți este; de îndată ce este fie rezolvată, fie respinsă, nu poate obține o altă stare. Acest lucru se datorează faptului că o promisiune poate obține doar o singură stare - fie este în așteptare, fie este rezolvată/respinsă. Aceasta înseamnă că putem avea zone moarte în promisiunile noastre. Să vedem asta în cod:
function deadZonePromise(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { resolve('I'm all Done'); throw new Error('I am never reached') // Dead Zone }); }); }
Din cele de mai sus vedem de îndată ce promisiunea este rezolvată, următoarea linie este o zonă moartă și nu va fi niciodată atinsă. Aceasta înseamnă că orice următoarea gestionare a erorilor sincrone efectuată în promisiunile tale va fi doar înghițită și nu va fi niciodată aruncată.
Exemple din lumea reală
Exemplele de mai sus ajută la explicarea tiparelor slabe de gestionare a erorilor, haideți să aruncăm o privire asupra tipului de probleme pe care le puteți vedea în viața reală.
Exemplul #1 din lumea reală — Transformarea erorii în șir
Scenariu : ați decis că eroarea returnată de la un API nu este suficient de bună pentru dvs., așa că ați decis să adăugați propriul mesaj la ea.
'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();
Să ne uităm la ce este în neregulă cu codul de mai sus. Din cele de mai sus vedem că dezvoltatorul încearcă să îmbunătățească eroarea generată de API-ul databaseGet
prin concatenarea erorii returnate cu șirul „Șablon nu a fost găsit”. Această abordare are multe dezavantaje, deoarece atunci când concatenarea a fost făcută, dezvoltatorul rulează implicit toString
pe obiectul de eroare returnat. În acest fel, el pierde orice informație suplimentară returnată de eroare (spune la revedere de la stack trace). Deci, ceea ce are dezvoltatorul acum este doar un șir care nu este util la depanare.
O modalitate mai bună este să păstrați eroarea așa cum este sau să o includeți într-o altă eroare pe care ați creat-o și ați atașat eroarea aruncată din apelul de bază de dateGet ca proprietate.
Exemplul #2 din lumea reală: Ignorarea completă a erorii
Scenariu : Poate că atunci când un utilizator se înregistrează în aplicația dvs., dacă apare o eroare, doriți doar să detectați eroarea și să afișați un mesaj personalizat, dar ați ignorat complet eroarea care a fost surprinsă fără măcar să o înregistrați în scopuri de depanare.
router.get('/:id', function (req, res, next) { database.getData(req.params.userId) .then(function (data) { if (data.length) { res.status(200).json(data); } else { res.status(404).end(); } }) .catch(() => { log.error('db.rest/get: could not get data: ', req.params.userId); res.status(500).json({error: 'Internal server error'}); }) });
Din cele de mai sus, putem vedea că eroarea este complet ignorată și codul trimite utilizatorului 500 dacă apelul la baza de date a eșuat. Dar, în realitate, cauza eșecului bazei de date ar putea fi datele incorecte trimise de utilizator, care este o eroare cu codul de stare 400.
În cazul de mai sus, am ajunge într-o groază de depanare, deoarece tu, în calitate de dezvoltator, nu ai ști ce a mers prost. Utilizatorul nu va putea oferi un raport decent, deoarece 500 de erori interne ale serverului sunt întotdeauna aruncate. Veți ajunge să pierdeți ore în șir pentru a găsi problema, ceea ce va echivala cu pierderea timpului și a banilor angajatorului dvs.
Exemplul #3 din lumea reală: Nu se acceptă eroarea aruncată dintr-un API
Scenariu : a fost trimisă o eroare dintr-un API pe care îl utilizați, dar nu acceptați acea eroare, în schimb, distribuiți și transformați eroarea în moduri care o fac inutilă în scopuri de depanare.
Luați următorul exemplu de cod de mai jos:
async function doThings(input) { try { validate(input); try { await db.create(input); } catch (error) { error.message = `Inner error: ${error.message}` if (error instanceof Klass) { error.isKlass = true; } throw error } } catch (error) { error.message = `Could not do things: ${error.message}`; await rollback(input); throw error; } }
Se întâmplă multe în codul de mai sus care ar duce la depanarea groază. Hai să aruncăm o privire:
- Împachetarea blocurilor
try/catch
: Puteți vedea din cele de mai sus că înfășurăm blocuritry/catch
, ceea ce este o idee foarte proastă. În mod normal, încercăm să reducem utilizarea blocurilortry/catch
pentru a micșora suprafața în care ar trebui să ne ocupăm de eroarea (gândiți-vă la aceasta ca fiind tratarea erorilor DRY); - De asemenea, manipulăm mesajul de eroare în încercarea de a îmbunătăți, ceea ce nu este, de asemenea, o idee bună;
- Verificăm dacă eroarea este o instanță de tip
Klass
și, în acest caz, setăm o proprietate booleană a eroriiisKlass
la truev (dar dacă această verificare trece atunci eroarea este de tipulKlass
); - De asemenea, derulăm înapoi baza de date prea devreme, deoarece, din structura codului, există o tendință mare ca s-ar putea să nu fi lovit nici măcar baza de date atunci când eroarea a fost aruncată.
Mai jos este o modalitate mai bună de a scrie codul de mai sus:
async function doThings(input) { validate(input); try { await db.create(input); } catch (error) { try { await rollback(); } catch (error) { logger.log('Rollback failed', error, 'input:', input); } throw error; } }
Să analizăm ceea ce facem chiar în fragmentul de mai sus:
- Folosim un singur bloc
try/catch
și numai în blocul catch folosim un alt bloctry/catch
, care trebuie să servească drept gard în cazul în care se întâmplă ceva cu acea funcție de rollback și îl înregistrăm; - În cele din urmă, aruncăm eroarea primită inițială, ceea ce înseamnă că nu pierdem mesajul inclus în acea eroare.
Testare
În mare parte, vrem să ne testăm codul (fie manual, fie automat). Dar de cele mai multe ori testăm doar lucruri pozitive. Pentru un test robust, trebuie să testați și erorile și cazurile marginale. Această neglijență este responsabilă pentru găsirea erorilor în producție, ceea ce ar costa mai mult timp suplimentar de depanare.
Sfat : Asigurați-vă întotdeauna că testați nu numai lucrurile pozitive (obținerea unui cod de stare de 200 de la un punct final), ci și toate cazurile de eroare și toate cazurile marginale.
Exemplul #4 din lumea reală: Respingeri netratate
Dacă ați mai folosit promisiuni, probabil că v-ați unhandled rejections
.
Iată un primer scurt despre respingerile nerezolvate. Respingerile nerezolvate sunt respingeri de promisiuni care nu au fost gestionate. Aceasta înseamnă că promisiunea a fost respinsă, dar codul tău va continua să ruleze.
Să ne uităm la un exemplu comun din lumea reală care duce la respingeri nerezolvate.
'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await a; await b; } catch (error) { // ignore all errors! } })();
Codul de mai sus la prima vedere ar putea părea că nu este predispus la erori. Dar la o privire mai atentă, începem să vedem un defect. Permiteți-mi să vă explic: ce se întâmplă când a
este respins? Asta înseamnă că await b
nu este niciodată atinsă și asta înseamnă că este o respingere necontrolată. O posibilă soluție este să folosiți Promise.all
pentru ambele promisiuni. Deci codul ar citi astfel:
'use strict'; async function foobar() { throw new Error('foobar'); } async function baz() { throw new Error('baz') } (async function doThings() { const a = foobar(); const b = baz(); try { await Promise.all([a, b]); } catch (error) { // ignore all errors! } })();
Iată un alt scenariu din lumea reală care ar duce la o eroare de respingere a promisiunii netratată:
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();
Dacă rulați fragmentul de cod de mai sus, veți primi o respingere a promisiunii necontrolate și iată de ce: deși nu este evident, returnăm o promisiune (foobar) înainte de a o gestiona cu try/catch
. Ceea ce ar trebui să facem este să așteptăm promisiunea pe care o facem cu try/catch
, astfel încât codul să citească:
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();
Încheierea lucrurilor negative
Acum că ați văzut modele greșite de gestionare a erorilor și posibile remedieri, să ne aprofundăm acum în modelul clasei de erori și cum rezolvă problema gestionării greșite a erorilor în NodeJS.
Clasele de eroare
În acest model, ne-am porni aplicația cu o clasă ApplicationError
, astfel știm că toate erorile din aplicațiile noastre pe care le aruncăm în mod explicit vor moșteni de la ea. Deci, vom începe cu următoarele clase de eroare:
-
ApplicationError
Acesta este strămoșul tuturor celorlalte clase de eroare, adică toate celelalte clase de eroare moștenesc de la acesta. -
DatabaseError
Orice eroare legată de operațiunile cu baze de date va moșteni din această clasă. -
UserFacingError
Orice eroare produsă ca urmare a interacțiunii unui utilizator cu aplicația va fi moștenită din această clasă.
Iată cum ar arăta fișierul nostru cu clasa de error
:
'use strict'; // Here is the base error classes to extend from class ApplicationError extends Error { get name() { return this.constructor.name; } } class DatabaseError extends ApplicationError { } class UserFacingError extends ApplicationError { } module.exports = { ApplicationError, DatabaseError, UserFacingError }
Această abordare ne permite să distingem erorile aruncate de aplicația noastră. Deci, acum, dacă dorim să gestionăm o eroare de solicitare greșită (intrare nevalidă de utilizator) sau o eroare negăsită (resursa nu a fost găsită), putem moșteni din clasa de bază care este UserFacingError
(ca în codul de mai jos).
const { UserFacingError } = require('./baseErrors') class BadRequestError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 400; } } class NotFoundError extends UserFacingError { constructor(message, options = {}) { super(message); // You can attach relevant information to the error instance // (eg. the username) for (const [key, value] of Object.entries(options)) { this[key] = value; } } get statusCode() { return 404 } } module.exports = { BadRequestError, NotFoundError }
Unul dintre avantajele abordării clasei de error
este că, dacă aruncăm una dintre aceste erori, de exemplu, un NotFoundError
, fiecare dezvoltator care citește această bază de cod ar putea înțelege ce se întâmplă în acest moment (dacă citește codul). ).
Veți putea transmite mai multe proprietăți specifice fiecărei clase de eroare, de asemenea, în timpul instanțierii acelei erori.
Un alt beneficiu esențial este că puteți avea proprietăți care fac întotdeauna parte dintr-o clasă de eroare, de exemplu, dacă primiți o eroare UserFacing, veți ști că un statusCode face întotdeauna parte din această clasă de eroare, acum îl puteți utiliza direct în cod mai târziu.
Sfaturi despre utilizarea claselor de eroare
- Creați-vă propriul modul (eventual unul privat) pentru fiecare clasă de eroare, astfel încât să îl puteți importa pur și simplu în aplicația dvs. și să îl utilizați peste tot.
- Aruncați numai erorile care vă pasă (erori care sunt exemple ale claselor dvs. de erori). În acest fel, știi că clasele tale de eroare sunt singura ta Sursă de Adevăr și conține toate informațiile necesare pentru depanarea aplicației.
- A avea un modul abstract de eroare este destul de util, deoarece acum știm că toate informațiile necesare referitoare la erorile pe care aplicațiile noastre le pot arunca sunt într-un singur loc.
- Gestionați erorile din straturi. Dacă gestionați erorile peste tot, aveți o abordare inconsecventă a gestionării erorilor, care este greu de urmărit. Prin straturi mă refer la baza de date, straturi expres/fastify/HTTP și așa mai departe.
Să vedem cum arată clasele de eroare în cod. Iată un exemplu în expres:
const { DatabaseError } = require('./error') const { NotFoundError } = require('./userFacingErrors') const { UserFacingError } = require('./error') // Express app.get('/:id', async function (req, res, next) { let data try { data = await database.getData(req.params.userId) } catch (err) { return next(err); } if (!data.length) { return next(new NotFoundError('Dataset not found')); } res.status(200).json(data) }) app.use(function (err, req, res, next) { if (err instanceof UserFacingError) { res.sendStatus(err.statusCode); // or res.status(err.statusCode).send(err.errorCode) } else { res.sendStatus(500) } // do your logic logger.error(err, 'Parameters: ', req.params, 'User data: ', req.user) });
Din cele de mai sus, profităm de faptul că Express expune un handler global de erori care vă permite să vă gestionați toate erorile într-un singur loc. Puteți vedea apelul la next()
în locurile în care gestionăm erorile. Acest apel ar transmite erorile către handler, care este definit în secțiunea app.use
. Deoarece Express nu acceptă async/wait, folosim blocuri try/catch
.
Deci, din codul de mai sus, pentru a ne gestiona erorile, trebuie doar să verificăm dacă eroarea care a fost aruncată este o instanță UserFacingError
și automat știm că ar exista un statusCode în obiectul de eroare și îl trimitem utilizatorului (s-ar putea să doriți pentru a avea și un cod de eroare specific pe care îl puteți transmite clientului) și cam asta este.
De asemenea, veți observa că în acest model (model de clasă de error
) orice altă eroare pe care nu ați aruncat-o în mod explicit este o eroare 500
, deoarece este ceva neașteptat, ceea ce înseamnă că nu ați aruncat în mod explicit acea eroare în aplicația dvs. În acest fel, putem distinge tipurile de erori care apar în aplicațiile noastre.
Concluzie
Gestionarea corectă a erorilor în aplicația dvs. vă poate face să dormiți mai bine noaptea și să economisiți timp de depanare. Iată câteva puncte cheie care trebuie luate din acest articol:
- Utilizați clase de eroare special configurate pentru aplicația dvs.;
- Implementarea de gestionare a erorilor abstracte;
- Utilizați întotdeauna async/wait;
- Faceți erorile expresive;
- Utilizatorul promite dacă este necesar;
- Returnează stările și codurile de eroare adecvate;
- Folosește cârligele de promisiune.
Biți utili pentru front-end și UX, livrați o dată pe săptămână.
Cu instrumente care vă ajută să vă desfășurați mai bine munca. Abonați-vă și obțineți prin e-mail listele de verificare pentru proiectarea interfeței inteligente Vitaly în format PDF .
Pe front-end și UX. Încrederea a 190.000 de oameni.