Промисы в ES6: паттерны и анти-паттерны

перевод
ru_vds 5 октября в 15:03 26,3k
Оригинал: Bobby Brennan
Несколько лет назад, когда я начал работать в Node.js, меня приводило в ужас то, что сейчас известно как «ад коллбэков». Но тогда из этого ада выбраться было не так уж и просто. Однако, в наши дни Node.js включает в себя самые свежие, самые интересные возможности JavaScript. В частности, Node, начиная с 4-й версии, поддерживает промисы. Они позволяют уйти от сложных конструкций, состоящих из коллбэков.

image

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

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

Паттерны


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

▍Использование промисов


Если вы применяете стороннюю библиотеку, которая уже поддерживает промисы, пользоваться ими довольно просто. А именно, нужно обратить внимание на две функции: then() и catch(). Например у нас имеется API с тремя методами: getItem(), updateItem(), и deleteItem(), каждый из которых возвращает промис:

Promise.resolve()
  .then(_ => {
    return api.getItem(1)
  })
  .then(item => {
    item.amount++
    return api.updateItem(1, item);
  })
  .then(update => {
    return api.deleteItem(1);
  })
  .catch(e => {
    console.log('error while working on item 1');
  })

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

Вот, для сравнения, реализация той же логики с помощью коллбэков:

api.getItem(1, (err, data) => {
  if (err) throw err;
  item.amount++;
  api.updateItem(1, item, (err, update) => {
    if (err) throw err;
    api.deleteItem(1, (err) => {
      if (err) throw err;
    })
  })
})

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

▍Преобразование коллбэков в промисы


Один из первых приёмов, который полезно изучить при переходе с коллбэков на промисы, заключается в преобразовании коллбэков в промисы. Потребность в подобном может возникнуть в том случае, если вы, например, работаете с библиотекой, которая всё ещё использует коллбэки, или с собственным кодом, написанном с их применением. Перейти от коллбэков к промисам не так уж и сложно. Вот пример преобразования функции Node fs.readFile, основанной на коллбэках, в функцию, которая задействует промисы:

function readFilePromise(filename) {
  return new Promise((resolve, reject) => {
    fs.readFile(filename, 'utf8', (err, data) => {
      if (err) reject(err);
      else resolve(data);
    })
  })
}

readFilePromise('index.html')
  .then(data => console.log(data))
  .catch(e => console.log(e))

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

Обратите внимание на то, что в результате должно быть вызвано что-то одно — либо resolve, либо reject, и этот вызов должен быть выполнен лишь один раз. В нашем примере, если fs.readFile возвращает ошибку, мы передаём эту ошибку в reject. В противном случае мы передаём данные файла в resolve.

▍Преобразование значений в промисы


В ES6 есть пара удобных вспомогательных функций для создания промисов из обычных значений. Это Promise.resolve() и Promise.reject(). Например, у вас может быть функция, которой нужно возвратить промис, но которая обрабатывает некоторые случаи синхронно:

function readFilePromise(filename) {
  if (!filename) {
    return Promise.reject(new Error("Filename not specified"));
  }
  if (filename === 'index.html') {
    return Promise.resolve('<h1>Hello!</h1>');
  }
  return new Promise((resolve, reject) => {/*...*/})
}

Обратите внимание на то, что вы можете передать что угодно (или ничего) при вызове Promise.reject(), однако, рекомендуется всегда передавать этому методу объект Error.

▍Одновременное выполнение промисов


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

let filenames = ['index.html', 'blog.html', 'terms.html'];

Promise.all(filenames.map(readFilePromise))
  .then(files => {
    console.log('index:', files[0]);
    console.log('blog:', files[1]);
    console.log('terms:', files[2]);
  })

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

▍Последовательное выполнение промисов


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

Одно из решений этой проблемы заключается в том, чтобы запускать промисы последовательно, один за другим. К сожалению, в ES6 нет простого аналога Promise.all для выполнения подобной операции (хотелось бы знать — почему?), но тут нам может помочь метод Array.reduce:

let itemIDs = [1, 2, 3, 4, 5];

itemIDs.reduce((promise, itemID) => {
  return promise.then(_ => api.deleteItem(itemID));
}, Promise.resolve());

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

Promise.resolve()
  .then(_ => api.deleteItem(1))
  .then(_ => api.deleteItem(2))
  .then(_ => api.deleteItem(3))
  .then(_ => api.deleteItem(4))
  .then(_ => api.deleteItem(5));

▍Гонка промисов


Ещё одна удобная вспомогательная функция, которая имеется в ES6 (хотя я и не особенно часто ей пользуюсь), это — Promise.race. Так же, как и Promise.all, она принимает массив промисов и выполняет их одновременно, однако, возврат из неё осуществляется как только любой из промисов будет выполнен или отклонён. Результаты других промисов при этом отбрасываются.

Например, создадим промис, который завершается с ошибкой по прошествии некоторого времени, задавая ограничение на выполнение операции по чтению файла, представленной другим промисом:

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

Promise.race([readFilePromise('index.html'), timeout(1000)])
  .then(data => console.log(data))
  .catch(e => console.log("Timed out after 1 second"))

Обратите внимание на то, что другие промисы продолжат выполняться — вы просто не увидите их результатов.

▍Перехват ошибок


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

Promise.resolve()
  .then(_ => api.getItem(1))
  .then(item => {
    item.amount++;
    return api.updateItem(1, item);
  })
  .catch(e => {
    console.log('failed to get or update item');
  })

Здесь вызывается блок catch(), если либо getItem, либо updateItem завершится с ошибкой. Но что, если совместная обработка ошибок нам не нужна и требуется обрабатывать ошибки, происходящие в getItem, раздельно? Для этого достаточно вставить ещё один блок catch() сразу после блока с вызовом getItem — он даже может вернуть другой промис:

Promise.resolve()
  .then(_ => api.getItem(1))
  .catch(e => api.createItem(1, {amount: 0}))
  .then(item => {
    item.amount++;
    return api.updateItem(1, item);
  })
  .catch(e => {
    console.log('failed to update item');
  })

Теперь, если getItem() даст сбой, мы вмешиваемся и создаём новый элемент.

▍Выбрасывание ошибок


Код внутри выражения then() стоит воспринимать так, будто он находится внутри блока try. И вызов return Promise.reject(), и вызов throw new Error() приведут к выполнению следующего блока catch().

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

api.getItem(1)
  .then(item => {
    delete item.owner;
    console.log(item.owner.name);
  })
  .catch(e => {
    console.log(e); // Cannot read property 'name' of undefined
  })

▍Динамические цепочки промисов


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

function readFileAndMaybeLock(filename, createLockFile) {
  let promise = Promise.resolve();

  if (createLockFile) {
    promise = promise.then(_ => writeFilePromise(filename + '.lock', ''))
  }

  return promise.then(_ => readFilePromise(filename));
}

В подобной ситуации нужно обновить значение promise, использовав конструкцию вида promise = promise.then(/*...*/). С этим примером связано то, что мы рассмотрим ниже в разделе «Множественный вызов .then()».

Анти-паттерны


Промисы — это аккуратная абстракция, но работа с ними полна подводных камней. Тут мы рассмотрим некоторые типичные проблемы, с которыми мне доводилось сталкиваться, работая с промисами.

▍Реконструкция ада коллбэков


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

api.getItem(1)
  .then(item => {
    item.amount++;
    api.updateItem(1, item)
      .then(update => {
        api.deleteItem(1)
          .then(deletion => {
            console.log('done!');
          })
      })
  })

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

▍Отсутствие команды возврата


Часто встречающаяся и вредная ошибка, с которой я сталкивался, заключается в том, что в цепочке промисов забывают о вызове return. Например, можете найти ошибку в этом коде?

api.getItem(1)
  .then(item => {
    item.amount++;
    api.updateItem(1, item);
  })
  .then(update => {
    return api.deleteItem(1);
  })
  .then(deletion => {
    console.log('done!');
  })

Ошибка заключается в том, что мы не поместили вызов return перед api.updateItem в строке 4, и этот конкретный блок then() разрешается немедленно. В результате api.deleteItem(), вероятно, будет вызвано до завершения вызова api.updateItem().

По моему мнению, это — основная проблема с промисами ES6, и она часто ведёт к их непредсказуемому поведению. Проблема заключается в том, что then() может вернуть либо значение, либо новый объект Promise, при этом он вполне может вернуть и undefined. Лично я, если бы отвечал за API промисов JavaScript, предусмотрел бы выдачу ошибки времени выполнения, если бы блок .then() возвращал undefined. Однако, подобное в языке не реализовано, поэтому сейчас нам лишь остаётся быть внимательными и выполнять явный возврат из любого создаваемого нами промиса.

▍Множественный вызов .then()


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

let p = Promise.resolve('a');
p.then(_ => 'b');
p.then(result => {
  console.log(result) // 'a'
})

let q = Promise.resolve('a');
q = q.then(_ => 'b');
q = q.then(result => {
  console.log(result) // 'b'
})

В этом примере, так как мы не обновляем значение p при следующем вызове then(), мы никогда не увидим возврата 'b'. Промис q более предсказуем, его мы обновляем каждый раз, вызывая then().

То же самое применимо и к обработке ошибок:

let p = Promise.resolve();
p.then(_ => {throw new Error("whoops!")})
p.then(_ => {
  console.log('hello!'); // 'hello!'
})

let q = Promise.resolve();
q = q.then(_ => {throw new Error("whoops!")})
q = q.then(_ => {
  console.log('hello'); // Сюда мы никогда не попадём
})

Тут мы ожидаем выдачу ошибки, которая прервёт выполнение цепочки промисов, но так как значение p не обновляется, мы попадаем во второй then().

Множественный вызов .then() позволяет создать из исходного промиса несколько новых независимых промисов, однако, мне до сих пор не удалось найти реального применения для этого эффекта.

▍Смешивание коллбэков и промисов


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

function getThing(callback) {
  api.getItem(1)
    .then(item => callback(null, item))
    .catch(e => callback(e));
}

getThing(function(err, thing) {
  if (err) throw err;
  console.log(thing);
})

Проблема здесь заключается в том, что в случае ошибки мы получим предупреждение «Unhandled promise rejection», несмотря на то, что блок catch() в цепочке присутствует. Это так из-за того, что callback() вызывается и внутри then(), и внутри catch(), что делает его частью цепочки промисов.

Если вам абсолютно необходимо обернуть промис в коллбэк, вы можете использовать функцию setTimeout, или process.nextTick в Node.js для того, чтобы выйти из промиса:

function getThing(callback) {
  api.getItem(1)
    .then(item => setTimeout(_ => callback(null, item)))
    .catch(e => setTimeout(_ => callback(e)));
}

getThing(function(err, thing) {
  if (err) throw err;
  console.log(thing);
})

▍Неперехваченные ошибки


Обработка ошибок в JavaScript — странная штука. Она поддерживает классическую парадигму try/catch, но не поддерживает средства обработки ошибок в вызванном коде вызывающей его конструкцией, как это сделано, например, в Java. Однако, в JS распространено использование коллбэков, первым параметром которых является объект ошибки (такой коллбэк называют ещё «errback»). Это вынуждает конструкцию, вызывающую метод, как минимум, учитывать возможность ошибки. Вот пример с библиотекой fs:

fs.readFile('index.html', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
})

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

(node:29916) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: whoops!
(node:29916) DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

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

Итоги


Мы рассмотрели некоторые паттерны и анти-паттерны использования промисов. Надеюсь, вы нашли здесь что-нибудь полезное. Однако, тема промисов весьма обширна, поэтому вот — несколько ссылок на дополнительные ресурсы:


Уважаемые читатели! Как вы используете промисы в своих Node.js-проектах?
Проголосовать:
+23
Сохранить: