Перейти к содержимому

Асинхронное программирование в Node.js: события, обработчики, генераторы

14/01/2010

Неблокирующие функции

Основная «фишка» node.js, как известно — большинство функций в ней неблокирующие. Что это значит для программиста?

Операции в программе отнимают разное время в зависимости от того к чему мы обращаемся. Операции с регистрами — самые быстрые, потом идут операции с кэшами первого и второго уровней (1 и 5 наносекунд соответственно), операции с RAM (~80-90 нс), операции с жёстким диском (~14 микросекунд). Чтобы понять масштаб, можно взглянуть вот на этот gif. Огромная колонна — обращение к жёсткому диску. Если сделать zoom in, будут видны операции с памятью и кэшами. Обращение к сетевому серверу ещё дольше — когда придёт ответ, вообще непонятно.

Философия неблоокирующего ввода-вывода состоит в том что функции вообще не должны ждать окончания длительных операций ввода-вывода (диск и сеть). Для этого функция либо принимает функцию, которую надо выполнить по завершении операции (callback), либо возвращает объект — генератор событий, на который опять же вешаются функции. Пример:

// Первый способ: оставление обработчика
server.query('Query text', function(err, data) {
    // Если не передано исключение, сделать что нибудь с данными
});

// Второй способ: подключение обработчиков к генератору событий
var query_status = server.query('Query text');
query_status.addListener('success', function(data){
    // Сделать что нибудь с данными
});

query_status.addListener('error', function(error){
    // Если пришла ошибка, тоже что нибудь сделать
});

Генераторы событий дают больше возможностей — они позволяют вешать разные функции на разные события. Callback-стиль, в свою очередь, выглядит более компактно, его удобнее использовать для простых вызовов. Ранее в Node был ещё класс promise — особый случай эмиттера, у которого есть только три события: success, error, cancel. Promises давно выброшены из ядра и не применяются, поэтому я здесь их рассматривать не буду 🙂

В Node v0.2.0 вместо метода addListener рекомендуется использовать короткий синоним on():

var sys = require("sys");

var connection = my_connector(1445, 'hello'); // Получили эмиттер

connection.on('success', function(data) {
    sys.puts('Data received: ' + data); // Обработчик успешного завершения
});

connection.on('error', function(error) {
    sys.puts('Error happened: ' + error); // Обработчик ошибок
});

Более длинный addListener скорее всего будет полностью исключен в следующих версиях Node. Поэтому далее я везде буду использовать on().

В некоторых старых модулях ещё можно найти использование promises, которые сейчас вынесены в отдельный сторонний модуль.

Пишем асинхронный код

Если мы хотим написать асинхронную функцию, нам надо как можно быстрее отдать управление в основной код, вернув какое-либо значение. Например, мы пишем библиотеку для обращения к какому то сервису:

var net = require("net");

var my_connector = function(port, data, callback) {
    var server_conn = tcp.createConnection(port);
    server_conn.on('connect', function () {
        // Подключились, получили объект потока
        server_conn.write(data, 'ascii');
        //Отправили данные, ждём ответа
        server_conn.on('data', function(answer) {
            callback(false, answer);
            // Закрыли поток после получения данных
            server_conn.end();
        }
    }
    return true;
}

Здесь возврат произойдёт сразу после установки первого внутреннего callback’а — того, который ждёт подключения. Скрипт будет выполняться дальше, а переданная нами функция будет терпеливо дожидаться конца операции. Этот подход работает, пока нам нужно обработать только одно событие — завершение операции. Чтобы хотя бы сигнализировать о том была ли операция успешной, нам придётся передавать статус в обработчик, вроде этого:

var net = require("net");

var my_connector = function(port, data, callback) {
    var server_conn = net.createConnection(port);
    server_conn.on('data', function(answer) {
        callback(null, answer); // Первый параметр - null, признак успешной операции
    }

    // Соединение закрыто
    server_conn.on('end', function(haserror) {
        if (haserror) {
            callback(new Error('Connection error'), null); // Первый параметр - объект ошибки при соединении
        } else {
            callback(new Error('Connection closed'), null); // Первый параметр - объект ошибки закрытия соединения
        }
    }
    return true;
}

Это не очень красиво выглядит, но будет работать, пока не понадобится обрабатывать пять-шесть различных ситуаций. После этого код обработки разрастётся до неприличия.

В Node принято передавать обработчику первым параметром объект ошибки (либо null если операция произошла успешно), либо возвращать объект генератора событий.

Событие генерируется методом event.emit('eventname', param1, param2...);, обработчики вешаются методом addListener() с указанием названия события:

var sys = require("sys");

var connection = my_connector(1445, 'hello'); // Получили эмиттер

connection.on('success', function(data) {
    sys.puts('Data received: ' + data); // Обработчик успешного завершения
});

connection.on('error', function(error) {
    sys.puts('Error happened: ' + error); // Обработчик ошибок
});

Управление обработчиками

Кроме назначения обработчиков бывает нужно снять некоторые из них или просто узнать какие обработчики сейчас действуют (потому что node-скрипт не завершится, если есть хоть один обработчик на действующем генераторе событий).

Получить список обработчиков для события можно с помощью метода listeners:

server.on('stream', function (stream) {
  console.log('someone connected!');
});
console.log(sys.inspect(server.listeners('stream'));

Методу передаётся идентификатор события, он возвращает массив функций. Чтобы снять один из обработчиков, надо передать функцию вместе с строкой события методу removeListener:

var callback = function(stream) {
  console.log('someone connected!');
};
server.on('connection', callback);
// ...
server.removeListener('connection', callback);

Снять все обработчики с события можно методом removeAllListeners:

var callback = function(stream) {
  console.log('someone connected!');
};
server.on('stream', callback);
// ...
server.removeAllListeners('stream');

Иногда требуется снять текущий обработчик и повесить вместо него новый. Например, мы ждём от TCP-сервера приветствие, и обработчик приветствия навешивает обработчик реальных данных ответа. В таких случаях удобнее снять обработчик через this, а не лезть за ним в массив возвращаемый listeners:

stream.on('data', function(data) {
    if (data == "hello") {
        // Снять текущий обработчик с события
        stream.removeListener('data', this);
        // Заменить его другим
        stream.on('data', function(data) {
            sys.puts('Data: ' + data);
        });
    } else {
        sys.puts('Wrong header received');
    }
});
9 комментариев
  1. Kolyaj permalink

    Первый пример из «Пишем асинхронный код» нерабочий же. Функция вернёт управление сразу, а полученный ответ затеряется в небытие. Без костылей синхронный I/O в Node.js не сделать (и слава богу 🙂 ). Костыль в данном случае promise.wait().

    • Спасибо, Вы правы. Я почему то подумал что изначально реализовал так запросы в limestone, но когда проверил, оказалось что там были просто вложенные sys.puts();. Поправил текст статьи. Кстати, про promise.wait() тоже надо будет что нибудь добавить.

  2. Это офигительная статья! Сам хотел давеча написать про асинхронное программирование в nodejs, но так как у вас, у меня бы не получилось написать 🙂

    Именно эти фишки, как асинхронность и событийно-ориентированнось, вот что мне нравится в nodejs. при использовании асинхронности код выглядит классно: почти не используется ветвление (if, else) — по-моему, это очень классно!

    • Да, в каком то плане асинхронность помогает лучше изложить структуру программы. Обработка результатов событий в отдельных listeners тоже идёт только на пользу.

      Вы тоже пишите, не стесняйтесь 🙂 Я мог что то забыть, где то перепутать. В конечном итоге чем больше русскоязычных статей о Node, тем лучше.

      • Статья очень хороша! Единственный не раскрытый момент — это тот факт, что ещё не дождавшись данных мы продолжаем выолнять дальше, а если наткнулись на конец то не ждём получения данных, а обслуживаем следующего пользователя, на оффициальном сайте есть очень хороший пример с setTimeout, аналогичный код на питоне не принимал бы следующего пользователья пока не выполнится задержка,а ноде же принимает.

        • Да, мы продолжаем не ожидая вывода. В этом суть неблокирующего программирования — выполнение программы не блокируется ожиданием окончания длительных операций.

          Насчёт питона не знаю, я не настолько в нём разбираюсь. Разве например в Tornado нельзя оставить коллбек на таймаут?

  3. True permalink

    > В таких случаях удобнее снять обработчик через this, а не лезть за ним в массив возвращаемый listeners:
    > stream.on(‘data’, function(data) {
    >…
    >stream.removeListener(‘data’, this);
    >…
    В этом случае this будет указывать на stream, а не на функцию-обработчик.
    Пруф: https://nodejs.org/api/events.html#events_passing_arguments_and_this_to_listeners

    • True permalink

      В этом примере лучше использовать NFE:
      https://learn.javascript.ru/named-function-expression#functions-nfe

      > stream.on(‘data’, function thisF(data) {
      >…
      >stream.removeListener(‘data’, thisF);
      >…

      И более лучший вариант:
      > stream.on(‘data’, function thisF(data) {
      >…
      >this.removeListener(‘data’, thisF);
      >…
      …для защиты от возможного «stream=null;»

Trackbacks & Pingbacks

  1. Асинхронное программирование в Node.js: события, коллбеки, promises « nodeJS

Ответить на kuroikaze85 Отменить ответ