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

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



    Если кто незнаком, 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 просторах!

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 20

      +1

      Напишите код нормально, пожалуйста. Ссылки на Gist не работают.

        0
        Спасибо, сейчас перенесу код в статью. У вас JS отключен?
          –1

          Нет, корпоративный прокси рубит gist.github.com.

            0
            Спасибо, оформление даже лучше получилось чем через gist.
      • UFO just landed and posted this here
          0
          Да, будет работать, эти промисы совместимы с нативными
            +1

            Можно, но не совсем прямо. Например конструкция


            async function myFunc() { ... }

            вернет системный промис, если немного не постараться. А на системных промисах некоторых bluebird-специфичных вещей нет. И когда не знаешь про это (а дебагер и то и другое показывает как Promise), то самый первый дебаг получается увлекательный.

          +1

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


          function semiAsyncFunction() {
             return new Promise((resolve, reject) => {
               // синхронные исключения здесь тоже зарежектят промис
             });
          }
            0
            Спасибо за комментарий. Предполагается, что потребителям возвращается результат `Promise.try`. Почему-то был уверен, что синхронная ошибка не будет отклонена в коде приведенном вами. Тогда в этом методе и правда нет смысла, поменял текст статьи. Ещё раз спасибо!
              0
              Все-таки чаще асинхронные функции возвращают промис полученный от другой функции, а не создают его.

              Поэтому я бы в качестве замены для Promise.try(...) рассматривал комбинацию Promise.resolve().then(...)
              +2

              Что-то не то с названием. Мне кажется, что когда пишут "магия внутри", то в статье будет про то, как это внутри работает (а там довольно интересные дела внутри с точки зрения оптимизации происходят), а не вольный пересказ api reference.

                +4

                Для некоторых из указанных штук, если неплохие альтернативы в нативном async/await


                1) Promise.protype.finally(). Здесь все очевидно


                try {
                  doSomething()
                }
                catch(e) {}
                finally {
                  cleanup()
                }

                2) Promise.any. В стандарте есть похожий Promise.race. У него отличается поведение в случае ошибки, нет AggregationError, но для типичной задачи "берем данные либо по сети, либо из кеша" — работает неплохо:


                const result = await Promise.race([tryNetwork(), tryCache(), timeout()])

                3) Promise.mapSeries заменяется на обычный for-of цикл


                for (const i of [10, 20, 30]) {
                   await doRequest(i);
                }

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


                5) Promise.bind() в async/await коде не нужен, можно просто обойтись переменными:


                const user = await fetchUser();
                const orders = await fetchOrders(user);
                const processed = await checkProcessedOrders(user, orders);

                В цепочках then значения user и orders приходилось бы как-то передавать, а здесь все выглядит естественно.


                Аналогично с Promise.tap методом, теперь нет проблем вставить вызов посередине


                const user = await fetchUser();
                await delay(300);
                const orders = await fetchOrders(user);

                6) Отмену промисов уже завезли в fetch API. Будет доступна с следующем релизе Хрома (66), пока можно поиграться в канарейке.


                P.S. ни в коем случае не принижаю полезность bluebird, но если вы решили использовать его только по одной из 6 причин показанных выше, то сперва стоит посмотреть на нативные возможности

                  0

                  P.P.S фича с опциональным перехватом исключений по типу или другому предикату — огонь! Такого даже в Typescript нет. Но реальные use-case для развесистой обработки ошибок по типу встречаются нечасто, обычно достаточно пары if-ов. Но подход красивый, не спорю.

                    0
                    Спасибо, отличное дополнение к статье.

                    2) Все таки `Promise.race` отличается и поэтому имеет другое применение. Он ведь перейдет в состояние rejected как только любой из переданных промисов перейдет в rejected. Поэтому наиболее частое применение `Promise.race` это конкурирование запроса с каким-либо событием, например, с таймаутом. С другой стороны, если у нас есть несколько источников информации и мы хотим дождаться ответа от любого из них и не получать ошибку до тех пор пока все источники не вернут ошибку, то будем использовать `Promise.any`. Надеюсь не запутал :)

                    6) С bluebird мы можем любой промис сделать отменяемым + если операция позволяет, отменить и ее

                    P.S. Использую bluebird в основном из-за map, any и скорости

                      +2
                      С bluebird мы можем любой промис сделать отменяемым + если операция позволяет, отменить и ее

                      С отменяемостью в bluebird неоднозначно.


                      Во-первых promise.cancel() не отменит операцию, если у нее будет второй консьюмер


                      var result = fetch(...);
                      
                      var first = result.then(...);
                      var second = result.then(...);
                      
                      first.cancel(); // ничего не произойдет

                      Вторая проблема, что при отмене промиса, он так и зависнет в неопределенном состоянии. finally вызовутся, а then/catch нет.


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

                        +1

                        Давайте разбираться вместе.


                        Включим отмену:


                        Promise.config({ cancellation: true });

                        Для задержки воспользуемся этим методом:


                        function delay(ms) {
                          return new Promise((resolve, reject, onCancel) => {
                            const timer = setTimeout(() =>  {
                               console.log('timer fired');
                               resolve();
                            }, ms);
                        
                            onCancel(() => { 
                              console.log('timer cancelled');
                              clearTimeout(timer);
                            });
                          });
                        }

                        Увидим timer fired, A и B:


                        const source = delay(1000);
                        
                        const consumerA = source.then(() => console.log(`A`));
                        const consumerB = source.then(() => console.log(`B`));

                        Увидим timer cancelled:


                        const source = delay(1000);
                        
                        const consumerA = source.then(() => console.log(`A`));
                        const consumerB = source.then(() => console.log(`B`));
                        
                        source.cancel();

                        Увидим timer fired и B:


                        const source = delay(1000);
                        
                        const consumerA = source.then(() => console.log(`A`));
                        const consumerB = source.then(() => console.log(`B`));
                        
                        consumerA.cancel();

                        Увидим timer cancelled:


                        const source = delay(1000);
                        
                        const consumerA = source.then(() => console.log(`A`));
                        const consumerB = source.then(() => console.log(`B`));
                        
                        consumerA.cancel();
                        consumerB.cancel();

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


                        Важно, что каждый вызов then или catch возвращает новый промис связанный с исходным. Поэтому consumerA не равен consumerB и не равен source. И поэтому увидим timer fired и A:


                        const source = delay(1000);
                        
                        const consumerA = source.then(() => console.log(`A`));
                        const consumerB = source.then(() => console.log(`B`));
                        const consumerС = consumerB.then(() => console.log(`С`));
                        
                        consumerС.cancel();

                        Насчёт fetch. Это не отмена промиса, а отмена нижележащей операции, в данном случае, сетевого запроса. Поэтому промис перейдёт в состояние rejected. Как вы правильно заметили, отмена bluebird-промиса не вызывает ни then, ни catch, что и является желательным поведением при отмене. Пользуясь этой фичей вы вообще не хотите ничего знать о том, как завершиться операция. Отмена через bluebird позволяет отменять именно промисы, а также нижележащие операции, если они такое поддерживают (таймеры, i/o с потоками и т.д.).


                        Это никак не принижает, а только дополняет, нововведения в fetch, если вы всё таки осмелитесь взять bluebird на клиента. Если нужно чтобы потребители получили уведомление о том, что промис уже ждать не надо, то это другой паттерн — либо timeout либо race.

                    0
                    а зачем вы много где написали свой delay, если уже есть Promise.delay (который делает то же самое)
                      0

                      Спасибо. Думал об этом при написании. В итоге решил оставить как есть для единообразия примеров с delay и delayAndReject. Так как такой код отработает как надо:


                      Promise.resolve(42).delay(1000).then(...);

                      А такой вызовет catch сразу:


                      Promise.reject(new Error('boom')).delay(1000).catch(...);
                      +1
                      В статье не упомянули про longStackTraces. Очень помогает при отладке.
                      bluebirdjs.com/docs/api/promise.longstacktraces.html

                      Only users with full accounts can post comments. Log in, please.