Pull to refresh

Ожидание нескольких событий в nodejs

Reading time6 min
Views14K
Наверное, каждый, кто начинает изучать nodejs, испытывает трудности с переходом на событийно-ориентированное программирование. Все просто, пока мы можем выполнять действия последовательно: начинать следующее, дождавшись завершающего события от предыдущего. Но что делать, если таких действий много и они продолжительны во времени? А если мы не можем продолжать, пока не дождемся завершения каждого из них?


Все просто


Небольшое отступление. Любому событию в nodejs может быть сопоставлена некоторая функция-обработчик, которая будет вызвана при наступлении события. Причем, не важно, как она будет передана процессу, который инициирует событие: в виде параметра асинхронной функции или «привязкой» функции к этому событию. Важно одно: нам нужно дождаться завершения работы каждой из наших функций-обработчиков.
Для начала, рассмотрим простейший пример. У нас есть несколько продолжительных действий (будем использовать функцию setTimeout) и запускать следующее действие мы будем только после завершения предыдущего.
Итак, пример:
console.log("begin");
setTimeout(function () {
	console.log("2000ms timeout");
	setTimeout(function () {
		console.log("1500ms timeout");
		setTimeout(function () {
			console.log("1000ms timeout");
			setTimeout(function () {
				console.log("final");
			}, 500);
		}, 1000);
	}, 1500);
}, 2000);
console.log("end");

Мы запускаем некоторый процесс протяженностью 2000мс, после его завершения запускаем второй на 1500мс, затем третий на 1000мс и, наконец, последний, на 500мс. Результат будет следующим:
begin
end
2000ms timeout
1500ms timeout
1000ms timeout
final

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

Почему нужно?


Какой смысл приостанавливать весь процесс и ждать пока медленная подсистема сделает всё, что мы от нее хотим, если нам есть чем заняться помимо ожидания? Никакого. Поэтому одновременный запуск нескольких долгих операций, а к ним можно отнести работу с сетью, дисковой подсистемой, может существенно ускорить выполенение программы.

Чуть сложнее


Предположим, что все операции у нас независимы друг от друга. Тогда их можно запустить параллельно:
console.log("begin");
setTimeout(function () {
	console.log("2000ms timeout");
}, 2000);
setTimeout(function () {
	console.log("1500ms timeout");
}, 1500);
setTimeout(function () {
	console.log("1000ms timeout");
}, 1000);
setTimeout(function () {
	console.log("final");
}, 500);
console.log("end");

Результат выполнения:
begin
end
final
1000ms timeout
1500ms timeout
2000ms timeout

Основная программа завершила выполнение, а затем начали последовательно отрабатывать функции обратного вызова для каждого из «долгих действий». Но главное — время выполнения программы уменьшилось почти в два с половиной раза!
Опять же, ничего сложного в этом примере нет, но только до тех пор, пока одно из действий не должно дожидаться завершения выполнения других.

Еще сложнее


Допустим, последнее действие мы можем запустить, только дождавшись завершения всех предыдущих. Первое, что приходит в голову, это организация счетчика. Сначала его значение будет равно количеству наших функций обратного вызова, выполнения которых надо дождаться. В конце выполнения каждой из них, будет происходить уменьшение счетчика, а при достижении последним нуля, будет вызвана наша «ожидающая функция».
Например, вот так:
var counter = 3;

console.log("begin");
setTimeout(function () { 
	console.log("2000ms timeout"); 
	if (-- counter == 0) final(); 
}, 2000);

setTimeout(function () { 
	console.log("1500ms timeout"); 
	if (-- counter == 0) final(); 
}, 1500);

setTimeout(function () { 
	console.log("1000ms timeout"); 
	if (-- counter == 0) final(); 
}, 1000);

function final() {
	setTimeout(function () { 
		console.log("final"); 
	}, 500);
}
console.log("end");

Результат:
begin
end
1000ms timeout
1500ms timeout
2000ms timeout
final

Все отлично!
Проблемы начнутся, когда однажды мы забудем добавить к счетчику единицу или добавить в функцию обратного вызова модификацию счетчика и вызов «ожидающей функции».
«Всю работу со счетчиком надо завернуть в какой-то объект!» — подумал я. Но, как выяснилось, не один я такой: Tim Caswell уже предложил нечто подобное, и именно его идею я взял за основу.
Обертка:
function Combo(finalCallback) {
	this.finalCallback = finalCallback;
	this.result = [];
	this.counter = 0;
}

Combo.prototype = {
	"add" : function (callback) {
		var that = this;
		this.counter ++;
		
		return function () {
			that.result[that.counter - 1] = callback.apply(this, arguments);
			that.check();
		};
	},
	
	"check" : function () {
		var that = this;
		this.counter --;
		if (this.counter == 0)
			process.nextTick(function () { 
				that.finalCallback.call(that, that.result); 
			});
	}
};

При создании объекта конструктор, в качестве параметра, получает «ожидающую функцию», которую обертка запустит по завершении всех «ожидаемых функций». Метод add увеличивает счетчик и создает для пользовательской «ожидаемой функции» функцию-обертку. Создаваемая функция-обертка запускает соответствующую «ожидаемую функцию», передавая ей все полученные параметры, а затем запускает метод check. Метод check, в свою очередь, уменьшает счетчик и, по достижении последним нуля, запускает «ожидающую функцию», переданную в конструкторе. При этом, на всякий случай, сохраняются результаты работы «ожидаемых функций», которые и передаются в качестве параметра «ожидающей функции».
Пример:
var test = new Combo(
		function (result) {
			console.log("final");
			console.log("result:");
			for (var i in result) {	
				console.log("  \"" + i + "\" : \"" + result[i] + "\"");	
			}
		});

console.log("begin");
setTimeout(test.add(function () {
	console.log("2000ms"); return "2000ms"; }), 2000);

setTimeout(test.add(function () { 
	console.log("1500ms"); return "1500ms"; }), 1500);

setTimeout(test.add(function () { 
	console.log("1000ms"); return "1000ms"; }), 1000);
	
console.log("end");

Результат работы:
begin
end
1000ms
1500ms
2000ms
final
result:
  "0" : "2000ms"
  "1" : "1500ms"
  "2" : "1000ms"

Красота!
Теперь мы можем подсовывать завершающим событиям (например, событие end для потоков) функцию-обертку, создаваемую методом add и наша «ожидающая функция» будет запущена только после того, как отработают все «ожидаемые функции».
Главное, не забывать оборачивать новые «ожидаемые функции».

Самое сложное


Но что делать, если в одном из действий произошел сбой? Для асинхронных функций, принимающих в качестве параметра функцию обратного вызова, проблем не будет: первым параметром фукции обратного вызова передается специальная переменная, содержащая информацию об ошибке, если таковая произошла. Но, например, в случае сбоя, потоки «выбрасывают» событие error. При этом, завершающее событие «end» выброшено не будет, а значит, не будет запущена и одна из «ожидаемых функций». Как следствие, «ожидающая функция» не будет запущена никогда.
Если можно завершить программу с выбросом исключения — очень хорошо. Но что делать, если такую ситуацию надо обработать и продолжать? Нужно добавить механизм удаления «ожидаемых функций»!
Например, так:
function Combo(finalCallback) {
	this.finalCallback = finalCallback;
	this.result = {};
	this.counter = 0;
}

Combo.prototype = {
	"add" : function (callback, id) {
		var that = this;
		if (!id) id = this.counter;
		this.counter ++;
		
		return function () {
			if (!that.result.hasOwnProperty(id))
			{
				that.result[id] = callback.apply(this, arguments);
				that.check();
			}
		};
	},
	
	"remove" : function (id, result) {
		this.result[id] = result;
		this.check();
	},
	
	"check" : function () {
		var that = this;
		this.counter --;
		if (this.counter == 0)
			process.nextTick(function () { 
				that.finalCallback.call(that, that.result); 
			});
	}
};

В качестве второго, необязательного, параметра, методу add передается идентификатор, по которому, в случае необходимости, можно удалить «ожидаемую функцию». И, кроме того, результат работы «ожидаемой функции» теперь сохраняется в поле с указанным идентификатором. Методу remove передаются идентификатор «ожидаемой функции» и «результат по ошибке», который будет сохранен в объекте-хранителе результатов.
Пример:
var test = new Combo(function (result) {
	console.log("final");
	console.log("result:");
	for (var i in result) {
		console.log(" \"" + i + "\" : \"" + result[i] + "\"");
	}
});

console.log("begin");
setTimeout(test.add(function () {
	console.log("2000ms"); return "2000ms"; }), 2000);

setTimeout(test.add(function () { 
	console.log("1500ms"); return "1500ms"; }, "1500ms"), 1500);

setTimeout(test.add(function () { 
	console.log("1000ms"); return "1000ms"; }), 1000);

// error
setTimeout(function () { 
	console.log("something wrong in 1500ms on 1250ms");
	test.remove("1500ms", "error in 1500ms");
}, 1250);

console.log("end");

Результат:
begin
end
1000ms
something wrong in 1500ms on 1250ms
2000ms
final
result:
 "0" : "2000ms"
 "2" : "1000ms"
 "1500ms" : "error in 1500ms"


Заключение


Конечно, обертку можно и нужно доработать, но для меня разработка этого объекта стала очень хорошей разминкой. Если все делать неспеша и вдумчиво, событийно-ориентированное программирование оказывается не таким уж и сложным.
Ну и, конечно, не стоит забывать о помощи сообщества: возможно, похожие на вашу задачи уже кто-нибудь решал.
Tags:
Hubs:
Total votes 29: ↑23 and ↓6+17
Comments7

Articles