Улучшенная обработка ошибок в NodeJS с классами ошибок
Опубликовано: 2022-03-10error
и то, как его использовать для лучшего и более эффективного способа обработки ошибок в ваших приложениях.Обработка ошибок — одна из тех частей разработки программного обеспечения, которой не уделяется должного внимания. Однако создание надежных приложений требует правильной обработки ошибок.
Вы можете обойтись в NodeJS без надлежащей обработки ошибок, но из-за асинхронной природы NodeJS неправильная обработка или ошибки могут достаточно быстро причинить вам боль, особенно при отладке приложений.
Прежде чем мы продолжим, я хотел бы указать на тип ошибок, которые мы будем обсуждать, как использовать классы ошибок.
Операционные ошибки
Это ошибки, обнаруженные во время выполнения программы. Операционные ошибки не являются ошибками и могут время от времени возникать в основном из-за одного или комбинации нескольких внешних факторов, таких как тайм-аут сервера базы данных или пользователь, решивший сделать попытку SQL-инъекции, введя SQL-запросы в поле ввода.
Ниже приведены другие примеры операционных ошибок:
- Не удалось подключиться к серверу базы данных;
- Неверные входные данные пользователя (сервер отвечает кодом ответа
400
); - тайм-аут запроса;
- Ресурс не найден (сервер отвечает кодом ответа 404);
- Сервер возвращается с ответом
500
.
Стоит также кратко обсудить аналог Операционных ошибок.
Ошибки программиста
Это ошибки в программе, которые можно устранить, изменив код. Эти типы ошибок не могут быть обработаны, потому что они возникают в результате взлома кода. Примеры этих ошибок:
- Попытка прочитать свойство объекта, который не определен.
const user = { firstName: 'Kelvin', lastName: 'Omereshone', } console.log(user.fullName) // throws 'undefined' because the property fullName is not defined
- Вызов или вызов асинхронной функции без обратного вызова.
- Передача строки там, где ожидалось число.
Эта статья посвящена обработке операционных ошибок в NodeJS. Обработка ошибок в NodeJS существенно отличается от обработки ошибок в других языках. Это связано с асинхронным характером JavaScript и открытостью JavaScript с ошибками. Позволь мне объяснить:
В JavaScript экземпляры класса error
— не единственное, что вы можете генерировать. Вы можете буквально выбросить любой тип данных, эта открытость не разрешена другими языками.
Например, разработчик JavaScript может решить добавить число вместо экземпляра объекта ошибки, например:
// bad throw 'Whoops :)'; // good throw new Error('Whoops :)')
Вы можете не видеть проблемы в генерировании других типов данных, но это усложнит отладку, поскольку вы не получите трассировку стека и другие свойства, которые предоставляет объект Error и которые необходимы для отладки.
Давайте рассмотрим некоторые неправильные шаблоны в обработке ошибок, прежде чем взглянуть на шаблон класса Error и на то, что это гораздо лучший способ обработки ошибок в NodeJS.
Плохой шаблон обработки ошибок № 1: неправильное использование обратных вызовов
Реальный сценарий : ваш код зависит от внешнего API, требующего обратного вызова для получения ожидаемого результата.
Возьмем приведенный ниже фрагмент кода:
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder'); fs.writeFile('./writeFolder/foobar.txt', 'Hello World'); } write();
До NodeJS 8 и выше приведенный выше код был законным, и разработчики просто запускали и забывали команды. Это означает, что разработчикам не требовалось обеспечивать обратный вызов для таких вызовов функций, и поэтому они могли не учитывать обработку ошибок. Что происходит, когда writeFolder
не создан? Вызов writeFile
не будет выполнен, и мы ничего об этом не узнаем. Это также может привести к состоянию гонки, потому что первая команда может не завершиться, когда вторая команда снова запустится, и вы этого не узнаете.
Давайте начнем решать эту проблему с решения условия гонки. Мы бы сделали это, передав первой команде mkdir
обратный вызов, чтобы убедиться, что каталог действительно существует, прежде чем писать в него второй командой. Таким образом, наш код будет выглядеть так, как показано ниже:
'use strict'; const fs = require('fs'); const write = function () { fs.mkdir('./writeFolder', () => { fs.writeFile('./writeFolder/foobar.txt', 'Hello World!'); }); } write();
Хотя мы решили проблему гонки, мы еще не закончили. Наш код по-прежнему проблематичен, потому что, несмотря на то, что мы использовали обратный вызов для первой команды, у нас нет возможности узнать, была ли создана папка writeFolder
или нет. Если папка не была создана, то второй вызов снова завершится ошибкой, но мы все равно снова проигнорировали ошибку. Мы решаем это с помощью…
Обработка ошибок с обратными вызовами
Чтобы правильно обрабатывать ошибки с помощью обратных вызовов, вы должны убедиться, что вы всегда используете подход, основанный на ошибке. Это означает, что вы должны сначала проверить, есть ли ошибка, возвращенная функцией, прежде чем использовать какие-либо данные (если они есть). Давайте посмотрим на неправильный способ сделать это:
'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);
Приведенный выше шаблон неверен, потому что иногда API, который вы вызываете, может не возвращать никакого значения или может возвращать ложное значение в качестве допустимого возвращаемого значения. Это приведет к тому, что вы окажетесь в случае ошибки, даже если у вас может быть успешный вызов функции или API.
Приведенный выше шаблон также плох, потому что его использование съест вашу ошибку (ваши ошибки не будут вызываться, даже если это могло произойти). Вы также не будете знать, что происходит в вашем коде из-за такого шаблона обработки ошибок. Итак, правильный способ для приведенного выше кода:
'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);
Паттерн неправильной обработки ошибок № 2: неправильное использование промисов
Сценарий из реальной жизни: Итак, вы обнаружили промисы и думаете, что они намного лучше, чем обратные вызовы, из-за ада обратных вызовов, и вы решили промисифицировать какой-то внешний API, от которого зависела ваша кодовая база. Или вы используете промис из внешнего API или API браузера, например, функцию fetch().
В наши дни мы на самом деле не используем обратные вызовы в наших кодовых базах NodeJS, мы используем промисы. Итак, давайте повторно реализуем наш примерный код с обещанием:
'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) }) }
Давайте рассмотрим приведенный выше код под микроскопом — мы увидим, что мы разветвляем fs.mkdir
на другую цепочку промисов (вызов fs.writeFile), даже не обрабатывая этот вызов промиса. Вы можете подумать, что лучший способ сделать это:
'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) }) }
Но вышеперечисленное не будет масштабироваться. Это потому, что если у нас будет больше цепочки обещаний для вызова, мы получим что-то похожее на ад обратных вызовов, для решения которого были созданы обещания. Это означает, что наш код будет продолжать отступать вправо. У нас был бы обещанный ад на наших руках.
Обещание API на основе обратного вызова
В большинстве случаев вы хотели бы обещать API на основе обратного вызова самостоятельно, чтобы лучше обрабатывать ошибки в этом API. Однако сделать это на самом деле не просто. Давайте рассмотрим пример ниже, чтобы объяснить, почему.
function doesWillNotAlwaysSettle(arg) { return new Promise((resolve, reject) => { doATask(foo, (err) => { if (err) { return reject(err); } if (arg === true) { resolve('I am Done') } }); }); }
Из вышеизложенного, если arg
не true
и у нас нет ошибки при вызове функции doATask
, то это обещание просто будет висеть, что является утечкой памяти в вашем приложении.
Проглоченные ошибки синхронизации в промисах
Использование конструктора Promise сопряжено с рядом трудностей, одна из которых заключается в следующем: как только он либо разрешен, либо отклонен, он не может получить другое состояние. Это связано с тем, что обещание может получить только одно состояние — либо оно ожидает выполнения, либо разрешено/отклонено. Это означает, что у нас могут быть мертвые зоны в наших промисах. Давайте посмотрим на это в коде:
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 }); }); }
Из вышеизложенного мы видим, что как только обещание разрешено, следующая строка является мертвой зоной и никогда не будет достигнута. Это означает, что любая последующая синхронная обработка ошибок, выполняемая в ваших промисах, будет просто проглочена и никогда не будет выброшена.
Реальные примеры
Приведенные выше примеры помогают объяснить плохие шаблоны обработки ошибок, давайте рассмотрим проблемы, с которыми вы можете столкнуться в реальной жизни.
Пример из реальной жизни №1 — Преобразование ошибки в строку
Сценарий : вы решили, что ошибка, возвращаемая API, недостаточно хороша для вас, поэтому вы решили добавить к ней собственное сообщение.
'use strict'; function readTemplate() { return new Promise(() => { databaseGet('query', function(err, data) { if (err) { reject('Template not found. Error: ', + err); } else { resolve(data); } }); }); } readTemplate();
Давайте посмотрим, что не так с приведенным выше кодом. Из приведенного выше мы видим, что разработчик пытается исправить ошибку, выдаваемую API databaseGet
, путем объединения возвращаемой ошибки со строкой «Шаблон не найден». Этот подход имеет много недостатков, потому что, когда конкатенация была выполнена, разработчик неявно запускает toString
для возвращаемого объекта ошибки. Таким образом, он теряет любую дополнительную информацию, возвращаемую ошибкой (попрощайтесь с трассировкой стека). Так что у разработчика сейчас есть просто строка, которая бесполезна при отладке.
Лучший способ — оставить ошибку как есть или обернуть ее другой ошибкой, которую вы создали, и прикрепить к ней выданную ошибку из вызова databaseGet в качестве свойства.
Реальный пример №2: полное игнорирование ошибки
Сценарий : возможно, когда пользователь регистрируется в вашем приложении, если возникает ошибка, вы хотите просто перехватить ошибку и показать собственное сообщение, но вы полностью проигнорировали обнаруженную ошибку, даже не зарегистрировав ее для целей отладки.
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'}); }) });
Из приведенного выше видно, что ошибка полностью игнорируется и код отправляет пользователю 500, если обращение к базе данных не удалось. Но на самом деле причиной сбоя базы данных могут быть искаженные данные, отправленные пользователем, что является ошибкой с кодом состояния 400.

В приведенном выше случае мы оказались бы в ужасе отладки, потому что вы, как разработчик, не знали бы, что пошло не так. Пользователь не сможет дать достойный отчет, потому что всегда выдается внутренняя ошибка сервера 500. В конечном итоге вы потратите часы на поиск проблемы, что будет равносильно пустой трате времени и денег вашего работодателя.
Реальный пример № 3: отказ от принятия ошибки, выданной из API
Сценарий : из API, который вы использовали, была выдана ошибка, но вы не принимаете эту ошибку, вместо этого вы сортируете и преобразуете ошибку таким образом, чтобы сделать ее бесполезной для целей отладки.
Возьмите следующий пример кода ниже:
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; } }
В приведенном выше коде происходит много такого, что привело бы к ужасу отладки. Давайте взглянем:
- Обтекание блоков
try/catch
: из приведенного выше видно, что мы оборачиваем блокtry/catch
, что является очень плохой идеей. Обычно мы пытаемся сократить использование блоковtry/catch
, чтобы минимизировать поверхность, на которой нам пришлось бы обрабатывать нашу ошибку (представьте себе, что это СУХАЯ обработка ошибок); - Мы также манипулируем сообщением об ошибке в попытке улучшить, что также не является хорошей идеей;
- Мы проверяем, является ли ошибка экземпляром типа
Klass
, и в этом случае мы устанавливаем логическое свойство ошибкиisKlass
в truev (но если эта проверка проходит успешно, то ошибка имеет типKlass
); - Мы также откатываем базу данных слишком рано, потому что, судя по структуре кода, существует высокая тенденция к тому, что мы могли даже не попасть в базу данных, когда была выдана ошибка.
Ниже приведен лучший способ написать приведенный выше код:
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; } }
Давайте проанализируем, что мы делаем прямо в приведенном выше фрагменте:
- Мы используем один блок
try/catch
, и только в блоке catch мы используем другой блокtry/catch
, который должен служить в качестве защиты на случай, если что-то произойдет с этой функцией отката, и мы регистрируем это; - Наконец, мы выбрасываем нашу исходную полученную ошибку, что означает, что мы не теряем сообщение, включенное в эту ошибку.
Тестирование
В основном мы хотим протестировать наш код (вручную или автоматически). Но в большинстве случаев мы проверяем только положительные моменты. Для надежного теста вы также должны проверить наличие ошибок и пограничных случаев. Эта небрежность является причиной того, что ошибки попадают в производственную среду, что требует дополнительного времени на отладку.
Совет : всегда проверяйте не только положительные моменты (получение кода состояния 200 от конечной точки), но также все случаи ошибок и все пограничные случаи.
Реальный пример № 4: необработанные отказы
Если вы использовали промисы раньше, вы, вероятно, сталкивались с unhandled rejections
.
Вот краткое руководство по необработанным отказам. Необработанные отклонения — это отказы от обещаний, которые не были обработаны. Это означает, что обещание было отклонено, но ваш код продолжит работу.
Давайте рассмотрим распространенный пример из реальной жизни, который приводит к необработанным отказам.
'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! } })();
Приведенный выше код на первый взгляд может показаться не подверженным ошибкам. Но при ближайшем рассмотрении мы начинаем видеть дефект. Позвольте мне объяснить: что происходит, когда a
отвергается? Это означает, что await b
никогда не достигается, и это означает необработанный отказ. Возможное решение — использовать Promise.all
для обоих промисов. Таким образом, код будет выглядеть так:
'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! } })();
Вот еще один реальный сценарий, который может привести к необработанной ошибке отклонения обещания:
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return foobar() } catch { // ignoring errors again ! } } doThings();
Если вы запустите приведенный выше фрагмент кода, вы получите необработанный отказ от промиса, и вот почему: хотя это и не очевидно, мы возвращаем промис (foobar) до того, как обработаем его с помощью try/catch
. Что нам нужно сделать, так это дождаться промиса, который мы обрабатываем с помощью try/catch
, чтобы код выглядел так:
'use strict'; async function foobar() { throw new Error('foobar'); } async function doThings() { try { return await foobar() } catch { // ignoring errors again ! } } doThings();
Подводя итог негативным вещам
Теперь, когда вы увидели неправильные шаблоны обработки ошибок и возможные исправления, давайте теперь углубимся в шаблон класса Error и как он решает проблему неправильной обработки ошибок в NodeJS.
Классы ошибок
В этом шаблоне мы бы начали наше приложение с класса ApplicationError
, чтобы мы знали, что все ошибки в наших приложениях, которые мы явно выбрасываем, будут наследоваться от него. Итак, мы начнем со следующих классов ошибок:
-
ApplicationError
Это предок всех других классов ошибок, т.е. все остальные классы ошибок унаследованы от него. -
DatabaseError
Любая ошибка, связанная с операциями с базой данных, будет наследоваться от этого класса. -
UserFacingError
Любая ошибка, возникшая в результате взаимодействия пользователя с приложением, будет унаследована от этого класса.
Вот как будет выглядеть наш файл класса 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 }
Этот подход позволяет нам различать ошибки, выдаваемые нашим приложением. Итак, теперь, если мы хотим обработать ошибку неверного запроса (неверный пользовательский ввод) или ошибку «не найдено» (ресурс не найден), мы можем наследовать от базового класса UserFacingError
(как в приведенном ниже коде).
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 }
Одним из преимуществ подхода с классом error
является то, что если мы выдадим одну из этих ошибок, например, NotFoundError
, каждый разработчик, читающий этот код, сможет понять, что происходит в данный момент времени (если они читают код). ).
Вы также сможете передать несколько свойств, специфичных для каждого класса ошибок, во время создания экземпляра этой ошибки.
Еще одно ключевое преимущество заключается в том, что вы можете иметь свойства, которые всегда являются частью класса ошибок, например, если вы получаете ошибку UserFacing, вы будете знать, что статусCode всегда является частью этого класса ошибок, теперь вы можете просто напрямую использовать его в код позже.
Советы по использованию классов ошибок
- Создайте свой собственный модуль (возможно, частный) для каждого класса ошибок, чтобы вы могли просто импортировать его в свое приложение и использовать его везде.
- Выбрасывайте только те ошибки, которые вам нужны (ошибки, которые являются экземплярами ваших классов ошибок). Таким образом, вы знаете, что ваши классы ошибок являются вашим единственным источником правды и содержат всю информацию, необходимую для отладки вашего приложения.
- Наличие абстрактного модуля ошибок очень полезно, потому что теперь мы знаем, что вся необходимая информация об ошибках, которые могут выдавать наши приложения, находится в одном месте.
- Обработка ошибок в слоях. Если вы обрабатываете ошибки везде, у вас непоследовательный подход к обработке ошибок, который трудно отслеживать. Под слоями я подразумеваю базы данных, экспресс-уровни/уровни fastify/HTTP и так далее.
Давайте посмотрим, как классы ошибок выглядят в коде. Вот пример в экспрессе:
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) });
Исходя из вышеизложенного, мы используем тот факт, что Express предоставляет глобальный обработчик ошибок, который позволяет вам обрабатывать все ваши ошибки в одном месте. Вы можете увидеть вызов next()
в тех местах, где мы обрабатываем ошибки. Этот вызов передаст ошибки обработчику, определенному в разделе app.use
. Поскольку экспресс не поддерживает async/await, мы используем блоки try/catch
.
Таким образом, из приведенного выше кода для обработки наших ошибок нам просто нужно проверить, является ли выданная ошибка экземпляром UserFacingError
, и автоматически мы знаем, что в объекте ошибки будет статусCode, и мы отправляем его пользователю (вы можете захотеть иметь также определенный код ошибки, который вы можете передать клиенту), и это почти все.
Вы также заметите, что в этом шаблоне (шаблон класса error
) каждая другая ошибка, которую вы явно не выдаете, является ошибкой 500
, потому что это что-то неожиданное, что означает, что вы явно не выдавали эту ошибку в своем приложении. Таким образом, мы можем различать типы ошибок, возникающих в наших приложениях.
Заключение
Правильная обработка ошибок в вашем приложении поможет вам лучше спать по ночам и сэкономить время отладки. Вот несколько ключевых моментов, которые можно вынести из этой статьи:
- Используйте классы ошибок, специально настроенные для вашего приложения;
- Реализовать абстрактные обработчики ошибок;
- Всегда используйте async/await;
- Делайте ошибки выразительными;
- Пользователь обещает, если это необходимо;
- Вернуть правильные статусы и коды ошибок;
- Используйте хуки промисов.
Полезные интерфейсные и UX-функции, доставляемые раз в неделю.
С инструментами, которые помогут вам сделать вашу работу лучше. Подпишитесь и получите контрольные списки Smart Interface Design Checklists от Виталия в формате PDF по электронной почте.
На интерфейсе и UX. Нам доверяют 190 000 человек.