Несколько лет назад, когда я начал работать в Node.js, меня приводило в ужас то, что сейчас известно как «ад коллбэков». Но тогда из этого ада выбраться было не так уж и просто. Однако, в наши дни Node.js включает в себя самые свежие, самые интересные возможности JavaScript. В частности, Node, начиная с 4-й версии, поддерживает промисы. Они позволяют уйти от сложных конструкций, состоящих из коллбэков.
Использование промисов вместо коллбэков ведёт к написанию более лаконичного кода, который легче читать. Однако, тому, кто с ними не знаком, они могут показаться не особенно понятными. В этом материале я хочу показать базовые шаблоны работы с промисами и поделиться рассказом о проблемах, которые может вызвать их неумелое применение.
Обратите внимание на то, что здесь я буду использовать стрелочные функции. Если вы с ними не знакомы, стоит сказать, что устроены они несложно, но в этом случае советую прочесть материал об их особенностях.
Паттерны
В этом разделе я расскажу о промисах, и о том, как пользоваться ими правильно, продемонстрировав несколько шаблонов их применения.
▍Использование промисов
Если вы применяете стороннюю библиотеку, которая уже поддерживает промисы, пользоваться ими довольно просто. А именно, нужно обратить внимание на две функции: 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-проектах?
Автор: ru_vds