Как создать систему обработки ошибок Node.js

Опубликовано: 2022-03-11

Нетрудно заметить, что некоторые люди изо всех сил пытаются обработать ошибки, а некоторые даже полностью упускают это из виду. Правильная обработка ошибок означает не только сокращение времени разработки за счет легкого поиска багов и ошибок, но и разработку надежной кодовой базы для крупномасштабных приложений.

В частности, разработчики Node.js иногда обнаруживают, что работают с не очень чистым кодом при обработке разного рода ошибок, неправильно применяя везде одну и ту же логику для их обработки. Они просто продолжают спрашивать себя: «Плохо ли Node.js обрабатывает ошибки?» или , если нет, как с ними справиться?» Мой ответ им: «Нет, Node.js совсем не плох. Это зависит от нас, разработчиков».

Вот одно из моих любимых решений для этого.

Типы ошибок в Node.js

В первую очередь необходимо иметь четкое представление об ошибках в Node.js. В целом ошибки Node.js делятся на две отдельные категории: операционные ошибки и ошибки программиста .

  • Операционные ошибки представляют собой проблемы во время выполнения, результаты которых ожидаются, и с ними следует обращаться надлежащим образом. Операционные ошибки не означают, что в самом приложении есть ошибки, но разработчикам нужно обдуманно с ними справляться. Примеры операционных ошибок включают «недостаточно памяти», «недопустимый ввод для конечной точки API» и т. д.
  • Ошибки программиста представляют собой неожиданные ошибки в плохо написанном коде. Они означают, что в самом коде есть некоторые проблемы, которые нужно решить, и он был написан неправильно. Хороший пример — попытаться прочитать свойство «undefined». Чтобы решить проблему, код должен быть изменен. Это ошибка разработчика, а не операционная ошибка.

Имея это в виду, у вас не должно возникнуть проблем с различением этих двух категорий ошибок: операционные ошибки являются естественной частью приложения, а ошибки программиста — это ошибки, вызванные разработчиками. Возникает закономерный вопрос: «Почему полезно разделить их на две категории и работать с ними?»

Без четкого понимания ошибок может возникнуть желание перезапустить приложение всякий раз, когда возникает ошибка. Имеет ли смысл перезапускать приложение из-за ошибки «Файл не найден», когда приложением пользуются тысячи пользователей? Точно нет.

А как же ошибки программиста? Имеет ли смысл продолжать работу приложения, когда появляется неизвестная ошибка, которая может привести к неожиданному эффекту снежного кома в приложении? Опять же, определенно нет!

Пришло время правильно обрабатывать ошибки

Предполагая, что у вас есть некоторый опыт работы с асинхронным JavaScript и Node.js, вы могли столкнуться с недостатками при использовании обратных вызовов для обработки ошибок. Они заставляют вас проверять все ошибки, вплоть до вложенных, вызывая пресловутые проблемы с «адскими обратными вызовами», которые затрудняют отслеживание потока кода.

Использование promises или async/await — хорошая замена обратным вызовам. Типичный поток кода async/await выглядит следующим образом:

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

Использование встроенного объекта Error в Node.js является хорошей практикой, поскольку он включает интуитивно понятную и четкую информацию об ошибках, таких как StackTrace, от которой зависит большинство разработчиков, чтобы отслеживать причину ошибки. А дополнительные значимые свойства, такие как код состояния HTTP и описание, расширяющее класс Error, сделают его более информативным.

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

Я реализовал некоторые коды состояния HTTP только для простоты, но вы можете добавить их позже.

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

Нет необходимости расширять BaseError или APIError, но можно расширить его для распространенных ошибок в соответствии с вашими потребностями и личными предпочтениями.

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

Итак, как вы его используете? Просто вставьте это:

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

Централизованная обработка ошибок Node.js

Теперь мы готовы создать основной компонент нашей системы обработки ошибок Node.js: централизованный компонент обработки ошибок.

Обычно хорошей идеей является создание централизованного компонента обработки ошибок, чтобы избежать возможного дублирования кода при обработке ошибок. Компонент обработки ошибок отвечает за то, чтобы пойманные ошибки были понятны, например, путем отправки уведомлений системным администраторам (при необходимости), передачи событий в службу мониторинга, такой как Sentry.io, и их регистрации.

Вот основной рабочий процесс для работы с ошибками:

Обработка ошибок в Node.js: базовый рабочий процесс

В некоторых частях кода ошибки перехватываются для передачи промежуточному программному обеспечению для обработки ошибок.

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

Промежуточное ПО для обработки ошибок — это хорошее место, чтобы различать типы ошибок и отправлять их в централизованный компонент обработки ошибок. Знание основ обработки ошибок в промежуточном программном обеспечении Express.js, безусловно, поможет.

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

Теперь можно представить, как должен выглядеть централизованный компонент, потому что мы уже использовали некоторые его функции. Имейте в виду, что это полностью зависит от вас, как это реализовать, но это может выглядеть следующим образом:

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

Иногда вывод файла console.log по умолчанию затрудняет отслеживание ошибок. Скорее, было бы намного лучше печатать ошибки в отформатированном виде, чтобы разработчики могли быстро понять проблемы и убедиться, что они исправлены.

В целом, это сэкономит время разработчиков, упростив отслеживание ошибок и их обработку за счет повышения их видимости. Это хорошее решение использовать настраиваемый регистратор, такой как winston или morgan.

Вот настроенный регистратор 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();

В основном он обеспечивает ведение журнала на нескольких разных уровнях в отформатированном виде, с четкими цветами и ведением журнала на разных носителях вывода в соответствии со средой выполнения. Хорошо, что вы можете просматривать и запрашивать журналы с помощью встроенных API Winston. Кроме того, вы можете использовать инструмент анализа журнала для анализа отформатированных файлов журнала, чтобы получить более полезную информацию о приложении. Это потрясающе, не так ли?

До этого момента мы в основном обсуждали работу с операционными ошибками. Как насчет ошибок программиста? Лучший способ справиться с этими ошибками — немедленно выйти из строя и аккуратно перезапустить с помощью автоматического перезапуска, такого как PM2, — причина в том, что ошибки программиста неожиданны, поскольку они являются реальными ошибками, которые могут привести к тому, что приложение перейдет в неправильное состояние и поведет себя. неожиданным образом.

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

И последнее, но не менее важное: я собираюсь упомянуть о необработанных отклонениях обещаний и исключениях.

Вы можете обнаружить, что тратите много времени на промисы при работе с приложениями Node.js/Express. Несложно увидеть предупреждающие сообщения об отклонении необработанных обещаний, когда вы забываете обработать отклонения.

Предупреждающие сообщения мало что делают, кроме ведения журнала, но рекомендуется использовать достойный запасной вариант и подписаться на process.on('unhandledRejection', callback) .

Типичный поток обработки ошибок может выглядеть следующим образом:

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

Подведение итогов

Когда все сказано и сделано, вы должны понимать, что обработка ошибок — это не дополнительная функция, а неотъемлемая часть приложения, как на этапе разработки, так и в производстве.

Стратегия обработки ошибок в одном компоненте в Node.js гарантирует, что разработчики сэкономят драгоценное время и напишут чистый и удобный для сопровождения код, избегая дублирования кода и отсутствия контекста ошибки.

Надеюсь, вам понравилось читать эту статью, и вы нашли обсуждаемый рабочий процесс и реализацию обработки ошибок полезными для создания надежной кодовой базы в Node.js.


Дальнейшее чтение в блоге Toptal Engineering:

  • Использование маршрутов Express.js для обработки ошибок на основе обещаний