Bluebird: пояс с инструментами для асинхронщика

в 11:38, , рубрики: async, bluebird, javascript, node.js, promises, Программирование

Асинхронность. Асинхронность никогда не меняется. Node.js использовал асинхронность, чтобы получить большой rps для io-операций. TC39 добавила промисы в спецификацию для борьбы с адом колбеков. Наконец, мы стандартизировали async/await. Но асинхронность никогда не меняется. Погодите, что это синеет в небе? Похоже bluebird несёт в клюве пояс с инструментами для тех из нас, кто плотно подсел на thenable-объекты и всю эту асинхронную лапшу.

Bluebird: пояс с инструментами для асинхронщика - 1

Если кто незнаком, bluebird это библиотека, реализующая функционал промисов для javascript. Если в клиентскую сборку вы её вряд ли потащите, как никак 21Kb gzipped, то не использовать её на стороне сервера вы просто не имеете морального права. Bluebird всё ещё работает быстрее нативной реализации. Можете не верить на слово, а скачать репозиторий и запустить бенчмарки на последней версии Node.js (9.x.x). Подробней о преимуществах можно прочитать в кратком обзоре архитектурных принципов библиотеки.

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

Начнем с достаточно легкого и известного, следящими за новыми фичами в ECMAScript, а именно  —  finally. Точно такой же метод теперь является частью спецификации (вошел в релиз ES2018). Позволяет зарегистрировать обработчик, срабатывающий в независимости от итогового состояния промиса (fullfiled, rejected).

// - 1 -
// after fullfill -> always
Promise.resolve(42)
  .then(() => console.log('after fullfill'))
  .catch(() => console.log('after reject'))
  .finally(() => console.log('always'));

// - 2 -
// after reject -> always
Promise.reject(42)
  .then(() => console.log('after fullfill'))
  .catch(() => console.log('after reject'))
  .finally(() => console.log('always'));

Этот метод, как и старые добрые then и catch, возвращает новый промис, на который можно подписаться. Важно, что в случае перехода в состояние rejected, обработчик в finally не считается успешной обработкой ошибки, поэтому она продолжит распространение до первого обработчика catch.

// - 1 -
// after fullfill -> always -> a bit later
Promise.resolve(42)
  .then(() => console.log('after fullfill'))
  .finally(() => console.log('always'))
  .then(() => console.log('a bit later'));

// - 2 -
// after reject -> always -> a bit later
Promise.reject(42)
  .catch(() => console.log('after reject'))
  .finally(() => console.log('always'))
  .then(() => console.log('a bit later'));

// - 3 -
// always -> after reject
Promise.reject(42)
  .then(() => console.log('after fullfill'))
  .finally(() => console.log('always'))
  .then(() => console.log('never'))
  .catch(() => console.log('after reject'));

И, конечно же, из обработчика finally можно вернуть промис. Оставшаяся цепочка дождётся его завершения вызовом последующих обработчиков.

// always -> after 1s
Promise.resolve(42)
  .finally(() => {
    console.log('always');

    return delay(1000);
  })
  .then(() => console.log('after 1s'));

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Двигаемся дальше. Ребята прокачали метод catch  —  с ним можно легко фильтровать возникающие ошибки, которые мы хотим обрабатывать:

class DeannonizationError extends Error {}
class BigBrotherWatchingYouError extends Error {}

// - 1 -
// better run
Promise.reject(new DeannonizationError())
  .catch(DeannonizationError, () => console.log('better run'))
  .catch(BigBrotherWatchingYouError, () => console.log('too late'));

// - 2 -
// too late
Promise.reject(new BigBrotherWatchingYouError())
  .catch(DeannonizationError, () => console.log('better run'))
  .catch(BigBrotherWatchingYouError, () => console.log('too late'));

// - 3 -
// oh no
Promise.reject(new BigBrotherWatchingYouError())
  .catch(DeannonizationError, BigBrotherWatchingYouError, () => console.log('oh no'));

Это стимулирует к написанию обработки ошибок в более атомарном стиле с хорошим потенциалом к переиспользованию кода. Также, помимо прототипа, можно использовать функцию предикат:

// predicate
Promise.reject({ code: 42 })
  .catch(error => error.code === 42, () => console.log('error 42'));

// shorthand for checking properties
Promise.reject({ code: 42 })
  .catch({ code: 42 }, () => console.log('error 42'));

Один из замечательнейших методов библиотеки и крайне странно, что его нет в стандарте  —  any.

// 42
Promise.any([
  Promise.reject(40),  // error
  Promise.reject(41),  // error
  Promise.resolve(42), // success
]).then(x => console.log(x));

Позволяет дождаться выполнения хотя бы одного промиса из переданного массива. Если более подробно, то промис, созданный методом any, перейдёт в состояние fullfiled, когда любой из промисов перейдет в это состояние. Обработчик в then получит значение из этого разрешенного промиса:

// 500
Promise.any([
  delay(1000),
  delay(500),
  delay(700),
]).then(x => console.log(x));

function delay(ms) {
  return new Promise(resolve => setTimeout(() => resolve(ms), ms));
}

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

// - 1 -
// 40 -> 41 -> 42
Promise.any([
  Promise.reject(40),
  Promise.reject(41),
  Promise.reject(42),
]).catch(error => error.forEach(x => console.log(x)));

// - 2 -
// 500 -> 700 -> 1000
Promise.any([
  delayAndReject(1000),
  delayAndReject(500),
  delayAndReject(700),
]).catch(error => error.forEach(x => console.log(x)));

function delayAndReject(ms) {
  return new Promise((resolve, reject) => setTimeout(() => reject(ms), ms));
}

По сути, метод any является специальной версией метода some с параметром count равным 1. Таким образом, через some мы можем явно задать условия для перехода агрегирующего промиса в состояние fulfilled:

// [40, 41]
Promise.some([
  Promise.resolve(40),
  Promise.resolve(41),
  Promise.reject(42),
], 2).then(x => console.log(x));

Если у вас часто возникает необходимость параллельно запустить асинхронную операцию для каждого элемента массива и потом дождаться всех результатов, то вам знаком этот код:

// [1, 2, 3]
const promises = [1, 2, 3].map(x => Promise.resolve(x));

Promise.all(promises)
  .then(x => console.log(x));

Синяя птица предоставляет нам шорткат для этого:

Promise.map([1, 2, 3], x => Promise.resolve(x))
  .then(x => console.log(x));

Единственное, на что следует обратить внимание : для функции, передаваемой в качестве маппера, третьим параметром вместо массива нам придёт его длина. Также у метода map есть объект настроек, передаваемых после маппера. На данный момент опция только одна  —  concurrency — контроллирующая, сколько промисов могут быть запущены параллельно:

// start of 1000ms timer
// start of 2000ms timer
// end of 1000ms timer
// start of 3000ms timer
// end of 2000ms timer
// end of 3000ms timer
// after 4000ms
Promise.map([1000, 2000, 3000], x => delay(x), { concurrency: 2 })
  .then(x => console.log('after 4000ms'));

function delay(ms) {
  console.log(`start of ${ms}ms timer`);

  return new Promise(resolve => setTimeout(() => {
    console.log(`end of ${ms}ms timer`);
    resolve();
  }, ms));
}

А что будет, если задать concurrency равным 1? Верно, промисы будут выполняться последовательно. Для этого тоже есть шорткат:

// start of 1000ms timer
// end of 1000ms timer
// start of 2000ms timer
// start of 3000ms timer
// end of 2000ms timer
// end of 3000ms timer
// after 6000ms
Promise.mapSeries([1000, 2000, 3000], x => delay(x))
  .then(x => console.log('after 6000ms'));

function delay(ms) {
  console.log(`start of ${ms}ms timer`);

  return new Promise(resolve => setTimeout(() => {
    console.log(`end of ${ms}ms timer`);
    resolve();
  }, ms));
}

Часто возникает ситуация, когда нужно передать какие-то промежуточные данные между обработчиками промиса в рамках цепочки. Можно использовать Promise.all и деструктуризацию для этих целей. Другим вариантом будет использование общего контекста, привязанного к обработчикам в then и catch с помощью метода bind:

// {x: 42, y: 43}
Promise.resolve(42)
  .bind({})
  .then(function (x) {
    this.x = x;
    return Promise.resolve(43);
  })
  .then(function (y) {
    this.y = y;
  })
  .then(function () {
    console.log(this)
  });

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

Promise.method(semiAsyncFunction)()
  .then(x => console.log('I handle both sync and async results', x)); 

function semiAsyncFunction() {
  if (Math.random() > 0.5) {
    return 420;
  }

  return delay(42);
}

function delay(ms) {
  return new Promise(resolve => setTimeout(() => resolve(ms), ms));
}

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

// log 42
// process 42
Promise.resolve(42)
  .tap(x => console.log(`log ${x}`))
  .then(x => console.log(`process ${x}`));

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

// start logging
// log 42
// process 42
Promise.resolve(42)
  .tap(x => asyncLogging(x))
  .then(x => console.log(`process ${x}`));

function asyncLogging(x) {
  console.log('start logging');

  return new Promise(resolve => setTimeout(() => {
    console.log(`log ${x}`);
    resolve();
  }, 1000));
}

В наличии также версия метода для ошибок:

// log error 42
// process error 42
Promise.reject(42)
  .tapCatch(x => console.log(`log error ${x}`))
  .catch(x => console.log(`process error ${x}`));

Также, как и с catch, можно сделать фильтрацию:

class DeannonizationError extends Error {}
class BigBrotherWatchingYouError extends Error {}

// log deannonimization
// process deannonimization
Promise.reject(new DeannonizationError())
  .tapCatch(DeannonizationError, x => console.log('log deannonimization'))
  .tapCatch(BigBrotherWatchingYouError, x => console.log('log bbwy'))
  .catch(DeannonizationError, () => console.log('process deannonimization'))
  .catch(BigBrotherWatchingYouError, () => console.log('process bbwy'));

// log bbwy
// process bbwy
Promise.reject(new BigBrotherWatchingYouError())
  .tapCatch(DeannonizationError, x => console.log('log deannonimization'))
  .tapCatch(BigBrotherWatchingYouError, x => console.log('log bbwy'))
  .catch(DeannonizationError, () => console.log('process deannonimization'))
  .catch(BigBrotherWatchingYouError, () => console.log('process bbwy'));

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

Promise.config({ cancellation: true });

const promise = delay(1000)
  .then(() => console.log('We will never see this'));

promise.cancel();

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

Некоторые асинхронные операции можно отменять. Bluebird при создании промиса предоставит вам специальный метод для регистрации колбека, вызываемого при отмене:

Promise.config({ cancellation: true });

const promise = delay(1000)
  .then(() => console.log('We will never see this'));

promise.cancel();

function delay(ms) {
  return new Promise((resolve, reject, onCancel) => {
    const timer = setTimeout(() =>  {
       console.log('and this one too');
       resolve();
    }, ms);

    onCancel(() => clearTimeout(timer));
  });
}

Полезным бывает задать временные ограничения для операции. Тогда в нашем распоряжении метод timeout, который отклонит промис с ошибкой TimeoutError в случае истечения времени:

// Time's up!
delay(1000)
  .timeout(100)
  .then(() => console.log(`We will never see this`))
  .catch(Promise.TimeoutError, error => console.log(`Time's up!`))

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

И, напоследок, для ментальной разгрузки. Если в силу непреодолимых обстоятельств необходимо отложить запуск асинхронной операции, то в этом поможет метод delay:

Promise.delay(1000)
  .then(() => console.log(`after 1s`));

На этом нам следует распрощаться. Попробуйте синюю птицу в своих pet-проектах, а затем берите с собой в production. Увидимся на JS просторах!

Автор: Accetone

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js