Появившиеся в JavaScript новые асинхроные конструкции async/await выглядят проще, чем Promise, и, конечно, значительно читабельнее, чем «callback-джунгли». Но одна вещь беспокоила меня — это использование try-catch. Сначала я подумал, что это не проблема, но, к несчастью, мне пришлось работать с цепочкой вызовов API, в которой каждый вызов API имел свое сообщение об ошибке, которое должно было прологировано. Вскоре я понял, что создаю «try/catch-джунгли», которые ничем не лучше «callback-джунглей».

Давайте рассмотрим этот Promise, который возвращает данные или исключение через 2 секунды в зависимости от параметра rejectPromise:

// api.js
const fetchData = async (duration, rejectPromise) => (
  new Promise((resolve, reject) => {
    setTimeout(() => {
      if (rejectPromise) {
        reject({
          error: 'Error Encountered',
          status: 'error'
        })
      }
      resolve({
        version: 1,
        hello: 'world',
      });
    }, duration);
  })
);

module.exports = {
  fetchData,
};

Типичное использование этой функции будет выглядеть так:

const { fetchData } = require('./api');

const callApi = async () => {
  try {
    const value = await fetchData(2000, false);
    console.info(value);
  } catch (error) {
    console.error(error);
  }
}

callApi();

/* 
 OUTPUT: 
 { version: 1, hello: 'world' } (rejectPromise=false)
 { error: 'Error Encountered', status: 'error' } (rejectPromise=true)
 */

Как вы можете видеть, когда параметр rejectPromise является ложным, Promise возвращает значение {version: 1, hello: 'world'}, а когда оно истинно, он вызывает исключение { error: 'Error Encountered', status: 'error' }.

Это типичная реализация async-await. Теперь мы попытаемся использовать Promise, чтобы сделать этот код более простым. Давайте напишем функцию-обертку, которая упростит нам обработку исключений.

// wrapper.js
const wrapper = promise => (
  promise
    .then(data => ({ data, error: null }))
    .catch(error => ({ error, data: null }))
);

module.exports = wrapper;
</source
Мы можем видеть, что функция-обертка принимает Promise в качестве входного параметра и возвращает ошибку или даные с использовнаием конструкции then().catch(). Итак, давайте изменим исходный код с применением функции-обертки:

<source lang="javascript">
const { fetchData } = require('./api');
const wrapper = require('./wrapper');

const callApi = async () => {
  const { error, data } = await wrapper(fetchData(2000, false));
  if (!error) {
    console.info(data);
    return;
  }
  console.error(error);
}

callApi();

/* 
 OUTPUT: 
 { version: 1, hello: 'world' } (rejectPromise=false)
 { error: 'Error Encountered', status: 'error' } (rejectPromise=true)
 */

image — получаем тот же результат но более читабельным кодом.

Полезные ссылки.


1. Почему [не] надо использовать async/await

Комментарии (23)


  1. mak_ufo
    18.05.2018 21:57
    +3

    Так и не понял. Зачем функция из первого примера помечена как async, если внутри нигде не вызывается await?


  1. mwizard
    18.05.2018 22:57
    +3

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

    И да, толку от обработки вида `console.error(err)` очень мало. Может, вовсе не стоит ловить исключение, если оно обрабатывается таким образом?


    1. apapacy Автор
      19.05.2018 00:25
      -2

      Есть одна неприятная вещь в промисах равно как и в асинках. Если не будет обработан reject то nodejs завершит работу с ошибкой. Это по сути обязывает разработчика все конструкции await заключать в блоки trycatch. Поэтому способ предложенный автором снимает определённую часть проблем.


      1. Daraku
        19.05.2018 01:18
        +2

        Поэтому способ предложенный автором снимает определённую часть проблем.

        Автор утверждает что: «try/catch-джунгли» ничем не лучше «callback-джунглей».

        И он прав, но предложенное решение это «if/else-джунгли». В этом плане все перечисленные решения одинаковы, так как являются вариацией одного и того же явления: "Pyramid of doom"


        1. apapacy Автор
          19.05.2018 13:33

          Из callback-джунглей был предложен хороший выход github.com/creationix/step. Я так ни разу не воспользовался этой библиотекой т.к все быстро перешли на промисы. Хотя с учётом того что промисы стали наличными и быстрыми не сразу указанное решение могло бы упростить разработку


          1. Alternator
            19.05.2018 17:51
            +1

            Более популярна для callback-джунглей библиотека www.npmjs.com/package/async
            В ней и функциональности больше, да и API на мой взгляд чище.

            Кроме того есть ее порт для промисов — www.npmjs.com/package/async-q
            Также полезна, так как организация высокоуровневых шаблонов асинхронности(ограничение количества потоков, очереди) в промисах все-таки отсутсвует


      1. lega
        19.05.2018 02:17
        +3

        обязывает разработчика все конструкции await заключать в блоки trycatch
        А нельзя сделать один trycatch где-то наверху, там где реквест/задача начинается?


        1. apapacy Автор
          19.05.2018 02:50
          -3

          Единственный вариант — это в каждой асинхронной функции взять все в try/catch блок. Если это сделать где-то на самом верхнем уровне вызовов функции — то я сталкивался с ситуацией что это не помогает. Нужно будет специально исследовать этот вопрос.


          1. Alternator
            19.05.2018 11:46
            +1

            Нужен конкретный пример, где один внешний try-catch не работает
            Проверил на базовых примерах — внешний try-catch прекрасно работает

            Может сломаться, если не await-ить внутренние вызовы, но тут и try-catch-hell не спасет
            Как следствие может сломаться на изощренных юзкейсах, вроде «послать все запросы в цикле, их промисы запомним в массив, а потом в однопоточном цикле их await-ить и обрабатывать»
            Но в таких нетривиальных случаях пожалуй лучше будет использовать библиотеки управления потоком, или как workaround, вызвать перед циклом

            await Promise.race(results)


            Как следствие try-catch-и можно размещать согласно логике приложение(там где вы хотите реально обработать ошибку), например на уровне обработчика запроса — точно также, как это делается для синхронного кода


            1. apapacy Автор
              19.05.2018 12:15

              Я когда писал комментарий не помнил точно где это встречалось. Потом проверил. На уровне модуля то есть не внутри функции нельзя использовать конструкцию await. За этот факт и за необходимость явно определять функцию как async есть справедливая критика. То есть вызов на верхнем уровне асинхронного кода в trycatch невозможен. Вернее он не будет ловить ошибку так как нельзя использовать await


              1. Alternator
                19.05.2018 12:46

                В таком случае можно обернуть верхний уровень в авто-вызываемую асинхронную функцию, и обработать ошибку в нем

                (async function main() {
                    await func1();
                    await func2();
                })().catch((err) => console.error(err))
                


                Также, вероятно на верхнем уровне асинхронные функции вызываются синхронно, одна за другой, потому что должны идти параллельно и независимо (иначе они уже были бы обернуты в асинхронную функцию, либо цепочку промисов).
                Тогда стоит рассматривать эти функции как верхнеуровневые, и достаточно добавить try-catch только в них, либо вообще обработать их ошибки на уровне модуля
                func1();
                func2();
                async function func1() {
                  try {
                    // ...
                  } catch(e) {
                    console.log(e);
                  }
                }
                async function func2() {
                  try {
                    // ...
                  } catch(e) {
                    console.log(e);
                  }
                }
                

                func1().catch(e => console.log(e));
                func2().catch(e => console.log(e));;
                async function func1() {
                  // ...
                }
                async function func2() {
                  // ...
                }
                


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


                1. apapacy Автор
                  19.05.2018 13:08

                  Да все верно. Единственное что хотелось бы получать более простой для восприятия код. В этом смысле await как кажется на первый взгляд сильно все упрощает. Но когда начинаешь работать с конкретной задачей то сталкиваешься с необходимостью обрабатывать ошибки. С учётом появления стрелочных функций которые упростили код с промисами я бы сказал что по восприятию код с промисами и стрелочными функциями и код с async/await примерно равны по простоте/сложности для восприятия.


  1. Daraku
    19.05.2018 00:18
    +2

    получаем тот же результат но более читабельным кодом

    В статье не хватает определения читабельности.

    Примеры недостаточно показывают разницу, так как, положительный пример имеет:
    • Одинаковое количество строк кода в теле функции как и «try/catch» пример (6 строк)
    • Одинаковое количество условий выхода из функции («return;/console.error(error)» и «try/catch»)
    • Отрицательное условие: «если не ошибка» (if (!error))
    • Двойную интерпретацию «wrapper(fetchData(2000, false))», в зависимость от понимания работы «fetchData»:
      • «fetchData» — синхронна и возвращает данные на обработку в «wrapper»:
        const dataRaw = fetchData(2000, false);
        const data = await wrapper(dataRaw);
      • «wrapper» — модифицирует «Promise» объект, возвращаемый вызовом «fetchData»:
        const fetchPromise = fetchData(2000, false);
        const data = await wrapper(fetchPromise);


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


  1. marshinov
    19.05.2018 11:42
    +1

    Обратите внимание на вот такой подход: https://m.habr.com/post/339606/. Все try/catch уйдут, но обработка ошибок останется


  1. F0iL
    19.05.2018 11:55
    +7

    const { error, data } = await wrapper(fetchData(2000, false));
    if (!error) {

    Что-то мне это напоминает… :)
    image


  1. Keyten
    19.05.2018 15:17

    Я заодно оставлю здесь более удобный способ создавать промисы.

    Код
    function Defer(){
        var status;
        this.resolve = function(value){
            status = {
                type: 'resolved',
                value: value
            };
        };
        this.reject = function(value){
            status = {
                type: 'rejected',
                value: value
            };
        };
    
        var that = this;
    
        this.promise = function(){
            return promise = new Promise((resolve, reject) => {
                if(status){
                    if(status.type === 'resolved'){
                        resolve(status.value);
                    } else {
                        reject(status.value);
                    }
                } else {
                    that.resolve = function(value){
                        resolve(value);
                    }
                    that.reject = function(value){
                        reject(value);
                    }
                }
            });
        };
    }


    1. lega
      19.05.2018 15:36

      Чем он более удобнее?, это стиль «deferred», от которого все* отказались много лет назад и перешли на промисы, в котором меньше кейвордов нужно помнить.


      1. Keyten
        19.05.2018 15:45

        Тем, что не нужно создавать 20 вложенных друг в друга функций.
        Лично мне наоборот сложнее, когда функций больше. У них ещё разные контексты исполнения, разные this (arrow functions не везде), где-то его нужно передавать, где-то нет… опять callback hell, в общем.


        1. justboris
          19.05.2018 16:35
          +1

          Откуда вы взяли цифру 20? new Promise добавляет только одну дополнительную функцию в код, зато вы получаете важное преимущество — синхронно выброшенное исключение перехватится и зарежектит промис. Вы гарантированно никогда не получите синхронного исключения.


          1. Keyten
            19.05.2018 19:33

            Число 20 появилось из традиционной для русского языка гиперболы.

            Про исключения не знал. Тогда всё становится понятнее, окей.


        1. lega
          19.05.2018 21:50

          Тем, что не нужно создавать 20 вложенных друг в друга функций.
          Это наверное при каком-то неправильном использовании, есть пример? Промисы можно более менее линейно «стыковать».


  1. justboris
    19.05.2018 16:47
    +1

    На самом деле, для подхода, описанного в статье, необязательно даже wrapper писать. Достаточно резолвить промис с ошибкой вместо режекта.


    const fetchData = async (rejectPromise) =>
      new Promise(resolve => {
        if (rejectPromise) {
          resolve({ error: "Error Encountered" });
        } else {
          resolve({ data: { version: 1, hello: "world" } });
        }
      });
    
    // использование будет таким же
    const { error, data } = await fetchData(true);

    Разумеется, это будет работать только для вашего кода, для библиотек нужна обертка.


  1. rumkin
    20.05.2018 07:01

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