Pull to refresh

Централизованная обработка исключений в Node.JS

Reading time 6 min
Views 9.6K
Original author: Stella Laurenzo


Преамбула от переводчика: пару месяцев назад я искал решение для возможности использовать исключения в сервере игры, написанном на node.js. К сожалению, исключения в чистом виде не совсем совместимы со средой, работающей на event loop'е. Легче всего это объяснить на примере:
try {
    process.nextTick(function() {
        throw new Error('Catch Me If You Can');
    });
} catch (e) {
    console.log('Exception caught:', e);
}

Это исключение, разумеется, не будет поймано, и оно уронит весь процесс. Месяц назад увидел свет node.js версии 0.8.0 со свеженьким (экспериментальным) модулем domain, который как раз призван решать подобные проблемы. Тем не менее, я бы хотел отдать дань классу, которым я пользуюсь до сих пор. Поехали:

Функциональное программирование в node.js — это весело, выразительно и компактно. Кроме одного момента — обработки исключений. Об этом не часто говорят, но, по моему мнению, отсутствие гармоничного способа обработки ошибок и исключений — один из самых больших недостатков node.js. Node-fibers использует полностью императивный стиль программирования, чтобы достичь этого, но мне бы хотелось решить проблему в рамках функционального стиля.

Проблема с кодами ошибок (на которых основано ядро node.js) — это то, что код, который сталкивается первым с возникшей ошибкой — это почти всегда не то место определить, как нужно на нее реагировать. В этом случае try/catch-структуры в многопоточных системах куда более понятны. Кто-нибудь выше по стэку обычно знает, как обработать ошибку.

Однако, проблема с асинхронными системами, такими как node, заключается в том, что каждый раз, когда вызывается один из ваших коллбэков или слушателей EventEmitter, он вызывается либо на самом верху event loop'а, либо вызывается кодом, отличным от того, который назначал этого слушателя (место, где мы назначаем слушателя — это, возможно, более удачное место обработки потенциальной ошибки, чем некий произвольный код, который его вызвал). Если вы кидаете исключение в таких условиях, ваша программа наверняка упадёт целиком. Принимая во внимание, что в JavaScript существует очень много возможностей, когда верный код может выкинуть runtime-ошибки, эта проблема становится даже хуже, чем в C, где я могу избежать проблем, если будут осторожной с указателями и не буду делить на ноль. Да, юнит-тесты помогают, но это скорее попытка затыкать все дыры в сите, когда вам на самом деле просто нужна чаша.

Чтобы этого добиться, нам нужна возможность задать Блок (Block) с собственным Обработчиком Ошибок (Error Handler), которые можно просто и быстро создавать, и передавать вместе с коллбэками во внешний код. Затем, если в коллбэке возникнет исключение, то оно должно быть направлено в Обработчик Ошибок Блока, который был активным, когда мы создавали коллбэк. Я обнаружила, что большинство таких решений привносят Futures, Promises, Fibers, и т.д. вместе с этим простым функционалом для создания Блоков. Следующий сниппет описывает класс Block, который делает ровно то, что мне нужно:
/**
 * Класс Block используется для перенаправления ошибок в логику верхнего уровня.
 */
function Block(errback) {
	this._parent=Block.current;
	this._errback=errback;
}
Block.current=null;

/**
 * Обернуть функцию, чтобы любое исключение, возникшее во время ее работы,
 * было перенаправлено в обработчик ошибок активного блока на момент вызова guard().
 * Если не существует активного блока, возвращается оригинальная функция.
 *
 * Example: stream.on('end', Block.guard(function() { ... }));
 */
Block.guard=function(f) {
	if (this.current) return this.current.guard(f);
	else return f;
};

/**
 * Начать новый блок с двумя коллбэками. Первый - это основная часть блока ("тело try").
 * Второй - это обработчик ошибок ('catch').
 */
Block.begin=function(block, rescue) {
	var ec=new Block(rescue);
	return ec.trap(block);
};

/**
 * Возвращает функцию function(err), которую можно вызвать в любое время для того,
 * чтобы кинуть исключение в текущий блок (или текущий контекст, если блок не создан).
 * Ошибка возникает только если аргумент err эквивалентен true.
 *
 * Example: request.on('error', Block.errorHandler())
 */
Block.errorHandler=function() {
	// Capture the now current Block for later
	var current=this.current;

	return function(err) {
		if (!err) return;
		if (current) return current.raise(err);
		else throw err;
	};
};

/**
 * Кинуть исключение в блоке. Если у блока есть error handler, исключение будет передано в него.
 * Иначе вызо raise(...) будет проброшен в родительский блок. Если родительского блока нет,
 * исключение будет просто брошено дальше с помощью throw.
 * Любые исключения, кинутые в error handler'ах вложенных блоков,
 * будут проброшены в родительские error handler'ы.
 */
Block.prototype.raise=function(err) {
	if (this._errback) {
		try {
			this._errback(err);
		} catch (nestedE) {
			if (this._parent) this._parent.raise(nestedE);
			else throw nestedE;
		}
	} else {
		if (this._parent) this._parent.raise(err);
		else throw(err);
	}
};

/**
 * Выполнить коллбэк callback в контексте текущего блока.
 * Любые исключения будут переданы в метод raise() этого блока.
 * Возвращает return value коллбэка или undefined при возникновении ошибки.
 */
Block.prototype.trap=function(callback) {
	var origCurrent=Block.current;
	Block.current=this;
	try {
		var ret=callback();
		Block.current=origCurrent;
		return ret;
	} catch (e) {
		Block.current=origCurrent;
		this.raise(e);
	}
};

/**
 * Обернуть функцию, чтобы ошибки из нее перенаправлялись в этот блок.
 * Этот метод похож на trap(), но возвращает функцию вместо ее немедленного исполнения.
 */
Block.prototype.guard=function(f) {
	if (f.__guarded__) return f;
	var self=this;
	var wrapped=function() {
		var origCurrent=Block.current;
		Block.current=self;
		try {
			var ret=f.apply(this, arguments);
			Block.current=origCurrent;
			return ret;
		} catch (e) {
			Block.current=origCurrent;
			self.raise(e);
		}
	};
	wrapped.__guarded__=true;
	return wrapped;
};


(Я выбрала терминологию Block/Rescue не потому что испытываю нежность к Ruby, а потому что такое решение не использует зарезервированные в JS слова).

Прим. переводчика: пример из начала статьи, но с использованием Блоков, приобретает такой вид:
Block.begin(function() {
    process.nextTick(Block.guard(function() {
        throw new Error;
    }));
}, function(err) {
    console.log('Exception caught:', err);
});
Теперь исключение обрабатывается, и мы можем не ронять сервер. Это работает и с setTimeout, EventEmitter, коллбэками для запросов к бд, и чем угодно еще.

Теперь рассмотрим пример использования Блока для централизованной обработки ошибок. В нашем примере используется connect'овский middleware, и в этом случае функция next является отличным обработчиком ошибок: она отдаст правильную ошибку http-клиенту. Если бы нам нужно было как-то самим обрабатывать ошибку, мы могли бы просто описать коллбэк в виде function(err) { обработка; next(err); }. Также вы можете использовать inline-функции в вызовах Block.begin для большего визуального сходства с try/catch, но я предпочитаю использовать именованные коллбэки для повышения читаемости.

function handleUserAgent(req, res, next) {
	return Block.begin(process, next);

	function process() {
		jsonifyRequest(req, withRequest); // мы уверены, что код jsonifyRequest блокирующий,
		// поэтому не используем Block.guard()
	}

	function withRequest(requestObj) {
		var r=validators.UserAgentRecord(requestObj, {fix:true});
		if (!r.valid) {
			res.writeHead(400);
			return res.end('Invalid request object: ' + r.reason);
		}

		var uar=r.object;
		if (uar.token) {
			// Verify
			//return handler.verifyUserAgent(uar);
			throw new Error('verifyUserAgent not yet implemented');
		} else {
			// Create
			uar.token=null;
			uar.type='auth';	// TODO: Maybe support unauth in the future?
			handler.createUserAgent(uar, Block.guard(withUserAgent));
		}
	}

	function withUserAgent(userAgent) {
		var r=validators.UserAgentRecord(userAgent, {fix:true});
		return respondJson(r.object, res);
	}
}


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

Рассмотрим еще один пример явного использования блока в коллбэках. В этом случае мы делаем HTTP-запрос, накапливаем текст ответа, вызываем callback, передавая в него созданный CouchResponse (который производит парсинг ответа и другие вещи, которые могут кинуть исключение).

request: function(options, callback) {
	var req=http.request(options, function(res) {
		var text='';
		res.setEncoding('utf8');
		res.on('data', function(chunk) {
			text+=chunk;
		});
		res.on('end', Block.guard(function() {
			callback(new CouchResponse(res, text));
		}));
		res.on('error', Block.errorHandler());
	});
	req.on('error', Block.errorHandler());
	req.end();
}


Здесь все еще есть несколько мест, где мы могли бы возникнуть неожиданные исключения, роняющие весь процесс:
  • Непосредственно в коллбэке function(res).
  • В коллбэке 'data'.

Я могла бы их тоже завернуть с помощью Block.guard, но считаю это лишним. К тому же я на 100% уверена, что ошибка в этом случае критична, и должна быть покрыта юнит-тестами. Обработчик 'end', однако, делает кое-что такое, что я не могу увидеть немедленно (и мне известно, что оно содержит вызов JSON.parse), так что я предпочитаю защитить его с помощью guard. Наконец, я использую стандартный errorHandler() блока, чтобы отлавливать события ошибок запроса и ответа. Этот простой шаблон централизованной обработки ошибок делает довольно понятным, куда эти ошибки идут, и обрабатывать их на любом уровне, где это имеет смысл. Вы можете использовать вложенные вызовы Block.begin() (аналог try{try{}catch{}}catch{}). Это полезно в коде фреймворков, который должен делать какую-то работу в рамках блока, созданного чужим кодом.

PS: У автора встречается реализация Future и примеры с их использованием. Я не стал переводить всё, что касается Future, и адаптировал примеры под использование классических коллбэков.

Рекомендую ознакомиться с оригинальным текстом целиком, т.к. там перечислено целых 10 рекомендаций для написания пуленепробиваемого кода на node.js.

Класс Block я оформил в виде github-репозитория и npm-модуля (npm install control-block).
Tags:
Hubs:
+19
Comments 11
Comments Comments 11

Articles