Обработка асинхронных ошибок с сохранением контекста запроса в connect/express

в 7:55, , рубрики: asynchronous, connect, express, node.js, метки: , , ,

Те, кому приходилось разрабатывать более-менее большие 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js