Pull to refresh

Еще раз про обещания

Reading time 13 min
Views 35K

Про обещания (promises) уже много написано. Эта статья — просто попытка собрать наиболее необходимые на практике приемы использования обещаний с достаточно подробными пояснениями того, как это работает.


Общие сведения об обещаниях


Сначала несколько определений.


Обещания (promises) — это объекты, позволяющие упорядочить выполнение асинхронных вызовов.


Асинхронный вызов — это вызов функции, при котором выполнение основного потока кода не дожидается завершения вызова. Например, выполнение http-запроса не прерывает выполнение основного потока. То есть выполняется запрос, и сразу, не дожидаясь его завершения, выполняется код следующий за этим вызовом, а результат http-запроса обрабатывается после его завершения функцией обратного вызова (callback-функцией).


Далее будем последовательно разбираться с функционированием обещаний. Пока будем исходить из того, что у нас уже есть объект-обещание выполнить некий асинхронный вызов. О том, откуда берутся обещания и, как их сформировать самому, поговорим чуть позже.



1. Обещания предоставляют механизм, позволяющий управлять последовательностью асинхронных вызовов. Иными словами, обещание — это просто обертка над асинхронным вызовом. Обещание может разрешиться успешно или завершиться с ошибкой. Суть механизма в следующем. Каждое обещание предоставляет две функции: then() и catch(). В качестве аргумента в каждую из функций передается функция обратного вызова. Callback-функция в then вызывается в случае успешного разрешения обещания, а в catch — в случае ошибки:


promise.then(function(result){
  // Вызывается после успешного разрешения обещания
}).catch(function(error){
  // Вызывается в случае ошибки
});

2. Следующий момент, который нужно уяснить. Обещание разрешается только один раз. Иными словами асинхронный вызов, который происходит внутри обещания выполняется только один раз. В дальнейшем обещание просто возвращает сохраненный результат асинхронного вызова, больше его не выполняя. Например, если мы имеем некоторое обещание promise, то:


// Первое разрешение обещания
promise.then(function(result){
  // При разрешении обещания происходит асинхронный вызов,
  // результат которого передается в эту функцию
});
// Повторное разрешение того же обещания
promise.then(function(result){
  // Теперь уже асинхронный вызов не выполняется.
  // В эту функцию передается сохраненный результат
  // разрешения обещания
});

3. Функции then() и catch() возвращают обещания. Таким образом, можно выстраивать цепочки обещаний. При этом результат, который возвращает callback-функция в then(), передается в качестве аргумента на вход callback-функции последующего then(). А аргумент вызова throw(message) передается в качестве аргумента callback-функции в catch():


promise.then(function(result){
  // Обработка разрешения обещания (код пропущен)
  return result1; // Возвращаем результат обработки обещания
}).then(function(result){
  // Аргумент result в этой функции имеет значение result1,
  // переданный из предыдущего вызова
  if(condition){
    throw("Error message");
  }
}).catch(function(error){
  // Если в предыдущем вызове условие выполнится,
  // то сработает исключение, управление будет передано в эту
  // функцию и переменная error будет иметь значение "Error message"
});

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


promise1.then(function(result1){
  return promise2.then(function(result2){
    // Какие-то действия
    return result3;
  });
}).then(function(result){
  // Значение аргумента result в этой функции будет равно result3
}).catch(function(error){
  // Обработка ошибок
});

5. Можно то же самое сделать, выстроив разрешение обещаний в одну линейную цепочку без вложений:


promise1.then(function(result1){
  return promise2;
}).then(function(result2){
  // Какие-то действия
  return result3;
}).then(function(result){
  // Значение аргумента result в этой функции будет равно result3
}).catch(function(error){
  // Обработка ошибок
});

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


Подводим итог


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


Откуда берутся обещания


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


Обещания библиотечных функций


Многие современные библиотеки поддерживают работу с обещаниями. О том, поддерживает библиотека обещания или нет, можно узнать из документации. Некоторые библиотеки поддерживают оба варианта работы с асинхронными вызовами: функции обратного вызова и обещания. Рассмотрим несколько примеров из жизни.


Библиотека работы с базами данных Waterline ORM


В документации приводится такой пример работы с функциями библиотеки (на примере выборки данных):


Model.find({id: [1,2,3]}).exec(function(error, data){
  if(error){
    // Обработка ошибки
  }
  else{
    // Работа с данными, переданными в data
  }
});

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


Model.find({id: [1,2,3]}).exec(function(error, data){
  if(error){
    // Обработка ошибки
  }
  else{
    // Работа с данными, переданными в data
    Model_1.update(...).exec(function(error, data){
      if(error){/* Обработка ошибки */}
      else{
        // Обработка результата
        Model_2.insert(...).exec(function(error, data){
          // и т.д.
        });
      }
    });
  }
});

Даже при отсутствии основного кода в приведенном примере уже понимать написанное очень трудно. А если запросы к базе данных нужно еще выполнить в цикле, то при использовании функций обратного вызова задача вообще становится неразрешимой.


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


Model.find(...).then(function(data){
  // Работа с данными, переданными в data
  return Model_1.update(...);
}).then(function(data){
  // Обработка результата
  return Model_2.insert(...);
}).then(function(data){
  // и т. д.
}).catch(function(error){
  // Обработка ошибок делается в одном месте
});

Думаю, что нет необходимости говорить, какое решение лучше. Особенно приятно то, что обработка всех ошибок теперь делается в одном месте. Хотя нельзя сказать, что это всегда хорошо. Иногда бывает так, что из контекста ошибки не ясно в каком именно блоке она произошла, соответственно отладка усложняется. В этом случае никто не запрещает вставлять вызов catch() в каждое обещание:


Model.find(...).then(function(data){
  // Работа с данными, переданными в data
  return Model_1.update(...).catch(function(error){...});
}).then(function(data){
  // Обработка результата
  return Model_2.insert(...).catch(function(error){...});
}).then(function(data){
  // и т. д.
}).catch(function(error){
  ...
});

Даже в этом случае код более понятный, чем в случае использования функций обратного вызова.


Библиотека для работы с MongoDB (базовая библиотека для node.js)


Как и в предыдущем случае, примеры из документации используют функции обратного вызова:


MongoClient.connect(url, function(err, db) {
  assert.equal(null, err);
  console.log("Connected succesfully to server");

  db.close();
});

Мы уже увидели на предыдущем примере, что использование обратных вызовов сильно усложняет жизнь в том случае, когда нужно сделать много последовательных асинхронных вызовов. К счастью, при внимательном изучении документации, можно узнать, что, если в функции этой библиотеки не передавать в качестве аргумента callback-функцию, то она вернёт обещание. А значит, предыдущий пример можно записать так:


MongoClient.connect(url).then(function(db){
  console.log("Connected succesfully to server");
  db.close();
}).catch(function(err){
  // Обработка ошибки
});

Особенность работы с данной библиотекой является то, что для выполнения любого запроса к базе используется объект-дескриптор базы данных db, возвращаемый в результате асинхронного вызова connect(). То есть, если нам в процессе работы необходимо выполнить множество запросов к базе данных, то возникает необходимость каждый раз получать этот дескриптор. И здесь использование обещаний красиво решает эту проблему. Для этого нужно просто сохранить обещание подключения к базе в переменной:


var dbConnect = MongoClient.connect(url); // Сохраняем обещание
// Теперь можно выполнить цепочку запросов к базе
dbConnect.then(function(db){
  return db.collection('collection_name').find(...);
}).then(function(data){
  // Обработка результатов выборки данных
  // Здесь дескриптор db уже не доступен, поэтому
  // для следующего запроса делаем так
  return dbConnect.then(function(db){
    return db.collection('collection_name').update(...);
  });
}).then(function(result){
  // Здесь обрабатываем результаты вызова update
  // и т.д.
  ...
}).catch(function(error){
  // Обработка ошибок
});
// Можно не дожидаясь завершения работы предыдущей цепочки
// выполнять другие запросы к базе данных, если конечно это
// допустимо по логике работы программы
dbConnect.then(function(db){
  ...
}).catch(function(error){
  ...
});

Что хорошо при такой организации кода, так это то, что соединение с базой данных устанавливается один раз (напоминаю, что обещание разрешается только один раз, далее просто возвращается сохраненный результат). Однако в приведенном выше примере есть одна проблема. После завершения всех запросов к базе нам нужно закрыть соединение с базой. Закрытие соединения с базой можно было бы вставить в конце цепочки обещаний. Однако мы не знаем, какая из двух запущенных нами цепочек завершится раньше, а значит, не понятно, в конец какой из двух цепочек вставлять закрытие соединения с базой. Решить эту проблему помогает вызов Promise.all(). О нем поговорим чуть позже.


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


А сейчас перейдем к рассмотрению вопроса о том, как самостоятельно создавать обещания, если библиотека их не поддерживает.


Создание обещаний (промисификация)


Часто бывает и так, что библиотека не поддерживает обещания. И предлагает использовать только обратные вызовы. В случае небольшого скрипта и простой логики можно довольствоваться и этим. Но если логика сложна, то без обещаний не обойтись. А значит, нужно уметь оборачивать асинхронные вызовы в обещания.


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


var redis = require('redis').createClient();

redis.get('attr_name', function(error, reply){
  if(error){
    //Обработка ошибки
  }
  else{
    // Обработка результата
  }
});

Теперь обернем эту функцию в обещание. JavaScript предоставляет для этого объект Promise. То есть, для создания нового обещания нужно сделать так:


new Promise(function(resolve, reject){
  // Тут происходит асинхронный вызов
});

В качестве аргумента конструктору Promise передается функция, внутри которой и происходит асинхронный вызов. Аргументами этой функции являются функции resolve и reject. Функция resolve должна быть вызвана в случае успешного завершения асинхронного вызова. На вход функции resolve передается результат асинхронного вызова. Функция reject должна вызываться в случае ошибки. На вход функции reject передается ошибка.


Соединив все вместе получим функцию get(attr), которая возвращает обещание получить параметр из хранилища redis:


var redis = require('redis').createClient();

function get(attr){
  return new Promise(function(resolve, reject){
    redis.get(attr, function(error, reply){
      if(error){
        reject(error);
      }
      else{
        resolve(reply);
      }
    });
  });
}

Вот и все. Теперь можно спокойно пользоваться нашей функцией get() привычным для обещаний образом:


get('attr_name').then(function(reply){
  // Обработка результата запроса
}).catch(function(error){
  // Обработка ошибки
});

Зачем все эти мучения


У любого здравомыслящего человека этот вопрос обязательно появится. Ведь чем мы сейчас занимались? Мы занимались разъяснением того, как последовательно выполнять (асинхронные) вызовы функций. В любом "нормальном" (не работающим с асинхронными вызовами) языке программирования это делается просто последовательной записью инструкций. Не нужны никакие обещания. А тут столько мучений ради того, чтобы сделать простую последовательность вызовов. Может асинхронные вызовы вообще не нужны? Ведь из-за них столько проблем!


Но нет худа без добра. При наличии асинхронных вызовов мы можем выбирать способ выполнения вызовов: последовательно или параллельно. То есть если одно действие требует для своего выполнения наличия результата другого действия, то мы используем вызовы then() в обещаниях. Если же выполнение нескольких действий не зависит друг от друга, то мы можем запустить эти действия на параллельное выполнение.


Как запустить параллельное выполнение нескольких независимых действий? Для этого используется вызов:


Promise.all([
  promise_1,
  promise_2,
  ...,
  promise_n
]).then(function(results){
  // Какие-то действия после завершения
});

То есть, на вход функции Promise.all передается массив обещаний. Все обещания в этом случае запускаются на выполнение сразу. Функция Promise.all возвращает обещание выполнить все обещания, поэтому вызов then() в этом случае срабатывает после выполнения всех обещаний массива. Параметр results, который передается на вход функции в then(), является массивом результатов работы обещаний в той последовательности, в которой они передавались на вход Promise.all.


Конечно можно обещания и не оборачивать в Promise.all, а просто последовательно запустить на выполнение. Однако, в этом случае мы теряем контроль над моментом завершения выполнения всех обещаний.


Выполнение обещаний в цикле


Часто возникает задача последовательного выполнения обещаний в цикле. Здесь мы будем рассматривать именно последовательное выполнение обещаний, так как, если обещания допускают параллельное выполнение, то нет ничего проще составить в цикле (или с помощью функций map, filter и т.п.) массив обещаний и передать его на вход Promise.all.


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


Перейдем к рассмотрению самих механизмов цикличного построения цепочек обещаний. Будем отталкиваться от привычных всем программистам разновидностей циклов: цикл for и цикл forEach.


Выстраивание цепочки обещаний в цикле for


Предположим, что некая функция doSomething() возвращает обещание выполнить какое-то асинхронное действие, и нам надо выполнить это действие последовательно n раз. Пусть само обещание возвращает некий строковый результат result, который используется при следующем асинхронном вызове. Также допустим, что первый вызов делается с пустой строкой. В этом случае выстраивание цепочки обещаний делается так:


// Устанавливаем начальное значение цепочки обещаний
var actionsChain = Promise.resolve("");
// В цикле составляем цепочку обещаний
for(var i=0; i<n; i++){
  actionsChain = actionsChain.then(function(result){
    return doSomething(result);
  });
}
// Определяем, что будем делать после завершения
// цепочки асинхронных действий и обработчик ошибок
actionsChain.then(function(result){
  // Код, который выполняется после завершения цепочки действий
}).catch(function(error){
  // Обработка ошибок
});

В этом примере-шаблоне пояснения требует только первая строка. Остальное должно быть понятно из рассмотренного выше материала. Функция Promise.resolve() возвращает успешно разрешенное обещание результатом которого является аргумент этой функции. То есть Promise.resolve("") равносильно следующему:


new Promise(function(resolve, reject){
  resolve("");
});

Выстраивание цепочки обещаний в цикле forEach


Предположим, нам нужно выполнить какое-либо асинхронное действие для каждого элемента массива array. Пусть это асинхронное действие обернуто в некую функцию doSomething(), которая возвращает обещание выполнить это асинхронное действие. Шаблон того, как в этом случае выстроить цепочку обещаний, как это ни странно, основан не на использовании функции forEach, а на использовании функции reduce.


Для начала рассмотрим, как работает функция reduce. Эта функция предназначена для обработки элементов массива с сохранением промежуточного результата. Таким образом, в итоге мы можем получить некий интегральный результат для данного массива. Например, с помощью функции reduce можно вычислить сумму элементов массива:


var sum = array.reduce(function(sum, current){
  return sum+current;
}, 0);

Аргументами функции reduce являются:


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

Начальное значение, которое передается в качестве результата предыдущего действия при обработке первого элемента массива.


Теперь посмотрим, как использовать функцию reduce для построения цепочки обещаний по поставленной задаче:


array.reduce(function(actionsChain, value){
  return actionsChain.then(function(){
    return doSomething(value);
  });
}, Promise.resolve());

В этом примере в качестве результата предыдущего действия выступает обещание actionsChain, после разрешения которого создается новое обещание выполнить действие doSomething, которое в свою очередь возвращается в качестве результата. Так выстраивается цепочка для каждого элемента массива. Promise.resolve() используется в качестве начального обещания actionsChain.


Более сложный пример использования обещаний


В качестве более сложного примера применения обещаний рассмотрим фрагмент кода сервиса генерации уникальных идентификаторов. Суть работы сервиса очень проста: на каждый запрос GET к сервису он вычисляет и возвращает очередной уникальный идентификатор. Однако, учитывая то, что одновременно к сервису может быть направлено несколько конкурирующих запросов, возникает задача выстраивания этих запросов в очередь (цепочку) обещаний для последующего выполнения.


Код самого сервера выглядит так:


// Создаем HTTP-сервер
var server = http.createServer(processRequest);
// Перед остановкой сервера закрываем соединение с redis
server.on('close', function(){
  storage.quit();
});
// Запускаем сервер
server.listen(config.port, config.host);
console.log('Service is listening '+config.host+':'+config.port);

Это стандартный код http-сервера, написанного на node.js. Функция processRequest — обработчик http-запроса. Сам сервис использует для хранения своего состояния хранилище redis. Механизм сохранения состояния в redis не имеет значения для понимания данного примера, поэтому рассматриваться не будет. Но при завершении работы сервера нам необходимо закрыть соединение с redis, что и отражено в коде выше. Важно только следующее: при каждом запросе вызывается функция processRequest. Очередь запросов представляет собой обещание requestQueue. Рассмотрим теперь сам код:


var requestQueue = Promise.resolve();

/**
 * Функция обработки запроса
 * Запросы выстраиваются в цепочку обещаний
 * @param  {Object} req Запрос
 * @param  {Object} res Ответ
 */
function processRequest(req, res){
  requestQueue = requestQueue
  .then(function(){
    // Если GET-запрос, то...
    if(req.method == 'GET'){
      // вычисляем UUID и
      return calcUUID()
      // возвращаем его значение клиенту
      .then(function(uuid){
        res.writeHead(200, {
          'Content-Type': 'text/plain',
          'Cache-Control': 'no-cache'
        });
        res.end(uuid);
      })
      // Обрабатываем ошибки
      .catch(function(error){
        console.log(error);
        res.writeHead(500, {
          'Content-Type': 'text/plain',
          'Cache-Control': 'no-cache'
        });
        res.end(error);
      });
    }
    // Если не GET-запрос, то возвращаем Bad Request
    else{
      res.statusCode = 400;
      res.statusMessage = 'Bad Request';
      res.end();
    }    
  });
}

Для вычисления самого идентификатора используется функция calcUUID(), которая возвращает обещание вычислить уникальный идентификатор. Код этой функции рассматривать не будем, так как для понимания этого примера он не нужен. При получении запроса к очереди обработки добавляется обещание вычислить очередной идентификатор. Таким образом, несколько конкурирующих запросов выстраиваются в одну цепочку, где вычисление каждого следующего идентификатора начинается после вычисления предыдущего.


Заключение


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


Надеюсь, что все было понятно. И каждый нашел в этой статье что-то полезное для себя.

Tags:
Hubs:
+16
Comments 28
Comments Comments 28

Articles