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

    Те, кому приходилось разрабатывать более-менее большие 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 процесса больше месяца.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 11

      0
      Спасибо!
        –2
        Хорошо-бы еще добавить nodejs.org/api/process.html#process_event_uncaughtexception про перехват глобальных исключений, где можно также получить и код возврата — а не уронив активный инстанц нода (не забываем про модуль cluster)
        И по поводу кода — для синхронных функций принято выбрасывать exception для try/catch, а для асинхронных возвращять результат вида prototype function(err=true, object=ExceptionObject)
        Также в node.js+express есть одна особенность — если Вы НЕ уверены в том что Вам всегда завершится удачно — то нужно на каждое подключение в текущем контексте создавать таймер (обычно на 15секунд) — который автоматически закроет соединение при бездействии — тем самым очищая хвосты дескрипторов (актуально при больших нагрузках).
          +2
          То что Вы описали — набор совершенно разных тематик. Не для одной статьи точно. В данной статье рассматривался именно способ обработки ошибок из асинхронных вызовов без потери контекста запроса и без ручной передачи ошибки наверх по стеку вызовов. Поэтому ни process.on('uncaughtException') ни возврат ошибки первым параметров в калбек тут не подходят. Об этом я упомянул в статье.

          Самый простой пример, когда описанный в статье метод актуален:
          1. Поступает запрос на страницу новостей
          2. Контроллер запрашивает у модели новостей список первой страницы новостей
          3. После того как список получен другой метод модели должен сопоставить каждой новости автора (они хранятся в отдельной таблице)
          4. Во время получения информации об одном из авторов падает СУБД и запрос выполняется с ошибкой.

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

          В случае с передачей ошибки первым параметром, нам придётся сначала вернуть её в в калбек функции получения авторов, потом вернуть её оттуда в функцию получения новостей, оттуда уже в функцию контроллера, в которой есть контекст ответа и уже оттуда мы можем вызвать next(err) и передать её обработчику ошибок. Очень часто бывает что стек вызовов ещё больше и появляется куча однотипного кода.

          Описанный в статье метод позволяет избавиться от ручной передачи ошибки. Достаточно сделать throw err; там где она произошла и ошибка будет передана в обработчик с сохранением контекста запроса.

          0
          У меня вот давно возник вопрос. Какие плюсы есть у NodeJS по сравнению с Java?(тут намеренно сравнение инструмента и языка программирования, но можно взять например Netty чтобы было всё честно). Ответьте мне кто-нибудь пожалуйста.
            +2
            Это очень холиварный вопрос. Серебряной пули не существует. У каждой технологии есть свои плюсы и минусы. Важно только то, как их использовать. На любой озвученный плюс найдётся Java/Python/Erlang/Ruby/C# программист, который скажет, что на %language_name% это можно сделать ещё лучше. Смысла нет это обсуждать.
            0
            У нас с Вами сходятся интересы :)
            habrahabr.ru/company/alawar/blog/158905/

            Раз уж вы работали с доменами, не подскажете, как можно создать 2 вложенных домена, и сделать rethrow, чтобы ошибка попала из обработчика ошибок внутреннего в обработчик ошибок внешнего? Я пытался с ними экспериментировать, но с некоторыми вопросами так и не разобрался.
              0
              groups.google.com/d/msg/nodejs/i8NjWjVvk2I/K7grylhIDywJ — это единственный рабочий способ?
                0
                Я не задавался вопросом вложенных доменов. Для решения необходимых задач пока хватает описанного в статье метода. Сейчас ради интереса попробовал — с ходу оно не заработало. uncaughtException работает, но в документации этот механизм не рекомендуют использовать вообще. Как рабочий вариант — вызывать topDomain.emit('error', err) внутри функции обработки ошибки вложенного domain. Но вообще, вкладывать Domain друг в друга, наверное, не самая лучшая идея. По крайней мере для этого сначала нужно полностью разобраться в их внутреннем устройстве, чтобы быть уверенным, что это не окажет влияния на производительность.
                  0
                  Ну просто вложенные домены — это прямой аналог try-catch блоков.
                  Ну и модули control-block и trycatch, которые решают сходные задачи, вполне себе переносят вложенность.
                    +1
                    Я протестировал скорость работы модуля trycatch, на простейшем примере он работает на 20-30% медленнее чем вариант с Domain. При этом вариант с Domain работает на 7-10% медленнее варианта без обработчика ошибок вообще. И это на простейшем примере. trycatch работает очень хардкорно. Он оборачивает все системные методы в свои обработки и следит за стеком выполнения. Это очень медленные операции. К сожалению, пока нет времени протестировать на более сложном примере, но есть мнение что разрыв в скорости будет увеличиваться с возрастанием кол-ва операций. Плюс ко всему цена ошибки в стороннем модуле, который изменяет поведение всех важнейших методов очень высока.

                    control-block не подходит для решения поставленной задачи, т.к. требует дополнительно оборачивать калбеки.

                    Коды тестов:

                    Тест без обработки ошибок (эталон)
                    var
                            connect = require('connect');
                    
                    var app = connect()
                            .use(function(req, res){
                                    process.nextTick(function() {
                                            res.end('Hello from Connect!');
                                    });
                            })
                            .use(function(err, req, res, next) {
                                    res.end(err.message);
                            });
                    
                    app.listen(3131);
                    



                    Тест connect-domain без выстреливания ошибки
                    var
                            connect = require('connect'),
                            connectDomain = require('connect-domain');
                    
                    var app = connect()
                            .use(connectDomain())
                            .use(function(req, res){
                                    process.nextTick(function() {
                                            res.end('Hello from Connect!');
                                    });
                            })
                            .use(function(err, req, res, next) {
                                    res.end(err.message);
                            });
                    
                    app.listen(3131);
                    



                    Тест connect-domain с выстреливанием ошибки
                    var
                            connect = require('connect'),
                            connectDomain = require('connect-domain');
                    
                    var app = connect()
                            .use(connectDomain())
                            .use(function(req, res){
                                    process.nextTick(function() {
                                            if (Math.random() > 0.5) {
                                                    throw new Error('Asynchronous error from process.nextTick');
                                            } else {
                                                    res.end('Hello from Connect!');
                                            }
                                    });
                            })
                            .use(function(err, req, res, next) {
                                    res.end(err.message);
                            });
                    
                    app.listen(3131);
                    



                    Тест trycatch без выстреливания ошибки
                    var
                            connect = require('connect'),
                            trycatch = require('trycatch');
                    
                    var app = connect()
                            .use(function (req, res, next) {
                                    trycatch(function () {
                                            next();
                                    }, function (err) {
                                            next(err);
                                    });
                            })
                            .use(function (req, res){
                                    process.nextTick(function () {
                                            res.end('Hello from Connect!');
                                    });
                            })
                            .use(function (err, req, res, next) {
                                    res.end(err.message);
                            });
                    
                    app.listen(3131);
                    



                    Тест trycatch с выстреливанием ошибки
                    var
                            connect = require('connect'),
                            trycatch = require('trycatch');
                    
                    var app = connect()
                            .use(function (req, res, next) {
                                    trycatch(function () {
                                            next();
                                    }, function (err) {
                                            next(err);
                                    });
                            })
                            .use(function (req, res){
                                    process.nextTick(function () {
                                            if (Math.random() > 0.5) {
                                                    throw new Error('Asynchronous error from process.nextTick');
                                            } else {
                                                    res.end('Hello from Connect!');
                                            }
                                    });
                            })
                            .use(function (err, req, res, next) {
                                    res.end(err.message);
                            });
                    
                    app.listen(3131);
                    



                    Тестировал утилитой ab.
                      0
                      Да, возможно, домены — не такая уж и плохая штука. Посмотрю на них повнимательнее на досуге.
                      Ваши результаты воспроизводятся. У меня trycatch тоже медленнее, но надо понимать, что разница в 20-30% на hello world — выльются в какие-нибудь 5-10% на реальном приложении, а это уже не так уж и много.

              Only users with full accounts can post comments. Log in, please.