Те, кому приходилось разрабатывать более-менее большие web-проекты на node.js, наверняка сталкивались с проблемой обработки ошибок, произошедших внутри асинхронных вызовов. Эта проблема обычно всплывает далеко не сразу, а когда у вас уже есть много написанного кода, который делает нечто большее, чем выводит «Hello, World!».
Суть проблемы
Для примера возьмём простое приложение на connect:
var connect = require('connect');
var getName = function () {
if (Math.random() > 0.5) {
throw new Error('Can't get name');
} else {
return 'World';
}
};
var app = connect()
.use(function (req, res, next) {
try {
var name = getName();
res.end('Hello, ' + name + '!');
} catch (e) {
next(e);
}
})
.use(function (err, req, res, next) {
res.end('Error: ' + err.message);
});
app.listen(3000);
Здесь мы имеем синхронную функцию, которая с некоторой долей вероятности, генерирует ошибку. Мы ловим эту ошибку и передаём в общий обработчик ошибок, который, в свою очередь, показывает ошибку пользователю. В данном примере вызов функции происходит синхронно и обработка ошибки ничем не отличается от подобной задачи в других языках.
Теперь попробуем сделать тоже самое, но функция getName будет асинхронной:
var connect = require('connect');
var getName = function (callback) {
process.nextTick(function () {
if (Math.random() > 0.5) {
callback(new Error('Can't get name'));
} else {
callback(null, 'World');
}
});
};
var app = connect()
.use(function (req, res, next) {
getName(function(err, name) {
if (err) return next(err);
res.end('Hello, ' + name + '!');
});
})
.use(function (err, req, res, next) {
res.end('Error: ' + err.message);
});
app.listen(3000);
В этом примере мы уже не можем поймать ошибку через try/catch, т.к. она возникнет не во время вызова функции, а внутри асинхронного вызова, который произойдёт позже (в данном примере — на следующей итерации event loop). Поэтому мы использовали подход, рекомендованный разработчиками node.js — передаём ошибку в первом аргументе функции обратного вызова.
Такой подход полностью решает проблему обработки ошибок внутри асинхронных вызовов, но он сильно раздувает код, когда подобных вызовов становится много. В реальном приложении появляются много методов, которые вызывают друг-друга, могут иметь вложенные вызовы и быть частью цепочек асинхронных вызовов. И каждый раз при возникновении ошибки где-то в глубине стека вызовов нам необходимо «доставить» её на самый верх, там где мы можем её правильно обработать и сообщить пользователю о нештатной ситуации. В синхронном приложении за нас это делает try/catch — там мы можем выбросить ошибку внутри нескольких вложенных вызовов и поймать её там, где можем правильно обработать, без необходимости вручную передавать её наверх по стеку вызовов.
Решение
В Node.JS начиная с версии 0.8.0 появился механизм под названием Domain. Он позволяет отлавливать ошибки внутри асинхронных вызовов, при этом сохраняя контекст выполнения, в отличие от process.on('uncaughtException'). Думаю, пересказывать тут документацию по Domain смысла не имеет, т.к. механизм его работы довольно прост, поэтому я сразу перейду к конкретной реализации универсального обработчика ошибок для connect/express.
Connect/express заворачивает все middleware в блоки try/catch, поэтому, если вы делаете throw внутри middleware, ошибка будет передана в цепочку обработчиков ошибок (middleware с 4-мя аргументами на входе), а если таких middleware нет — в обработчик ошибок по умолчанию, который выведет trace ошибки в браузер и консоль. Но это поведение актуально только для ошибок произошедших в синхронном коде.
При помощи Domain мы можем перенаправлять ошибки, произошедшие внутри асинхронных вызовов, в контексте запроса в цепочку обработчиков ошибок этого запроса. Теперь для нас, в конечном итоге, обработка синхронных и асинхронных ошибок будет выглядеть одинаково.
Для этой цели я написал небольшой модуль-middleware для connect/express, который решает эту задачу. Модуль доступен на GitHub и в npm.
Пример использования:
var
connect = require('connect'),
connectDomain = require('connect-domain');
var app = connect()
.use(connectDomain())
.use(function(req, res){
if (Math.random() > 0.5) {
throw new Error('Simple error');
}
setTimeout(function() {
if (Math.random() > 0.5) {
throw new Error('Asynchronous error from timeout');
} else {
res.end('Hello from Connect!');
}
}, 1000);
})
.use(function(err, req, res, next) {
res.end(err.message);
});
app.listen(3000);
В этом примере ошибки, выброшенные внутри синхронного и асинхронного вызова, будут обработаны одинаково. Вы можете выбросить ошибку на любой глубине вызовов в контексте запроса, и она будет обработана цепочкой обработчиков ошибок данного запроса.
var
connect = require('connect'),
connectDomain = require('connect-domain');
var app = connect()
.use(connectDomain())
.use(function(req, res){
if (Math.random() > 0.5) {
throw new Error('Simple error');
}
setTimeout(function() {
if (Math.random() > 0.5) {
process.nextTick(function() {
throw new Error('Asynchronous error from process.nextTick');
});
} else {
res.end('Hello from Connect!');
}
}, 1000);
})
.use(function(err, req, res, next) {
res.end(err.message);
});
app.listen(3000);
В заключение отмечу, что официально стабильность модуля Domain на момент написания статьи остаётся экспериментальной, однако я уже использую описанный подход, хоть в небольшом но продакшене и не наблюдаю каких-либо проблем. Сайт, использующий данный модуль, ни разу не завершал работу аварийно и не страдает утечками памяти. Uptime процесса больше месяца.
Автор: BVadim