Asynchrónne veci v JavaScripte pomocou callbackov, promises a async/await

2019/10/29

Úvod

Riešení je viacero:

Callbacks

Tradičný spôsob: funkcia má medzi parametrami inú funkciu, callback, ktorú zavolá po dobehnutí.

Callback: príklad setTimeout()

Objekt window v browseroch má metódu setTimeout(), ktorá berie:

Kód:

function ding() {
    console.log("Ding!")
}

setTimeout(ding, 3000);

Alternatívne:

setTimeout(function() {
    console.log("Three seconds elapsed!");
}, 3000);

Callback: príklad práce so súborovým systémom v Node.js

fs.readFile('/etc/passwd', 'utf8', (err, data) => {
    if (err) throw err;
    console.log(data)
});

Callback v Node.js prijíma dva parametre:

Pyramid of Doom

Callbacky sú jednoduchý spôsob zápisu, ktorý však trpí neprehľadnosťou. Programátorský folklór ich často volá „pyramída hrôzy“, pretože biele miesto v odsadení pripomína otočenú pyramídu.

Skrátene, „Kód ide rýchlejšie doprava než nadol“:

const fs = require('fs');

fs.open('/etc/passwd', 'r', (err, fd) => {
    if (err) throw err;
    fs.fstat(fd, (err, stats) => {
        console.log(stats.ctime)
        fs.close(fd, (err) => {
            if (err) throw err;
        });
    });
});

V algoritme voláme tri kroky:

  1. Otvor súbor a získaj jeho deskriptor v premennej fd.
  2. Zisti informácie o súbore.
  3. Zatvor súbor.

Každý krok algoritmu vedie k samostatnému callbacku a k ďalšej úrovni zanorenia.

Žiadne návratové hodnoty

Všimnime si, ako sa vôbec nepoužívajú návratové hodnoty funkcií v algoritme! Namiesto nich používame callbacky, ktoré prijmú výslednú hodnotu:

  1. Callback funkcie open prijme deskriptor súboru.
  2. Callback funkcie fstat prijme objekt so štatistikami súboru.
  3. Callback funkcie close nepríjima žiaden parameter, čo zodpovedá procedúre, teda funkcii bez návratovej hodnoty.

Promises

Promises (prísľuby) sprehľadňujú zápis callbackov.

Promises v JavaScripte

Od ES6/ECMAScript 2015 sú promisy súčasťou jazyka.

V odôvodnených prípadoch možno použiť niektorú z knižníc:

Príklad použitia v prehliadači

let promise = fetch('http://jsonplaceholder.typicode.com/albums')
promise.then(response => showResult(response.statusText))

Špecifikácia Promises/A+

Promisy z ES6 a zmienených knižníc zodpovedajú tejto špecifikácii.

Promise: stavy a prechody

Splnený, ani zamietnutý prísľub sa už nikdy nemôže dostať do stavu pending.

Poznámka: natívne promisy v ES2015 neposkytujú spôsob, ako zistiť ich stav.

Metóda then()

Metóda then() na promise berie dva parametre, tzv. callbacky (niekde tiež handlery), reprezentujúce dve funkcie.

Návratová hodnota then()

Po zavolaní metódy then() získame nový promise, ktorý je splnený po dobehnutí handlera pre splnenie, či zamietnutý po dobehnutí zamietacieho handlera. V prípade úspechu je hodnota v splnenom promise prevzatá z návratovej hodnoty callbacku.

To nám dáva možnosť reťaziť prísľuby, o čom si povieme podrobnejšie v sekcii Reťazenie promisov.

Použitie then s dvoma callbackmi

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => console.log(response.statusText), err => console.error(err));

Funkcia fetch je zabudovaná funkcia v ES6/ECMAScript v prehliadači, ktorá predstavuje náhradu ajaxového API XMLHttpRequest.

Výsledkom funkcie je promise, ktorému podsunieme dva callbacky:

Skúsme zámerne urobiť preklep v adrese URL, napríklad na ftp:// a uvidíme zamietnutý promise s volaním chybového callbacku.

Použitie then s jedným callbackom

Ak funkciu onRejected (druhý callback) vynecháme, použije sa štandardná obsluha chýb: chyba sa prepadne do zreťazeného promisu (viď nižšie) a ak taký neexistuje, prostredie ju môže vypísať na konzolu, prípadne do logu.

Reťazenie promisov

Funkcia then vracia ďalší promise, ktorý môžeme použiť na ďalšie spracovanie údajov. Stav tohto promisu záleží od návratovej hodnoty callbackov.

Callback (funkcia, ktorá je parametrom pre then()) môže vracať tri veci:

Ak callback nevracia nič, alebo vracia nejakú hodnotu, je promise vrátený funkciou then automaticky splnený.

Ak callback vyhodí výnimku, promise je zamietnutý.

Ak callback promisu P vracia iný promise Q, promise P prevezme stav vráteného promisu Q (promise P musí byť v stave pending dovtedy, kým je v stave pending vrátený promise Q. Podobne sa spriahne aj prechod do splneného či zamietnutého vzťahu. Podrobnosti určuje špecifikácia.).

Ukážka reťazenia

V ukážke vidíme dvojité reťazenie promisov, v dvoch rozličných situáciách,.

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => response.json())
    .then(json => console.log(json))
  1. Prvý callback vracia JSON z odpovede. Keďže metóda json() vracia Promise, stav promisu vráteného z prvého then sa spriahne s prísľubom json().
  2. Druhý callback prevezme parameter json, obsahujúci odbalenú hodnotu z predošlého promisu, teda samotný objekt s dátami, a vypíše ho. Callback nevracia nič, ale promise vrátený z druhej then už ani nepotrebujeme.

Volanie catch() odchytáva výnimky

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => response.json())
    .then(json => console.log(json))
    .catch(err => console.error(err));

Volanie catch() je len syntaktický cukor pre volanie .then(), kde prvý parameter je undefined.

.then(undefined, err => console.error(err))

Skracovanie zreťazených promisov

Ak sú kroky a obsluhy výnimiek implementované ako funkcie, máme takmer Java/.NET try-catch štýl. Môžeme využiť trik eta-redukcia, kde volanie funkcie s jedným parametrom nahradíme priamo jej názvom:

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => response.json())
    .then(console.log)
    .catch(console.error);

Komplexné volania REST API

Komplexné volania REST API pomocou promisov sú veľmi bežné. Predstavme si nasledovnú situáciu:

  1. Získame zoznam albumov pomocou volania REST.
  2. Pre každý album dotiahneme samostatným volaním majiteľa (používateľa).
  3. Majiteľa asociujeme s albumom.
  4. Vrátime zoznam albumov s asociovanými majiteľmi.

Niektoré operácie sú asynchrónne: získanie zoznamu a dotiahnutie majiteľa.

Záludnosť spočíva v poslednom kroku: máme zoznam albumov, pre každú položku asynchrónne dotiahneme majiteľa a obohatíme existujúci album. Výsledný zoznam albumov však nemôžeme používať dovtedy, kým nedobehli všetky získavania jednotlivých majiteľov. Preto musíme počkať na splnenie všetkých prísľubov s majiteľmi albumov; to však urobíme asynchrónne, bez blokujúceho čakania!

Poďme si to rozobrať po kroku:

Získanie albumu

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => response.json())
    .then(fetchAlbumOwnersAsync)
    .then(JSON.stringify)
    .then(console.log)
    .catch(console.error);

Získanie albumu zrealizujeme jednoducho: získame odpoveď, z nej vytiahneme JSON, a následne zavoláme funkciu fetchAlbumOwnersAsync (tá ešte neexistuje, ale vznikne). Funkcia vráti finálny zoznam albumov, ktorý následne zalogujeme, a obslúžime prípadné chyby.

Všimnime si, že to všetko zrealizujeme reťazení promisov!

Jednotlivé kroky si postupne odovzdávajú výsledky:

  1. objekt Response.
  2. prísľub, ktorý obsiahne JSONovskú reprezentáciu objektu v odpovedi
  3. zoznam albumov, vrátane majiteľov
  4. ich reťazová reprezentácia
  5. nič (po zalogovaní), ak sa všetko vykoná bez problémov
  6. nič (po zalogovaní chyby), v prípade chýb.

Získanie majiteľov albumov

Funkcia fetchAlbumOwnersAsync zoberie sadu albumov, a pre každý album v nej dotiahne autora pomocou vnoreného prísľubu.

Funkcia má príponu Async, čo je menná konvencia z niektorých projektov, ktorá naznačuje, že návratovou hodnotou je promise.

function fetchAlbumOwnersAsync(albums) {
    let userPromises = albums.map(findUserByAlbumAsync);
    return Promise.all(userPromises)
        .then(users => associate(albums, users))
}

Funkcia prakticky namapuje každý album na prísľub, ktorý bude obsahovať jeho majiteľa. Mapovanie zrealizuje ďalšia funkcia, findUserByAlbumAsync, ku ktorej sa ihneď dostaneme:

function findUserByAlbumAsync(album) {
    return fetch(`http://jsonplaceholder.typicode.com/users/${album.userId}`)
        .then(response => response.json())
}

Táto funkcia je jednoduchá: vráti prísľub s jedným používateľom, podľa identifikátora userId v albume.

Opäť pripomeňme, že táto funkcia mapuje album na prísľub používateľa a preto sme jej dali príponu Async.

Ak sa vrátime naspäť k fetchAlbumOwnersAsync, uvidíme premennú userPromises, ktorá obsahuje zoznam prísľubov (s majiteľmi). V tejto chvíli musíme „počkať“ na dobehnutie všetkých promisov, pretože inak nevieme albumom priradiť ich celé objekty majiteľov. Slovo „počkať“ je zámerne v úvodzovkách, lebo ide o neblokujúce čakanie, ktoré nezbrzdí (jediné) vlákno prehliadača. Vďaka špinavým trikom ide o neblokujúce čakanie, ktoré dosiahneme pomocou metódy Promise.all.

Metóda all — statická na triede Promise — počká na dobehnutie promisov v poli, alebo na zlyhanie ktoréhokoľvek z nich a vráti prísľub s jedným poľom obsahujúcim výsledky jednotlivých promisov.

Inak povedané, all prevádza pole prísľubov na pole s výsledkami prísľubov, pričom počká na úspech alebo prvé zlyhanie.

Výsledné pole s výsledkami (s majiteľmi albumov v takom poradí, v akom sme zaslali albumy) následne preiterujeme a priradíme k nim albumy pomocou funkcie associate().

Asociovanie majiteľa s albumom

Na asociovanie majiteľa s albumom si urobíme pomocnú funkciu:

function associate(albums, users) {
    albums.forEach((album, i) => album.user = users[i]);
    return albums
}

Funkcia prejde dve polia, index po indexe a priradí majiteľa zo zoznamu používateľov do dynamickej premennej user v príslušnom albume. Táto funkcia je úplne bežná, nie je asynchrónna, ani nevracia prísľub, ale bežný, upravený zoznam albumov.

Výsledný kód

function associate(albums, users) {
    albums.forEach((album, i) => album.user = users[i]);
    return albums
}

function findUserByAlbumAsync(album) {
    return fetch(`http://jsonplaceholder.typicode.com/users/${album.userId}`)
        .then(response => response.json())
}

function fetchAlbumOwnersAsync(albums) {
    let userPromises = albums.map(findUserByAlbumAsync);
    return Promise.all(userPromises)
        .then(users => associate(albums, users))
}

fetch('http://jsonplaceholder.typicode.com/albums')
    .then(response => response.json())
    .then(fetchAlbumOwnersAsync)
    .then(JSON.stringify)
    .then(console.log)
    .catch(console.error);

Async-Await

Mechanizmus async/await slúži na návrat ku klasickému zápisu, kde máme premenné a priradenia do nich z návratových hodnôt funkcií namiesto používania then a callbackov.

Kľúčové slová async /await sú k dispozícii vo viacerých programovacích jazykoch, napr. v modernom JavaScripte a v C#.

Kľúčové slovo await môžeme v JavaScripte používať len vo funkciách označených ako async.

Prebudujme teraz náš kód tak, aby používal async-await.

Začnime zhora: a rovno povieme, že funkcie associate sa nedotkneme, lebo tá nie je asynchrónna.

Vyhľadanie používateľa podľa albumu

async function findUserByAlbum(album) {
    let response = await fetch(`http://jsonplaceholder.typicode.com/users/${album.userId}`);
    return response.json()
}

Keďže funkcia fetch vracia prísľub, je to skvelý kandidát na prepis then na bežné priradenie do premennej. Keďže prísľub musíme odbaliť do hodnoty, použijeme slovo await (“očakávaj”).

Odpoveď používa metódu json(), ktorá vracia prísľub s objektovou reprezentáciou albumov, ktorý prepočleme ďalej.

A keďže funkcia findUserByAlbum vracia prísľub a keďže funkcia využíva await, musíme ju vyhlásiť za async, teda asynchrónnu.

Dotiahnutie majiteľov albumu

async function fetchAlbumOwners(albums) {
    let userPromises = albums.map(findUserByAlbum);
    let users = await Promise.all(userPromises);
    return associate(albums, users)
}

Funkcia je tiež asynchrónna. Keďže all vracia prísľub, môžeme ho nahradiť očakávaním odpovede s poľom používateľov, ktoré priradíme do premennej s použitím await. Výsledok následne použijeme ako argument pre funkciu associate, ktorá nevracia prísľub, ale bežnú hodnotu. To neprekáža, pretože vďaka kľúčovému slovu async nad funkciou sa návratová hodnota funkcie fetchAlbumOwners vždy zabalí do prísľubu.

Spustenie mašinérie

Keďže zavolanie zoznamu albumov chceme prepísať do async/await, čo je možné len v rámci async funkcie, upracme v kóde:

async function findFullAlbums() {
    let response = await fetch('http://jsonplaceholder.typicode.com/albums');
    let albums = await response.json();
    return fetchAlbumOwners(albums);
}

Odpoveď z funkcie fetch je prísľub, preto ho odbalíme do premennej response.

V ďalšom kroku prevedieme prísľub z funkcie json() na zoznam albumov, opäť odbalením pomocou await.

Výslednú zbierku albumov následne použijeme ako argument pre funkciu fetchAlbumOwners, ktorá vracia prísľub. Ten použijeme ako návratovú hodnotu z našej funkcie.

Vajce-sliepka pri asynchrónnych funkciách

Samotná mašinéria teraz trpí problémom vajca a sliepky. Potrebujeme zavolať findFullAlbums, ktorá je asynchrónna, ale to môžeme robiť len v rámci asynchrónnej funkcie. Ako z toho von?

Pripravíme si anonymnú asynchrónnu funkciu, ktorá sa navyše nielen zadeklaruje, ale aj sama spustí. Ide o immediately invoked async function expression, čo je rozšírenie vzorca IIFE z JavaScriptu.

(async function () {
    try {
        let fullAlbums = await findFullAlbums();
        console.log(JSON.stringify(fullAlbums));
    } catch (e) {
        console.error(e)
    }
})();

Kolekcia zátvoriek definuje anonymnú funkciu (medzi úplne prvou guľatou zátvorkou a jej párom), ktorú následne okamžite vykoná (úplne posledný pár zátvoriek pred finálnou bodkočiarkou.).

Takto máme asynchrónnu funkciu, kde vieme získať zoznam celých albumov (i s majiteľmi), priradiť do do premennej s odbalením prísľubu a následným zalogovaním.

Obsluha výnimiek a flow

Všimnime si, ako sa vieme vrátiť k civilizovanej obsluhe výnimiek pomocou try/catch, čo je obvyklý spôsob! Vďaka async/await vieme používať asynchrónny kód skoro tak isto ako bežný, synchrónny"

Celý kód

function associate(albums, users) {
    albums.forEach((album, i) => album.user = users[i]);
    return albums
}

async function findUserByAlbum(album) {
    let response = await fetch(`http://jsonplaceholder.typicode.com/users/${album.userId}`);
    return response.json()
}

async function fetchAlbumOwners(albums) {
    let userPromises = albums.map(findUserByAlbum);
    let users = await Promise.all(userPromises);
    return associate(albums, users)
}

async function findFullAlbums() {
    let response = await fetch('http://jsonplaceholder.typicode.com/albums');
    let albums = await response.json();
    return fetchAlbumOwners(albums);
}

(async function () {
    try {
        let fullAlbums = await findFullAlbums();
        console.log(JSON.stringify(fullAlbums));
    } catch (e) {
        console.error(e)
    }
})();

Repozitár v Gite

Ukážkový kód sa nachádza v repozitári na GitHube, v repe novotnyr/javascript-async-rest-client.

Pramene

>> Home