Несколько лет назад, когда я начал работать в Node.js, меня приводило в ужас то, что сейчас известно как «ад коллбэков». Но тогда из этого ада выбраться было не так уж и просто. Однако, в наши дни Node.js включает в себя самые свежие, самые интересные возможности JavaScript. В частности, Node, начиная с 4-й версии, поддерживает промисы. Они позволяют уйти от сложных конструкций, состоящих из коллбэков.
Использование промисов вместо коллбэков ведёт к написанию более лаконичного кода, который легче читать. Однако, тому, кто с ними не знаком, они могут показаться не особенно понятными. В этом материале я хочу показать базовые шаблоны работы с промисами и поделиться рассказом о проблемах, которые может вызвать их неумелое применение.
Обратите внимание на то, что здесь я буду использовать стрелочные функции. Если вы с ними не знакомы, стоит сказать, что устроены они несложно, но в этом случае советую прочесть материал об их особенностях.
В этом разделе я расскажу о промисах, и о том, как пользоваться ими правильно, продемонстрировав несколько шаблонов их применения.
Если вы применяете стороннюю библиотеку, которая уже поддерживает промисы, пользоваться ими довольно просто. А именно, нужно обратить внимание на две функции:
Каждый вызов
Вот, для сравнения, реализация той же логики с помощью коллбэков:
Первое отличие этого фрагмента кода от предыдущего заключается в том, что в случае с коллбэками мы должны включать обработку ошибок на каждом шаге процесса, вместо использования единственного блока для обработки всех ошибок. Вторая проблема с коллбэками больше относится к стилю. Блок кода, представляющий каждый из шагов, выровнен по горизонтали, что мешает воспринимать последовательность выполнения операций, очевидную при взгляде на код, основанный на промисах.
Один из первых приёмов, который полезно изучить при переходе с коллбэков на промисы, заключается в преобразовании коллбэков в промисы. Потребность в подобном может возникнуть в том случае, если вы, например, работаете с библиотекой, которая всё ещё использует коллбэки, или с собственным кодом, написанном с их применением. Перейти от коллбэков к промисам не так уж и сложно. Вот пример преобразования функции Node
Краеугольный камень этой функции — конструктор
Обратите внимание на то, что в результате должно быть вызвано что-то одно — либо
В ES6 есть пара удобных вспомогательных функций для создания промисов из обычных значений. Это
Обратите внимание на то, что вы можете передать что угодно (или ничего) при вызове
Я даже пытаться не буду писать эквивалентный код с использованием традиционных коллбэков. Достаточно сказать, что такой код будет запутанным и подверженным ошибкам.
Иногда одновременное выполнение нескольких промисов может приводить к неприятностям. Например, если вы попробуете получить множество ресурсов из API с использованием
Одно из решений этой проблемы заключается в том, чтобы запускать промисы последовательно, один за другим. К сожалению, в ES6 нет простого аналога
В данном случае мы хотим ждать завершения текущего обращения к
Ещё одна удобная вспомогательная функция, которая имеется в ES6 (хотя я и не особенно часто ей пользуюсь), это —
Например, создадим промис, который завершается с ошибкой по прошествии некоторого времени, задавая ограничение на выполнение операции по чтению файла, представленной другим промисом:
Обратите внимание на то, что другие промисы продолжат выполняться — вы просто не увидите их результатов.
Обычный способ перехвата ошибок в промисах заключается в добавлении в конец цепочки блока
Здесь вызывается блок
Теперь, если
Код внутри выражения
Это означает, что ошибки времени выполнения также вызывают срабатывание блоков
Иногда нужно сконструировать цепочку промисов динамически, то есть — добавляя дополнительные шаги при выполнении каких-то условий. В следующем примере, прежде чем прочесть заданный файл, мы, при необходимости, создаём файл блокировки:
В подобной ситуации нужно обновить значение
Промисы — это аккуратная абстракция, но работа с ними полна подводных камней. Тут мы рассмотрим некоторые типичные проблемы, с которыми мне доводилось сталкиваться, работая с промисами.
Когда я только начал переходить с коллбэков на промисы, я обнаружил, что от некоторых старых привычек отказаться тяжело, и поймал себя на том, что вкладываю друг в друга промисы так же, как коллбэки:
На практике такие конструкции не требуются практически никогда. Иногда один или два уровня вложенности могут помочь сгруппировать связанные задачи, но вложенные промисы практически всегда можно переписать в виде вертикальной цепочки, состоящей из
Часто встречающаяся и вредная ошибка, с которой я сталкивался, заключается в том, что в цепочке промисов забывают о вызове
Ошибка заключается в том, что мы не поместили вызов
По моему мнению, это — основная проблема с промисами ES6, и она часто ведёт к их непредсказуемому поведению. Проблема заключается в том, что
В соответствии с документацией, вполне можно вызывать
В этом примере, так как мы не обновляем значение
То же самое применимо и к обработке ошибок:
Тут мы ожидаем выдачу ошибки, которая прервёт выполнение цепочки промисов, но так как значение
Множественный вызов
Если вы используете библиотеку, основанную на промисах, но работаете над проектом, основанном на коллбэках, легко попасться в ещё одну ловушку. Избегайте вызовов коллбэков из блоков
Проблема здесь заключается в том, что в случае ошибки мы получим предупреждение «Unhandled promise rejection», несмотря на то, что блок
Если вам абсолютно необходимо обернуть промис в коллбэк, вы можете использовать функцию
Обработка ошибок в JavaScript — странная штука. Она поддерживает классическую парадигму
Работая с промисами, легко забыть о том, что ошибки надо явным образом обрабатывать. Особенно это актуально в тех случаях, когда речь идёт об операциях, восприимчивым к ошибкам, таким, как команды для работы с файловой системой или для доступа к базам данных. В текущих условиях, если не перехватить отклонённый промис, в Node.js можно увидеть довольно-таки неприглядное предупреждение:
Для того, чтобы этого избежать, не забывайте добавлять
Мы рассмотрели некоторые паттерны и анти-паттерны использования промисов. Надеюсь, вы нашли здесь что-нибудь полезное. Однако, тема промисов весьма обширна, поэтому вот — несколько ссылок на дополнительные ресурсы:
Уважаемые читатели! Как вы используете промисы в своих Node.js-проектах?
Использование промисов вместо коллбэков ведёт к написанию более лаконичного кода, который легче читать. Однако, тому, кто с ними не знаком, они могут показаться не особенно понятными. В этом материале я хочу показать базовые шаблоны работы с промисами и поделиться рассказом о проблемах, которые может вызвать их неумелое применение.
Обратите внимание на то, что здесь я буду использовать стрелочные функции. Если вы с ними не знакомы, стоит сказать, что устроены они несложно, но в этом случае советую прочесть материал об их особенностях.
Паттерны
В этом разделе я расскажу о промисах, и о том, как пользоваться ими правильно, продемонстрировав несколько шаблонов их применения.
▍Использование промисов
Если вы применяете стороннюю библиотеку, которая уже поддерживает промисы, пользоваться ими довольно просто. А именно, нужно обратить внимание на две функции:
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.al
l для выполнения подобной операции (хотелось бы знать — почему?), но тут нам может помочь метод 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()
в конец цепочек промисов.Итоги
Мы рассмотрели некоторые паттерны и анти-паттерны использования промисов. Надеюсь, вы нашли здесь что-нибудь полезное. Однако, тема промисов весьма обширна, поэтому вот — несколько ссылок на дополнительные ресурсы:
- Материалы по промисам от Mozilla
- Введение в промисы от Google
- Обзор промисов, подготовленный Дэйвом Атчли
- Вот и вот — дополнительные материалы по паттернам и анти-паттернам
Уважаемые читатели! Как вы используете промисы в своих Node.js-проектах?
Only registered users can participate in poll. Log in, please.
Что вы используете в своих проектах?
44.27% Промисы220
43.86% Async/await218
11.87% Коллбэки59
497 users voted. 106 users abstained.