Programio

Přísliby v JavaScriptu

Pár poznámek o javasriptových příslibech, se kterými jsem se teď začal v práci nějak víc setkávat. Přísliby slouží především k zpřehlednění kódu a zabraňují tzv. callback hell.

Callback v callbacku v callbacku

Pokud programujete v Node.js, jistě znáte, nebo si dokážete představit, pojem callback hell. Běžný kód v Node.js, který používá několik asynchronních operací, totiž vypadá nějak takto:

1
dbClient.get("obsah", function(err, data) {
    if (err) {
        return console.error(err);
    }
    fs.writeFile("soubor.txt", data, function(err) {
        if (err) {
            return console.error(err);
        }
    });
});

…na což lze reagovat jen takto:

Milion calbacků v milionu jiných callbacků, ve kterých musíte milionkrát ověřit, jestli existuje err a když ano, tak nějak reagovat. Nejlépe zase zavolat nějaký callback, který zavolá řetězec jiných callbacků. Debugování takového kódu je příjemné asi jako sezení na záchodě bez WiFi. Jak z toho ven?

Nejvíce se mi líbí používání příslibů/promisů. Příslib je jen jakási obálka nad hromadou callbacků, která ale umožňuje zásadně přehlednější syntaxi pro řetězení callbacků a hlavně pro odchytávání chyb. Pokud by náš dbClient a naše fs knihovna používala/vracela přísliby, vypadal by předchozí kód takto:

1
dbClient.getAsync("obsah").then(function(data) {
    return fs.writeFileAsync("soubor.txt", data);
}).catch(function(err) {
    console.error(err);
});

(Suffix Async jsem tam přidal záměrně, abych odlišil původní funkce a nové funkce, které pracují s přísliby). Obě funkce, které braly jako parametr callback ho nyní neberou a namísto toho vrací objekt reprezentující náš příslib. Tento objekt má dvě základní metody: then a catch. Asi si dokážete představit, jak má kód fungovat: pokud dotaz z databáze vrátí nějaká data, zavolá se funkce předaná v then metodě. Pokud nastane chyba, zavolá se funkce předaná v catch metodě.

Základní idea příslibů je, že příslib je objekt, který nám slibuje, že jednoho dne (až se přečte soubor z disku, až se vyhodnotí dotaz v databázi…) nám vrátí data, o které jsme žádali anebo vrátí chybu. Přitom příslib nijak nezdržuje načítání – přísliby jsou stále asynchronní, stejně jako callbacky.

Jak něco takového implementovat

Asi ne úplně snadno, aby to fungovalo správně. Naštěstí už existují knihovny, které tuto myšlenku implementovaly za vás, nejvíce se mi líbí Bluebird. V další části článku tak budu popisovat práci s touto knihovnou. Instalace je jednoduchá:

1
npm install bluebird

A v kódu:

1
var Promise = require("bluebird");

Knihovna dokonce umožňuje “zpříslibovat” existující knihovny. Takže pokud napíšete

1
var fs = Promise.promisifyAll(require("fs"));

přidá vám k modulu fs metody se suffixem Async, které už pracují s promisy. Místo readFile můžete používat metodu readFileAsync apod. Viz příklady níže nebo výše.

První krůčky s vlastními přísliby

Abychom mohli s přísliby pracovat, potřebujeme jako první nějaký příslib vytvořit. Úplně nejjednodušší je metoda Promise.resolve(value), která udělá to, že vrátí nový příslib, který se později vyhodnotí na hodnotu value. Příklad:

1
var promise = new Promise.resolve(42);

Tento kód vlastně nic neudělá. Vytvořili jsme nový příslib, který se resolvnul na číslo 42 … ale s touto hodnotou jsme nijak dál nepracovali. Přidáme metodu then:

1
var promise = new Promise.resolve(42);
promise.then(function(data) {
    console.log("Ziskal jsem:", data);
});

Program by vypsal: “Ziskal jsem: 42”. Stalo se to, že jsme vytvořili nový příslib, který se hned vyhodnotil na číslo 42. Toto číslo se pak odchytlo v prvním then bloku. Přísliby můžeme i řetězit, takže bychom mohli napsat například toto:

1
var promise = new Promise.resolve(42);
promise.then(function(data) {
    console.log("Ziskal jsem:", data);
    return data * 10;
}).then(function(data) {
    console.log("A ted jsem ziskal:", data);
});

Tu hodnotu, kterou jsme vrátili v prvním then zase odchytíme v druhém then, takže se vypíše

1
Ziskal jsem: 42
A ted jsem ziskal: 420

Knihovna je navíc natolik chytrá, že pozná, jestli vracíte příslib nebo nějakou normální hodnotu a podle toho se zachová.

1
var promise = new Promise.resolve(42);
promise.then(function(data) {
    console.log("Ziskal jsem:", data);
    return data * 10;
}).then(function(data) {
    console.log("A ted jsem ziskal:", data);
    return Promise.resolve(data * 10);
}).then(function(data) {
    console.log("A nakonec jsem ziskal:", data);
});

Program by vypsal:

1
Ziskal jsem: 42
A ted jsem ziskal: 420
A nakonec jsem ziskal: 4200

Na čtvrtém řádku jsme vrátili číslo 420, zatímco na sedmém řádku jsme vrátili příslib, který se resolvne na 4200. Přitom v argumentu data už jsme vždy získali výslednou hodnotu – v posledním then jsme v argument data neobdrželi příslib, ale opravdu číslo 4200.

Odchytávání chyb

Co by se stalo, kdybychom uprostřed příslibu vyhodili výjimku?

1
var promise = new Promise.resolve(42);
promise.then(function(data) {
    throw new Error("Milos Zeman se stal prezidentem");
}).then(function(data) {
    console.log("A ted jsem ziskal:", data);
});

Program by spadnul a vyhodil by “Possibly unhandled Error: Milos Zeman se stal prezidentem”. Proč se tam vypsalo zrovna “Possibly unhandled Error” je teď jedno, otázkou je, jak to spravit? Jednoduše tak, že přidáme catch blok:

1
var promise = new Promise.resolve(42);
promise.then(function(data) {
    throw new Error("Milos Zeman se stal prezidentem");
}).then(function(data) {
    console.log("A ted jsem ziskal:", data);
}).catch(function(err) {
    console.error("Zachytil jsem", err);
});

Teď už program správně vypíše “Zachytil jsem [Error: Milos Zeman se stal prezidentem]”. Všimněte si, že program se vůbec nedostal k druhému then, protože chyba nastala před ním. Tím, že vyhodíme uprostřed příslib výjimku, nastavíme příznak příslibu na rejected. Podobného chování docílíme také metodou Promise.reject(err). Tato metoda se chová podobně jako Promise.resolve, jen s tím, rozdílem, že vytvořený příslib bude hned rejected, takže se nezavolá then, ale až catch:

1
var promise = Promise.reject(new Error("Tomio Okamura se stal nasim dalsim prezidentem"));
promise.then(function(data) {
    console.log("Vypisuji z then:", data);
}).catch(function(err) {
    console.error("Vypisuji z catch:", err);
});

Vypíše se: “Vypisuji z catch: [Error: Tomio Okamura se stal nasim dalsim prezidentem]”. Úplně nejvíc nejlepší fíčura metody catch je, že nám dovoluje odchytávat jen výjimky nějakého typu tím, že daný typ uvedeme jako argument metody catch. Příklad:

1
var promise = Promise.reject(new TypeError("Tomio Okamura se stal nasim dalsim prezidentem"));
promise.catch(ReferenceError, function(err) {
    console.error("Vypisuji z catch/ReferenceError:", err);
}).catch(TypeError, function(err) {
    console.error("Vypisuji z catch/TypeError:", err);
}).catch(function(err) {
    console.error("Vypisuji z catch:", err);
});

V kódu máme třikrát catch. Dva reagují na výjimky konkrétního typu a jeden obecný. Protože jsme vytvořili výjimku typu TypeError, chytil se pouze jeden catch, ostatní ne. Vypsalo by se: “Vypisuji z catch/TypeError: [TypeError: Tomio Okamura se stal nasim dalsim prezidentem]”. Když bude úplně nejhůř, můžete jako první parametr v catch metodě předat i predikát testující chybu:

1
var is6xxError = function(err) { return err.code >= 600 && err.code < 700 };
var error = new Error("Stanislav Huml pouzil Facebook");
error.code = 666;
Promise.reject(error).catch(is6xxError, function(err) {
    console.error("Specificky catch:", err);
}).catch(function(err) {
    console.error("Obecny catch:", err);
});

Metoda catch nyní bere dva argumenty. První funkce přitom říká, že tento catch ošetřuje všechny chyby, které mají chybový kód 6xx. Program by tak vypsal: “Specificky catch: { [Error: Stanislav Huml pouzil Facebook] code: 666 }”.

Asynchronní přísliby

Schválně, co se vypíše teď?

1
Promise.resolve("Z prislibu").then(console.log);
console.log("Po prislibu");

Abyste rozuměli: vypíše se první “Z prislibu” nebo “Po prislibu?”? Přísliby se vyhodnocují asynchronně, takže se funkce v then se zavolá až ve chvíli, kdy skončí vykonávání celého současného kódu. Nejprve se vytvoří příslib a nastavíme mu then metodou, co se má stát, až bude příslib resolvnutý. Pak pokračujeme dalším řádkem, tj. tím console.logem. V tuto chvíli už nám došel synchronní kód, takže začneme zpracovávat kód, který jsme volali asynchronně, tj. náš příslib. Konečně se zavolá funkce z then metody.

1
Po prislibu
Z prislibu

I kdybychom tam měli nějaký takový kód…

1
Promise.resolve("Z prislibu").then(console.log);
for (var i = 0; i < 10000; i++) console.log(i);
console.log("Po prislibu");

…tak se nejdřív 10000krát vypíše hodnota i, pak se vypíše “Po prislibu” a až pak se vypíše “Z prislibu”. Je to v podstatě podobné, jako kdybychom měli tento kód:

1
fs.readFile("soubor.txt", "utf-8", function(err, data) {
    if (!err) {
        console.log(data);
    }
});
console.log("Tu se Zemanem nechci. Nevim, kterou stranu bych mel oliznout.");

Také se jako první vypíše reakce paní, která si kupuje známku na dopis a až potom se vypíše obsah souboru. Reakce by se vypsala jako první, i kdybychom čtení ze souboru přepsali do příslibů:

1
fs.readFileAsync("soubor.txt", "utf-8").then(function(data) {
    console.log(data);
});
console.log("Tu se Zemanem nechci. Nevim, kterou stranu bych mel oliznout.");

Vnořené přísliby

Trochu nepořádek nastává ve chvíli, kdy používáte vnořené přísliby. Co vypíše tento program?

1
fs.readFileAsync("prvnisoubor.txt", "utf-8").then(function(prvnidata) {
    fs.readFileAsync("druhysoubor.txt", "utf-8");
}).then(function(druhadata) {
    console.log(druhadata);
});

Vypíše obsah prvního, nebo druhého souboru? … Chvíle napětí … Program vypíše undefined. Nejprve se přečte první soubor a jeho obsah se uloží do argumentu prvnidata. V tuto chvíli se vykoná kód fs.readFileAsync("druhysoubor.txt", "utf-8");, tj. vytvoří se příslib přečtení druhého souboru. A s tímto příslibem jaksi nic neděláme, takže tento příslib je zahozen. Když pak voláme další then, tak mu vlastně nepředáváme žádná data. Chybí nám tam jeden return na druhém řádku:

1
fs.readFileAsync("prvnisoubor.txt", "utf-8").then(function(prvnidata) {
    return fs.readFileAsync("druhysoubor.txt", "utf-8");
}).then(function(druhadata) {
    console.log(druhadata);
});

V tuto chvíli už funkce v prvním then vrací příslib, který jsem vytvořili kódem fs.readFileAsync("druhysoubor.txt", "utf-8");. Logika v tuto chvíli říká, že by se do argumentu druhadata měl dostat právě tento příslib. Nicméně to není pravda – pokud vrátíme příslib, knihovna už je natolik chytrá, že pochopí, že nechceme ani tak ten příslib, jako spíš tu hodnotu, na kterou se má příslib vyhodnotit. Takže console.log vypíše obsah druhého souboru.

Na ten chybějící return si dávejte pozor, je to celkem častá chyba, která se blbě hledá. Něco jako chybějící return v rekurzivní funkci – taky to vypadá, že celá funkce funguje správně a ve výsledku vrací undefined. Hehe.

Další způsoby, jak vytvořit příslib

Promise.promisify

Vůbec nejlepší je přísliby nikdy ručně nevytvářet, ale získávat je z knihoven, které už s přísliby pracují. Pokud dané knihovny s přísliby nepracují, můžete použít metodu Promise.promisifyAll, která projde celý předaný objekt a přidá k objektu nové metody, které už vrací přísliby. Všem novým metodám přidá suffix Async. Příklad už jste viděli. Pokud nechcete zpříslibovat celou knihovnu, můžete vytovřit příslib jen z jedné metody pomocí Promise.promisify:

1
var readFileAsync = Promise.promisify(fs.readFile);
readFileAsync("soubor.txt", "utf-8").then(function(data) {
    console.log(data);
});

Občas tento způsob nefunguje, protože když předáte referenci na funkci jako v tomto případě, tak ztratíte původní this kontext. V takovém případě použijte druhý parametr. (Zrovna u fs.readFile to není nutné, ale pro příklad…)

1
var readFileAsync = Promise.promisify(fs.readFile, fs);

new Promise

Když vše selže, použijte new Promise. Syntaxe je už celkem složitá:

1
var isTomorrow = false;
var promise = new Promise(function(resolve, reject) {
    if (isTomorrow) {
        resolve("Je zitra.");
    } else {
        reject(new Error("Neni zitra"));
    }
});
promise.then(console.log).catch(console.error);

Náš Promise bere jako parametr funkci o dvou parametrech. Oba parametry jsou zase funkce. První je resolve – tuto funkci zavoláme, když chceme příslib úspěšně vyhodnotit a předáme mu hodnotu, kterou má příslib dávat dál. Druhá funkce je reject, tu použijeme ve chvíli, kdy chceme příslib rejectnout. Tento kód by tak skončil rejectnutím, then by se přeskočil a zavolal by se až console.error, který by vypsal chybu.

Tento způsob vytváření příslibů využívejte jen když nebude zbytí.

Práce se seznamy

Přísliby nám umožňují krásně a jednoduše pracovat s více asynchronními požadavky. Pokud máme v poli tři názvy souborů, které chceme načíst, můžeme je všechny tři asynchronně načíst takto:

1
var filenames = ["soubor1.txt", "soubor2.txt", "soubor3.txt"];
var promises = filenames.map(function(filename) {
    return fs.readFileAsync(filename, "utf-8");
});
Promise.all(promises).then(function(contents) {
    console.log(contents);
}).catch(function(err) {
    console.error(err);
});

Nejprve projdeme všechny názvy a vytvoříme tři přísliby. Ty si uložíme do proměnné promises. Pak zavoláme Promise.all(promises), čímž říkáme: hele, počkej, až budou všechny přísliby resolvnuty a pak mi obsahy všech tří souborů vrať jako pole do argumentu contents. A pokud během načítání nastane chyba, vypiš ji. Nemusíte se tak starat o žádnou další synchronizaci, o vše se za vás postará Promise.all. Ještě jednodušší je použít Promise.map:

1
var filenames = ["soubor.txt", "soubor.txt", "soubor.txt"];
Promise.map(filenames, function(filename) {
    return fs.readFileAsync(filename, "utf-8");
}).then(function(contents) {
    console.log(contents);
}).catch(function(err) {
    console.error(err);
});

Zkuste si představit, jak by se tohle implementovalo, kdybyste používali čisté callbacky.

A to by pro dnešek bylo všechno. Snad vám tento krátký článek pomůže.