Ще раз про обіцянки

Про обіцянки (promises) вже багато написано. Ця стаття — просто спроба зібрати найбільш необхідні на практиці прийоми використання обіцянок з досить докладними поясненнями того, як це працює.
Загальні відомості про обіцянки
Спочатку кілька визначень.
Обіцянки (promises) — це об'єкти, що дозволяють упорядкувати виконання асинхронних викликів.
Асинхронний виклик — це виклик функції, при якому виконання основного потоку коду не чекає завершення. Наприклад, виконання http-запиту не перериває виконання основного потоку. Тобто виконується запит, і відразу, не чекаючи її завершення, виконується наступний код за цим викликом, а результат http-запиту обробляється після його завершення функцією зворотного виклику (callback-функції).
Далі будемо послідовно розбиратися з функціонуванням обіцянок. Поки будемо виходити з того, що у нас вже є об'єкт-обіцянка виконати якийсь асинхронний виклик. Про те, звідки беруться обіцянки і, як їх сформувати самому, поговоримо трохи пізніше.

1. Обіцянки надають механізм, що дозволяє керувати послідовністю асинхронних викликів. Іншими словами, обіцянка — це просто обгортка над асинхронним викликом. Обіцянка може вирішитися успішно завершитися або з помилкою. Суть механізму в наступному. Кожне обіцянку надає дві функції: then() і catch(). В якості аргументу в кожну з функцій передається функція зворотного виклику. Callback-функції then викликається в разі успішного вирішення обіцянки, а в catch — у разі помилки:
promise.then(function(result){
// Викликається після успішного дозволу обіцянки
}).catch(function(error){
// Викликається в разі помилки
});

2. Наступний момент, який потрібно усвідомити. Обіцянка дозволяється тільки один раз. Іншими словами асинхронний виклик, який відбувається всередині обіцянки виконується тільки один раз. Надалі обіцянку просто повертає збережений результат асинхронного виклику, більше його не виконуючи. Наприклад, якщо ми маємо деякий обіцянку promise, то:
// Перше дозвіл обіцянки
promise.then(function(result){
// При вирішенні обіцянки відбувається асинхронний виклик,
// результат якого передається в цю функцію
});
// Повторне дозвіл того ж обіцянки
promise.then(function(result){
// Тепер вже асинхронний виклик не виконується.
// Цю функцію передається збережений результат
// дозволу обіцянки
});

3. Функції then() і catch() повертають обіцянки. Таким чином, можна вибудовувати ланцюжка обіцянок. При цьому результат, який повертає callback-функції then(), передається як аргумент на вхід callback-функції подальшого then(). А аргумент виклику throw(message) передається як аргумент callback-функції catch():
promise.then(function(result){
// Обробка дозволу обіцянки (код пропущено)
return result1; // Повертаємо результат обробки обіцянки
}).then(function(result){
// Аргумент result цієї функції має значення result1,
// переданий з попереднього виклику
if(condition){
throw("Error message");
}
}).catch(function(error){
// Якщо в попередньому виклику умова виконується,
// то спрацює виняток, управління буде передано в цю
// функцію і мінлива error буде мати значення "Error message"
});

4. В якості результату callback-функції then() може повертати не тільки яке-небудь значення, але і інше обіцянку. При цьому в наступний виклик then() буде переданий результат вирішення цієї обіцянки:
promise1.then(function(result1){
return promise2.then(function(result2){
// Якісь дії
return result3;
});
}).then(function(result){
// Значення аргументу result цієї функції буде дорівнює result3
}).catch(function(error){
// Обробка помилок
});

5. Можна те ж саме зробити, вибудувавши дозвіл обіцянок в одну лінійну ланцюжок без вкладень:
promise1.then(function(result1){
return promise2;
}).then(function(result2){
// Якісь дії
return result3;
}).then(function(result){
// Значення аргументу result цієї функції буде дорівнює result3
}).catch(function(error){
// Обробка помилок
});

Зверніть увагу на те, що кожна ланцюжок обіцянок закінчується викликом catch(). Якщо цього не робити, то виникають помилки будуть пропадати в надрах обіцянок, тоді неможливо буде встановити, де сталася помилка.
Підводимо підсумок
Якщо необхідно виконати послідовність асинхронних викликів (тобто, коли кожен наступний виклик повинен виконатися строго після завершення попереднього), то ці асинхронні виклики необхідно вибудувати ланцюжок обіцянок.
Звідки беруться обіцянки
Обіцянки можна отримати двома способами: або функції використовуваної бібліотеки можуть повертати готові обіцянки, яких можна самостійно обертати в обіцянки асинхронні виклики функцій, які не підтримують обіцянки (промисификация). Розглянемо кожен варіант окремо.
Обіцянки бібліотечних функцій
Багато сучасні бібліотеки підтримують роботу з обіцянками. Про те, підтримує бібліотека обіцянки чи ні, можна дізнатися з документації. Деякі бібліотеки підтримують обидва варіанти роботи з асинхронними викликами: функції зворотного виклику і обіцянки. Розглянемо кілька прикладів з життя.
Бібліотека роботи з базами даних Waterline ORM
документації наводиться такий приклад роботи з функціями бібліотеки (на прикладі вибірки даних):
Model.find({id: [1,2,3]}).exec(function(error, data){
if(error){
// Обробка помилки
}
else{
// Робота з даними, переданими в data
}
});

Здається, що все красиво. Однак, якщо після вибірки даних і їх обробки потрібно зробити ще якісь операції з даними, наприклад, оновлення записів в одній таблиці, потім вставка нових записів в іншій таблиці, то вкладеності функцій зворотного виклику стає такою, що читаність коду різко починає прагнути до нуля:
Model.find({id: [1,2,3]}).exec(function(error, data){
if(error){
// Обробка помилки
}
else{
// Робота з даними, переданими в data
Model_1.update(...).exec(function(error, data){
if(error){/* Обробка помилки */}
else{
// Обробка результату
Model_2.insert(...).exec(function(error, data){
// і т. д.
});
}
});
}
});

Навіть при відсутності основного коду в наведеному прикладі вже розуміти написане дуже важко. А якщо запити до бази даних потрібно ще виконати в циклі, то при використанні функцій зворотного виклику завдання взагалі стає нерозв'язною.
Проте, функції бібліотеки Waterline ORM вміють працювати з обіцянками, хоча про це в документації згадується як-то побіжно. Але використання обіцянок сильно спрощує життя. Виявляється, що функції запитів до бази даних повертають обіцянки. А це означає, що останній приклад на мові обіцянок можна записати так:
Model.find(...).then(function(data){
// Робота з даними, переданими в data
return Model_1.update(...);
}).then(function(data){
// Обробка результату
return Model_2.insert(...);
}).then(function(data){
// і т. д.
}).catch(function(error){
// Обробка помилок робиться в одному місці
});

Думаю, що немає необхідності говорити, яке рішення краще. Особливо приємно те, що обробка всіх помилок тепер робиться в одному місці. Хоча не можна сказати, що це завжди добре. Іноді буває так, що з контексту помилки не ясно в якому саме блоці вона сталася, відповідно налагодження ускладнюється. В цьому випадку ніхто не забороняє вставляти виклик catch() в кожну обіцянку:
Model.find(...).then(function(data){
// Робота з даними, переданими в data
return Model_1.update(...).catch(function(error){...});
}).then(function(data){
// Обробка результату
return Model_2.insert(...).catch(function(error){...});
}).then(function(data){
// і т. д.
}).catch(function(error){
...
});

Навіть у цьому випадку код більш зрозумілий, ніж у разі використання функцій зворотного виклику.
Бібліотека для роботи з MongoDB (базова бібліотека для node.js)
Як і в попередньому випадку, приклади з документації використовують функції зворотного виклику:
MongoClient.connect(url, function(err, db) {
assert.equal(null, err);
console.log("Connected succesfully to server");

db.close();
});

Ми вже побачили на попередньому прикладі, що використання зворотних викликів сильно ускладнює життя в тому випадку, коли потрібно зробити багато послідовних асинхронних викликів. На щастя, при уважному вивченні документації, можна дізнатися, що, якщо у функції цієї бібліотеки не передавати в якості аргументу callback функцію, то вона поверне обіцянку. А значить, попередній приклад можна записати так:
MongoClient.connect(url).then(function(db){
console.log("Connected succesfully to server");
db.close();
}).catch(function(err){
// Обробка помилки
});

Особливість роботи з бібліотекою є те, що для виконання будь-якого запиту до бази використовується об'єкт-дескриптор бази даних db, що повертається в результаті асинхронного виклику connect(). Тобто, якщо в процесі роботи необхідно виконати безліч запитів до бази даних, то виникає необхідність кожного разу отримувати цей дескриптор. І тут використання обіцянок красиво вирішує цю проблему. Для цього потрібно просто зберегти обіцянку підключення до бази змінної:
var dbConnect = MongoClient.connect(url); // Зберігаємо обіцянку
// Тепер можна виконати ланцюжок запитів до бази
dbConnect.then(function(db){
return db.collection('collection_name').find(...);
}).then(function(data){
// Обробка результатів вибірки даних
// Тут дескриптор db вже не доступний, тому
// для наступного запиту робимо так
return dbConnect.then(function(db){
return db.collection('collection_name').update(...);
});
}).then(function(result){
// Тут обробляємо результати виклику update
// і т. д.
...
}).catch(function(error){
// Обробка помилок
});
// Можна не чекаючи завершення роботи попередньої ланцюжка
// виконувати інші запити до бази даних, якщо звичайно це
// допустимо за логікою роботи програми
dbConnect.then(function(db){
...
}).catch(function(error){
...
});

Що добре при такій організації коду, так це те, що з'єднання з базою даних встановлюється один раз (нагадую, що обіцянка дозволяється тільки один раз, далі просто повертається збережений результат). Однак у наведеному вище прикладі є одна проблема. Після завершення всіх запитів до бази нам потрібно закрити з'єднання з базою. Закриття з'єднання з базою можна було б вставити в кінці ланцюжка обіцянок. Однак ми не знаємо, яка з двох запущених нами ланцюжків завершиться раніше, а значить, не зрозуміло, в кінець якої з двох ланцюжків вставляти закриття з'єднання з базою. Вирішити цю проблему допомагає виклик Promise.all(). Про нього поговоримо трохи пізніше.
Так само поки відкладемо обговорення питання про те, як з допомогою обіцянок можна організувати циклічне виконання асинхронних викликів. Адже як було сказано вище використання зворотних викликів не дозволяє вирішити цю проблему взагалі.
А зараз перейдемо до розгляду питання про те, як самостійно створювати обіцянки, якщо бібліотека їх не підтримує.
Створення обіцянок (промисификация)
Часто буває і так, що бібліотека не підтримує обіцянки. І пропонує використовувати тільки зворотні дзвінки. У разі невеликого скрипта і простої логіки можна задовольнятися цим. Але якщо логіка складна, то без обіцянок не обійтися. А значить, треба уміти обертати асинхронні виклики в обіцянки.
В якості прикладу розглянемо обернення в обіцянку асинхронної функції отримання значення атрибута з сховища redis. Функції redis-бібліотеки не підтримують обіцянки і працюють тільки з функціями зворотного виклику. Тобто, стандартний виклик асинхронної функції буде виглядати так:
var redis = require('redis').createClient();

redis.get('attr_name', function(error, reply){
if(error){
//Обробка помилки
}
else{
// Обробка результату
}
});

Тепер обернем цю функцію в обіцянку. JavaScript надає для цього об'єкт Promise. Тобто, для створення нового обіцянки потрібно зробити так:
new Promise(function(resolve, reject){
// Тут відбувається асинхронний виклик
});

В якості аргументу конструктору Promise передається функція, всередині якої відбувається асинхронний виклик. Аргументами цієї функції є функції resolve і reject. Функція resolve повинна бути викликана у разі успішного завершення асинхронного виклику. На вхід функції resolve передається результат асинхронного виклику. Функція reject повинна викликатися в разі помилки. На вхід функції reject передається помилка.
З'єднавши всі разом отримаємо функцію get(attr), яка повертає обіцянку отримати параметр зі сховища redis:
var redis = require('redis').createClient();

function get(attr){
return new Promise(function(resolve, reject){
redis.get(attr, function(error, reply){
if(error){
reject(error);
}
else{
resolve(reply);
}
});
});
}

Ось і все. Тепер можна спокійно користуватися нашою функцією get() звичним для обіцянок чином:
get('attr_name').then(function(reply){
// Обробка результату запиту
}).catch(function(error){
// Обробка помилки
});

Навіщо всі ці муки
У будь-якої розсудливої людини це питання обов'язково з'явиться. Адже чим ми зараз займалися? Ми займалися роз'ясненням того, як послідовно виконувати (асинхронні) виклики функцій. У будь-якому "нормальному" (не працюють з асинхронними викликами) мовою програмування це робиться просто послідовним записом інструкцій. Не потрібні ніякі обіцянки. А тут стільки мук заради того, щоб зробити просту послідовність викликів. Може асинхронні виклики взагалі не потрібні? Адже через них стільки проблем!
Але немає лиха без добра. При наявності асинхронних викликів ми можемо вибирати спосіб виконання викликів: послідовно або паралельно. Тобто якщо одна дія вимагає для свого виконання наявності результату іншої дії, то ми використовуємо виклики then() в обіцянках. Якщо ж виконання кількох дій не залежить один від одного, то ми можемо запустити ці дії на паралельне виконання.
Як запустити паралельне виконання декількох незалежних дій? Для цього використовується виклик:
Promise.all([
promise_1,
promise_2,
...,
promise_n
]).then(function(results){
// Якісь дії після завершення
});

тобто, на вхід функції Promise.all передається масив обіцянок. Всі обіцянки в цьому випадку запускаються на виконання відразу. Функція Promise.all повертає обіцянку виконати всі обіцянки, тому виклик then() в цьому випадку спрацьовує після виконання всіх обіцянок масиву. Параметр results, який передається на вхід функції then(), є масивом результатів роботи обіцянок в тій послідовності, в якій вони передавалися на вхід Promise.all.
Звичайно можна обіцянки і не обертати в Promise.all, а просто послідовно запустити на виконання. Однак, у цьому випадку ми втрачаємо контроль над моментом завершення виконання всіх обіцянок.
Виконання обіцянок у циклі
Часто виникає завдання послідовного виконання обіцянок в циклі. Тут ми будемо розглядати саме послідовне виконання обіцянок, так як, якщо обіцянки допускають паралельне виконання, то немає нічого простіше скласти в циклі (або з допомогою функцій map, filter і т. п.) масив обіцянок і передати його на вхід Promise.all.
Як же в циклі послідовно виконати обіцянки? Відповідь проста — ніяк. Тобто, строго кажучи, обіцянки в циклі виконати не можна, але в циклі можна скласти ланцюжок обіцянок, що виконуються одне за іншим, що, по суті, еквівалентно цикличному виконання асинхронних дій.
Перейдемо до розгляду самих механізмів циклічної побудови ланцюжків обіцянок. Будемо відштовхуватися від звичних всім програмістам різновидів циклів: цикл for і цикл forEach.
Побудова ланцюжка обіцянок в циклі for
Припустимо, що якась функція doSomething() повертає обіцянку виконати якесь асинхронне дію, і нам треба виконати цю дію послідовно n разів. Хай сама обіцянка повертає якийсь рядковий результат result, який використовується при наступному асинхронному виклик. Також припустимо, що перший виклик робиться з порожнім рядком. У цьому випадку побудова ланцюжка обіцянок робиться так:
// Задаємо початкове значення ланцюжка обіцянок
var actionsChain = Promise.reslove("");
// У циклі складаємо ланцюжок обіцянок
for(var i=0; i < n; i++){
actionsChain = actionsChain.then(function(result){
return doSomething(result);
});
}
// Визначаємо, що будемо робити після завершення
// ланцюжка асинхронних дій і обробник помилок
actionsChain.then(function(result){
// Код, який виконується після завершення ланцюжка дій
}).catch(function(error){
// Обробка помилок
});

У цьому прикладі-шаблоні пояснення потребує лише перший рядок. Решта має бути зрозуміло з розглянутого вище матеріалу. Функція Promise.resolve() повертає успішно дозволене обіцянку результатом якого є аргумент цієї функції. Тобто Promise.resolve("") рівносильне наступному:
new Promise(function(resolve, reject){
resolve("");
});

Побудова ланцюжка обіцянок в циклі forEach
Припустимо, нам потрібно виконати яку-небудь асинхронне дію для кожного елемента масиву array. Нехай це асинхронне дію обгорнуте в якусь функцію doSomething(), яка повертає обіцянку виконати це асинхронне дію. Шаблон того, як в цьому випадку вибудувати ланцюжок обіцянок, як це ні дивно, заснований не на використанні функції forEach, а на використанні функції reduce.
Для початку розглянемо, як працює функція reduce. Ця функція призначена для обробки елементів масиву із збереженням проміжного результату. Таким чином, в результаті ми можемо отримати якийсь інтегральний результат для даного масиву. Наприклад, за допомогою функції reduce можна обчислити суму елементів масиву:
var sum = array.reduce(function sum, current){
return sum+current;
}, 0);

Аргументами функції reduce є:
Функція, яка викликається для кожного елемента масиву. В якості аргументів цієї функції передаються: результат попереднього виклику, поточний елемент масиву, індекс цього елемента і сам масив (у наведеному вище прикладі останні два аргументи опущені з-за відсутності необхідності).
Початкове значення, яке передається в якості результату попереднього дії при обробці першого елемента масиву.
Тепер подивимося, як використовувати функцію reduce для побудови ланцюжка обіцянок щодо поставленого завдання:
array.reduce(function(actionsChain, value){
return actionsChain.then(function(){
return doSomething(value);
});
}, Promise.resolve());

У цьому прикладі в якості результату попереднього дії виступає обіцянку actionsChain, після вирішення якого створюється нове обіцянку виконати дію doSomething, яке в свою чергу повертається в якості результату. Так вибудовується ланцюжок для кожного елемента масиву. Promise.resolve() використовується в якості початкового обіцянки actionsChain.
складніший приклад використання обіцянок
як більш складного прикладу застосування обіцянок розглянемо фрагмент коду сервісу генерації унікальних ідентифікаторів. Суть роботи сервісу дуже проста: на кожен запит GET до сервісу він обчислює і повертає черговий унікальний ідентифікатор. Однак, враховуючи те, що одночасно до сервісу може бути направлено декілька конкуруючих запитів, виникає задача побудови цих запитів в чергу (ланцюжок) обіцянок для подальшого виконання.
Код самого сервера виглядає так:
// Створюємо HTTP-сервер
var server = http.createServer(processRequest);
// Перед зупинкою сервера закриваємо з'єднання з redis
server.on('close', function(){
storage.quit();
});
// Запускаємо сервер
server.listen(config.port, config.host);
console.log('Service is listening '+config.host+':'+config.port);

Це стандартний код http-сервера, написаного на node.js. Функція processRequest — обробник http-запиту. Сам сервіс використовує для збереження свого стану сховище redis. Механізм збереження стану в redis не має значення для розуміння даного прикладу, тому розглядатися не буде. Але при завершенні роботи сервера нам необхідно закрити з'єднання з redis, що і відображено в коді вище. Важливо тільки наступне: при кожному запиті викликається функція processRequest. Чергу запитів являє собою обіцянку requestQueue. Розглянемо тепер сам код:
var requestQueue = Promise.resolve();

/**
* Функція обробки запиту
* Запити вибудовуються в ланцюжок обіцянок
* @param {Object} req Запит
* @param {Object} res Відповідь
*/
function processRequest(req, res){
requestQueue = requestQueue
.then(function(){
// Якщо GET-запит, то...
if(req.method == 'GET'){
// обчислюємо UUID і
return calcUUID()
// повертаємо його значення клієнту
.then(function(uuid){
res.writeHead(200, {
'Content-Type': 'text/plain',
'Cache-Control': 'no-cache'
});
res.end(uuid);
})
// Обробляємо помилки
.catch(function(error){
console.log(error);
res.writeHead(500, {
'Content-Type': 'text/plain',
'Cache-Control': 'no-cache'
});
res.end(error);
});
}
// Якщо не GET-запит, то повертаємо Bad Request
else{
res.statusCode = 400;
res.statusMessage = 'Bad Request';
res.end();
} 
});
}

Для обчислення самого ідентифікатора використовується функція calcUUID(), яка повертає обіцянку обчислити унікальний ідентифікатор. Код цієї функції розглядати не будемо, так як для розуміння цього прикладу він не потрібен. При отриманні запиту до черги обробки додається обіцянка обчислити черговий ідентифікатор. Таким чином, кілька конкуруючих запитів шикуються в один ланцюжок, де обчислення кожного наступного ідентифікатора починається після обчислення попереднього.
Висновок
Таким чином, обіцянки дозволяють контролювати процес виконання асинхронних викликів, роблячи при цьому код читабельним і доступним для розуміння. Необхідно тільки навчитися використовувати механізми обіцянок і шаблони програмування з використанням обіцянок.
Сподіваюся, що все було зрозуміло. І кожен знайшов у цій статті щось корисне для себе.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.