Jak zbudować system obsługi błędów Node.js?

Opublikowany: 2022-03-11

Nietrudno zauważyć, że niektórzy ludzie zmagają się z błędami, a niektórzy nawet całkowicie tego tracą. Właściwa obsługa błędów oznacza nie tylko skrócenie czasu opracowywania poprzez łatwe znajdowanie błędów i błędów, ale także opracowanie solidnej bazy kodu dla aplikacji na dużą skalę.

W szczególności programiści Node.js czasami pracują z niezbyt czystym kodem podczas obsługi różnego rodzaju błędów, niepoprawnie stosując wszędzie tę samą logikę, aby sobie z nimi poradzić. Po prostu zadają sobie pytanie „Czy Node.js źle radzi sobie z błędami?” a jeśli nie, jak sobie z nimi radzić?” Moja odpowiedź na nie brzmi : „Nie, Node.js wcale nie jest zły. To zależy od nas, programistów”.

Oto jedno z moich ulubionych rozwiązań.

Rodzaje błędów w Node.js

Przede wszystkim konieczne jest jasne zrozumienie błędów w Node.js. Ogólnie błędy Node.js dzielą się na dwie odrębne kategorie: błędy operacyjne i błędy programistyczne .

  • Błędy operacyjne to problemy w czasie wykonywania, których rezultaty są oczekiwane i należy je odpowiednio rozwiązać. Błędy operacyjne nie oznaczają, że sama aplikacja ma błędy, ale programiści muszą się z nimi ostrożnie obchodzić. Przykłady błędów operacyjnych obejmują „brak pamięci”, „nieprawidłowe dane wejściowe dla punktu końcowego interfejsu API” i tak dalej.
  • Błędy programisty to nieoczekiwane błędy w źle napisanym kodzie. Oznacza to, że sam kod ma pewne problemy do rozwiązania i został źle zakodowany. Dobrym przykładem jest próba odczytania właściwości „nieokreślony”. Aby rozwiązać problem, należy zmienić kod. To błąd, który zrobił programista, a nie błąd operacyjny.

Mając to na uwadze, nie powinieneś mieć problemu z rozróżnieniem tych dwóch kategorii błędów: błędy operacyjne są naturalną częścią aplikacji, a błędy programistów to błędy spowodowane przez programistów. Logiczne pytanie, które następuje, brzmi: „Dlaczego warto podzielić je na dwie kategorie i zająć się nimi?”

Bez jasnego zrozumienia błędów możesz mieć ochotę na ponowne uruchomienie aplikacji za każdym razem, gdy wystąpi błąd. Czy ponowne uruchomienie aplikacji z powodu błędów „Nie znaleziono pliku” ma sens, gdy tysiące użytkowników korzysta z aplikacji? Absolutnie nie.

Ale co z błędami programisty? Czy ma sens utrzymywanie aplikacji uruchomionej, gdy pojawi się nieznany błąd, który może spowodować nieoczekiwany efekt kuli śniegowej w aplikacji? Znowu zdecydowanie nie!

Czas na właściwą obsługę błędów

Zakładając, że masz pewne doświadczenie z asynchronicznym JavaScript i Node.js, możesz napotkać wady podczas używania wywołań zwrotnych do radzenia sobie z błędami. Zmuszają Cię do sprawdzania błędów aż do zagnieżdżonych, powodując notoryczne problemy z „oddzwonieniem do piekła”, które utrudniają śledzenie przepływu kodu.

Używanie obietnic lub async/await jest dobrym zamiennikiem wywołań zwrotnych. Typowy przepływ kodu async/await wygląda następująco:

 const doAsyncJobs = async () => { try { const result1 = await job1(); const result2 = await job2(result1); const result3 = await job3(result2); return await job4(result3); } catch (error) { console.error(error); } finally { await anywayDoThisJob(); } }

Korzystanie z wbudowanego obiektu Error Node.js jest dobrą praktyką, ponieważ zawiera intuicyjne i jasne informacje o błędach, takich jak StackTrace, na których większość programistów polega, aby śledzić źródło błędu. A dodatkowe znaczące właściwości, takie jak kod stanu HTTP i opis poprzez rozszerzenie klasy Error, uczynią ją bardziej informacyjną.

 class BaseError extends Error { public readonly name: string; public readonly httpCode: HttpStatusCode; public readonly isOperational: boolean; constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) { super(description); Object.setPrototypeOf(this, new.target.prototype); this.name = name; this.httpCode = httpCode; this.isOperational = isOperational; Error.captureStackTrace(this); } } //free to extend the BaseError class APIError extends BaseError { constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') { super(name, httpCode, isOperational, description); } }

Zaimplementowałem tylko niektóre kody statusu HTTP dla uproszczenia, ale później możesz dodać więcej.

 export enum HttpStatusCode { OK = 200, BAD_REQUEST = 400, NOT_FOUND = 404, INTERNAL_SERVER = 500, }

Nie ma potrzeby rozszerzania BaseError lub APIError, ale można go rozszerzyć o typowe błędy zgodnie z własnymi potrzebami i osobistymi preferencjami.

 class HTTP400Error extends BaseError { constructor(description = 'bad request') { super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description); } }

Więc jak tego używasz? Po prostu wrzuć to:

 ... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );

Scentralizowana obsługa błędów Node.js

Teraz jesteśmy gotowi do zbudowania głównego komponentu naszego systemu obsługi błędów Node.js: scentralizowanego komponentu obsługi błędów.

Zwykle dobrym pomysłem jest zbudowanie scentralizowanego komponentu obsługującego błędy, aby uniknąć możliwego powielania kodu podczas obsługi błędów. Komponent obsługi błędów jest odpowiedzialny za uczynienie wykrytych błędów zrozumiałymi, na przykład poprzez wysyłanie powiadomień do administratorów systemu (jeśli to konieczne), przesyłanie zdarzeń do usługi monitorującej, takiej jak Sentry.io, i rejestrowanie ich.

Oto podstawowy przepływ pracy do radzenia sobie z błędami:

Obsługa błędów w Node.js: podstawowy przepływ pracy

W niektórych częściach kodu błędy są przechwytywane i przekazywane do oprogramowania pośredniczącego obsługującego błędy.

 ... try { userService.addNewUser(req.body).then((newUser: User) => { res.status(200).json(newUser); }).catch((error: Error) => { next(error) }); } catch (error) { next(error); } ...

Oprogramowanie pośredniczące do obsługi błędów jest dobrym miejscem do rozróżniania typów błędów i wysyłania ich do scentralizowanego składnika obsługi błędów. Znajomość podstaw obsługi błędów w oprogramowaniu pośredniczącym Express.js z pewnością pomogłaby.

 app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => { if (!errorHandler.isTrustedError(err)) { next(err); } await errorHandler.handleError(err); });

Do tej pory można sobie wyobrazić, jak powinien wyglądać scentralizowany komponent, ponieważ wykorzystaliśmy już niektóre jego funkcje. Pamiętaj, że od Ciebie zależy, jak to zaimplementujesz, ale może to wyglądać następująco:

 class ErrorHandler { public async handleError(err: Error): Promise<void> { await logger.error( 'Error message from the centralized error-handling component', err, ); await sendMailToAdminIfCritical(); await sendEventsToSentry(); } public isTrustedError(error: Error) { if (error instanceof BaseError) { return error.isOperational; } return false; } } export const errorHandler = new ErrorHandler();

Czasami dane wyjściowe domyślnego pliku „console.log” utrudniają śledzenie błędów. Zamiast tego znacznie lepiej byłoby wydrukować błędy w sformatowany sposób, aby programiści mogli szybko zrozumieć problemy i upewnić się, że zostały naprawione.

Ogólnie rzecz biorąc, zaoszczędzi to czas programistom, ułatwiając śledzenie błędów i radzenie sobie z nimi poprzez zwiększenie ich widoczności. Dobrym rozwiązaniem jest użycie konfigurowalnego rejestratora, takiego jak Winston lub Morgan.

Oto dostosowany rejestrator Winston:

 const customLevels = { levels: { trace: 5, debug: 4, info: 3, warn: 2, error: 1, fatal: 0, }, colors: { trace: 'white', debug: 'green', info: 'green', warn: 'yellow', error: 'red', fatal: 'red', }, }; const formatter = winston.format.combine( winston.format.colorize(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.splat(), winston.format.printf((info) => { const { timestamp, level, message, ...meta } = info; return `${timestamp} [${level}]: ${message} ${ Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '' }`; }), ); class Logger { private logger: winston.Logger; constructor() { const prodTransport = new winston.transports.File({ filename: 'logs/error.log', level: 'error', }); const transport = new winston.transports.Console({ format: formatter, }); this.logger = winston.createLogger({ level: isDevEnvironment() ? 'trace' : 'error', levels: customLevels.levels, transports: [isDevEnvironment() ? transport : prodTransport], }); winston.addColors(customLevels.colors); } trace(msg: any, meta?: any) { this.logger.log('trace', msg, meta); } debug(msg: any, meta?: any) { this.logger.debug(msg, meta); } info(msg: any, meta?: any) { this.logger.info(msg, meta); } warn(msg: any, meta?: any) { this.logger.warn(msg, meta); } error(msg: any, meta?: any) { this.logger.error(msg, meta); } fatal(msg: any, meta?: any) { this.logger.log('fatal', msg, meta); } } export const logger = new Logger();

Zasadniczo zapewnia rejestrowanie na wielu różnych poziomach w sformatowany sposób, z wyraźnymi kolorami i logowanie do różnych nośników wyjściowych zgodnie ze środowiskiem wykonawczym. Dobrą rzeczą jest to, że możesz oglądać i odpytywać logi za pomocą wbudowanych interfejsów API Winstona. Ponadto możesz użyć narzędzia do analizy dzienników, aby przeanalizować sformatowane pliki dziennika, aby uzyskać bardziej przydatne informacje o aplikacji. To niesamowite, prawda?

Do tego momentu dyskutowaliśmy głównie o radzeniu sobie z błędami operacyjnymi. A co z błędami programisty? Najlepszym sposobem radzenia sobie z tymi błędami jest natychmiastowa awaria i wdzięczne ponowne uruchomienie za pomocą automatycznego restartu, takiego jak PM2, ponieważ błędy programisty są nieoczekiwane, ponieważ są to rzeczywiste błędy, które mogą spowodować, że aplikacja znajdzie się w złym stanie i będzie się zachowywać. w nieoczekiwany sposób.

 process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

Na koniec wspomnę o radzeniu sobie z nierozpatrzonymi odrzuceniami obietnic i wyjątkami.

Podczas pracy nad aplikacjami Node.js/Express możesz spędzać dużo czasu na dotrzymywaniu obietnic. Nie jest trudno zobaczyć komunikaty ostrzegawcze o nieobsługiwanym odrzuceniu obietnic, gdy zapomnisz obsłużyć odrzucenia.

Komunikaty ostrzegawcze nie robią wiele poza rejestrowaniem, ale dobrą praktyką jest użycie przyzwoitego powrotu i zasubskrybowanie process.on('unhandledRejection', callback) .

Typowy przepływ obsługi błędów może wyglądać następująco:

 // somewhere in the code ... User.getUserById(1).then((firstUser) => { if (firstUser.isSleeping === false) throw new Error('He is not sleeping!'); }); ... // get the unhandled rejection and throw it to another fallback handler we already have. process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => { throw reason; }); process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });

Zawijanie

Kiedy już wszystko zostało powiedziane i zrobione, powinieneś zdać sobie sprawę, że obsługa błędów nie jest opcjonalnym dodatkiem, ale raczej istotną częścią aplikacji, zarówno na etapie rozwoju, jak i produkcji.

Strategia obsługi błędów w jednym komponencie w Node.js zapewni programistom oszczędność cennego czasu oraz pisanie czystego i łatwego w utrzymaniu kodu, unikając duplikacji kodu i brakującego kontekstu błędu.

Mam nadzieję, że podobało Ci się czytanie tego artykułu, a omawiany przepływ pracy i implementacja obsługi błędów okazały się pomocne przy tworzeniu solidnej bazy kodu w Node.js.


Dalsza lektura na blogu Toptal Engineering:

  • Używanie tras Express.js do obsługi błędów opartej na obietnicach