Přejít k navigační liště

Zdroják » JavaScript » Zbavte se asynchronních callbacků v Node.js za pomocí generátorů

Zbavte se asynchronních callbacků v Node.js za pomocí generátorů

Články JavaScript

Asynchronní callbacky v Node.js jsou peklo. Každý, kdo v nodě něco kdy zkoušel napsat, to ví. Nové verze enginu V8 nám ale přináší některé novinky z ECMA6, které pomohou udělat asynchronní kód čitelný a jednoduchý, konkrétně mám na mysli generátory. V tomto článku si ukážeme techniku, která umožní psát asynchronní kód stejně čitelně, jako by byl synchronní.

Nejprve ještě nutná poznámka. Uvedené příklady jsou funkční v node.js od verze 0.11.x s použitím přepínače –harmony-generators:

$ node --harmony-generators

V Google Chrome si podporu můžete zapnout na stránce s nastavením experimentálních voleb chrome://flags/#enable-javascript-harmony.

Co jsou ty generátory zač?

Vlastně jsou to koprogramy. V ECMA6 jsou zastoupeny objektem zapouzdřujícím uspaný kontext běhu programu. Možná to zní nesrozumitelně, pojďme si raději ukázat příklad:

function* helloGenerator() {
  yield "Ahoj";
  yield "generátore!";
}

var hello = helloGenerator();

console.log(hello.next()); // { value: 'Ahoj', done: false }
console.log(hello.next()); // { value: 'generátore!', done: false }
console.log(hello.next()); // { value: undefined, done: true }
// console.log(hello.next()); // throws Error: Generator has already finished

Takto se například nechá definovat Fibonacciho posloupnost:

function* fibonacciGenerator() {
  var fib, prev = 0, curr = 1;
  while(true) {
    fib = prev + curr;
    prev = curr;
    yield curr = fib;
  }
}

var fibonacci = fibonacciGenerator();

console.log(fibonacci.next()); // { value: 1, done: false }
console.log(fibonacci.next()); // { value: 2, done: false }
console.log(fibonacci.next()); // { value: 3, done: false }
console.log(fibonacci.next()); // { value: 5, done: false }
console.log(fibonacci.next()); // { value: 8, done: false }

Generátoru lze předat zpět hodnotu zavoláním next(hodnota). Toto je velice důležitá vlastnost, kterou dále v článku použijeme. Zde je příklad:

function* zvedavyGenerator() {
    var result = yield 'Kdo jsem?';
    return result === 'Generátor' ? 'Ano! :-)' : 'Ne!';
}

var otazky = zvedavyGenerator();

// Předávat hodnotu prvnímu volání next() nemá smysl
console.log(otazky.next());            // { value: 'Kdo jsem?', done: false }
console.log(otazky.next("Generátor")); // { value: 'Ano! :-)', done: true }

Ještě si ukážeme, jak vyvolat výjimku uvnitř generátoru, to se bude také hodit:

function* zlobivyGenerator() {
    var i = 0;
    try {
        while (true) yield i++;
    } catch (err) {
        console.log('Ouha ' + err);
        while (i--) yield i;
    }
}

var generator = zlobivyGenerator();

console.log(generator.next()); // { value: 0, done: false }
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 2, done: false }
generator.throw(new Error("Chyba")); // Ouha Error: Chyba
console.log(generator.next()); // { value: 1, done: false }
console.log(generator.next()); // { value: 0, done: false }
console.log(generator.next()); // { value: undefined, done: true }

Generátory lze též skládat pomocí syntaxe yield* (všimněte si hvezdičky)

function* sequenceGenerator(start, increment, max) {
    while (start < max) {
        yield start;
        start += increment;
    }
}

function* composedGenerator() {
    // yield s hvezdičkou vrací celý generátor
    yield* sequenceGenerator(0, 2, 10);
    yield* sequenceGenerator(10, 20, 100);
    yield* sequenceGenerator(100, 200, 1000);
}

var sequence = composedGenerator();

while (true) {
    var item = sequence.next();
    if (item.done) return;
    console.log(item.value);
}
// vypíše 0 2 4 6 8 10 30 50 70 90 100 300 500 700 900

K čemu je to dobré?

Tak, schválně, nikoho nic nenapadá? :-) Tak jdem na to:

var fs = require('fs');

// toto je náš aplikační kód
function *taskGenerator() {
    var lines = [];
    for (var i = 1; i <= 6; i++) {
        var filename = './generator' + i + '.js';
        lines.push('File: ' + filename);
        lines.push(yield readFileThunk(filename, { encoding: 'utf-8' }));
        lines.push('========================');
    }
    console.log(lines.join('\n'));
}

// co je "thunk" si popíšeme dále v textu
function readFileThunk(filename, options) {
    return function(callback) {
        fs.readFile(filename, options, callback);
    }
}

// toto teď moc nestudujte, není to až tak třeba
// popíšeme si lepší způsob, jak docílit téhož
// daleko lepší je použít modul co
function procesTasks(tasks, callback, value) {
    var task = tasks.next(value);
    if (typeof task.value === 'function') {
        task.value(function(err, result) {
            procesTasks(tasks, callback, result);
        });
    } else {
        if (typeof callback === 'function') {
            callback(new Error('Unsupported return value'));
        }
    }
}

// a teprve teď to celé zpracujeme
procesTasks(taskGenerator());

Všimněte si kódu v taskGenerator. Vidíte, jak je krásně čitelný? (No dobře, ještě to tak pěkné není, ale bude to hezčí :-) ) Přitom v sekvenci zpracovává 6 asynchronních úkolů! Jak je to možné? Klíčem zde je takzvaný thunk, který se z generátoru navrací jakémusi plánovači, v našem příkladu je reprezentován poněkud naivní rekurzivní funkcí procesTasks. V praxi existují připravené npm moduly.

Co je to Thunk

Thunk se dá chápat jako úmysl. Je to funkce, která vrací funkci přijímající jediný callback (tedy další funkci), který přijímá dva parametry (err, result). Všimněte si funkce readFileThunk v příkladu výše. Je to vlastně jakási low-level variace na promisy vyhovující konvenčnímu tvaru callbacků v node.js core.

Zjednodušeně řečeno, thunk umožňuje převést kód z tohoto:

fs.readFile(filename, options, callback);

na tento:

readFileThunk(filename, options)(callback);

Existuje i modul thunkify, který toto umí udělat s jakoukoliv funkcí, která přijímá callback jako poslední parametr:

var thunkify = require('thunkify');
var readFileThunk = thunkify(fs.readFile);

Ještě jeden příklad jednoduchého, ale užitečného thunku, který pozastaví aktuální generátor, aby mohly pokračovat jiné asynchronní úkoly. Nesmíme nikdy zapomínat, že samotný náš JavaScriptový kód běží pouze v jednom vlákně a asynchronní úkoly pouze informují náš kód, že byly dokončeny, když náš kód čeká.

function immediateThunk() {
    return function(callback) {
        setImmediate(function() {
            callback();
        });
    }
}

Thunky se mi nelíbí :-( Něco jiného? CO?

Není problém :-) Naštěstí TJ Holowaychuck napsal modul co, který funguje stejně jako naše metoda procesTasks , s tím rozdílem, že lze pomocí yield vynášet nejen thunky, ale i promisy a jejich pole a objekty, generátory a generátorové funkce. Modul je krásně zdokumentován.
Pro ilustraci si přepíšeme předchozí příklad vypisující zdrojové kódy příkladů:

var fs = require('fs');
var co = require('co');
var thunkify = require('thunkify');

var read = thunkify(fs.readFile);

// toto je náš aplikační kód
co(function *() {
    var lines = [];
    for (var i = 1; i <= 6; i++) {
        var filename = './generator' + i + '.js';
        lines.push('File: ' + filename);
        lines.push(yield read(filename, { encoding: 'utf-8' }));
        lines.push('========================');
    }
    console.log(lines.join('\n'));
})();

Není to hned nějaké kratší? :-) Navíc sama funkce co vrací thunk, lze tedy napsat:

co(function *() {
    return (yield read('./generator1.js', { encoding: 'utf-8' })) +
        (yield read('./generator2.js', { encoding: 'utf-8' }));
})(function(err, result) {
    console.log(result);
});

A CO a chyby?

Výjimky fungují tak, jak by měly, konečně :-)

co(function *() {
    try {
        return (yield read('./generator1.js', { encoding: 'utf-8' })) +
            (yield read('./generator2.js', { encoding: 'utf-8' })) +
            (yield read('neexistujici-soubor', { encoding: 'utf-8' }));
    } catch (err) {
        throw new Error('Něco je špatně');
    }
})(function(err, result) {
    if (err) {
        console.error('Chyba: ' + err.message);
        // Chyba: Něco je špatně: ENOENT, open 'neexistujici-soubor'
        return;
    }
    console.log(result);
});

Paralelní zpracování

Spustit více asynchronních operací paralelně je s co také velice snadné:

co(function *() {
    var results = yield [
        read('./co1.js', { encoding: 'utf8'}),
        read('./co2.js', { encoding: 'utf8'}),
        read('./co3.js', { encoding: 'utf8'})];
    console.log(results.join('\n===\n'));
})();

Alternativně lze předat objekt, což je dobré pro pojmenované úlohy:

co(function *() {
    var results = yield {
        'co1': read('./co1.js', { encoding: 'utf8'}),
        'co2': read('./co2.js', { encoding: 'utf8'}),
        'co3': read('./co3.js', { encoding: 'utf8'})}
    console.log(results);
})();

Dokonce lze objekty a pole různě zanořovat a kombinovat yieldy:

co(function *() {
    var results = yield {
        'jmeno': print('jmeno'),
        'prijmeni': print('prijmeni'),
        'id': yield print('id'),
        'konstanta5': 5,
        'konstantaStr': 'konstantaStr',
        'pole': [
            print('pole[0]'),
            print('pole[1]'),
            [
                print('pole[2][0]'),
                [
                    print('pole[2][1][0]'),
                    print('pole[2][1][1]'),
                ],
                {
                    'pozdni': print('pole[2][2].pozdni'),
                    'drivejsi': yield print('pole[2][2].drivejsi')
                },
                print('pole[2][3]'),
            ],
            print('pole[3]'),
        ],
    };
    console.log(JSON.stringify(results, null, 2));
})();

function print(name) {
    return function(callback) {
        console.log('Zpracovávám ' + name);
        setTimeout(function() {
            callback(null, 'hodnota ' + name);
        }, 500 + Math.random() * 1000);
    };
}

// Zpracovávám id
// ... čeká
// Zpracovávám pole[2][2].drivejsi
// ... čeká
// Zpracovávám jmeno
// Zpracovávám prijmeni
// Zpracovávám pole[0]
// Zpracovávám pole[1]
// Zpracovávám pole[2][0]
// Zpracovávám pole[2][1][0]
// Zpracovávám pole[2][1][1]
// Zpracovávám pole[2][2].pozdni
// Zpracovávám pole[2][3]
// Zpracovávám pole[3]
// ... čeká
({
  "id": "hodnota id",
  "konstanta5": 5,
  "konstantaStr": "konstantaStr",
  "prijmeni": "hodnota prijmeni",
  "jmeno": "hodnota jmeno",
  "pole": [
    "hodnota pole[0]",
    "hodnota pole[1]",
    [
      "hodnota pole[2][0]",
      [
        "hodnota pole[2][1][0]",
        "hodnota pole[2][1][1]"
      ],
      {
        "drivejsi": "hodnota pole[2][2].drivejsi",
        "pozdni": "hodnota pole[2][2].pozdni"
      },
      "hodnota pole[2][3]"
    ],
    "hodnota pole[3]"
  ]
});

Všechny příklady zde uvedené jsou na GitHubu

Příště…

Příště se podíváme na serverový framework koa od tvůrců Express, který staví na tomto přístupu.

Komentáře

Subscribe
Upozornit na
guest
41 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
Lukas Rychtecky

„Vydíte“? To jako vážně?

Martin Hassman

Vidíte, to uteklo i naší korektorce. Díky.

Lukas Rychtecky

Díky za opravu.

jjjjj

uz dlhsie som riesil ako nezakapat v callbackovom pekle. uz som sa skoro aj do opalang pustil.

jjjjj

k tej fibonacci postupnosti este jedna otazocka. ten generator mi stale bezi alebo po klucovom slove yield sa zastavi pokial nezavolam next()?

Martin Hassman

Zastavi, yield je v podstate takovy „return„, a po zavolani next() koprogram zase pokračuje.

keff

Dobry den, hledal jsem, ale zatim marne – jak funguje GC u generatoru? Pokud budu mit nekonecny generator (napr. ten fibonacci), jak vi runtime kdy uz ho muze smazat? Nebo je nejaka konvence ze pokud vim ze generator uz nebudu potrebovat, tak ho necham dobehnout (napr. ve fib. generatoru mu z kodu poslu jako hodnotu false, nebo mu dam vyjimku)?

Martin Hassman

Nevím, ale je to zajímavý dotaz, tak jsem chvíli přemýšlel a Googlil a zdá se, že to (v jiných jazycích) řeší klasicky pomocí referencí, tak bych tipoval, že to v JS bude podobné – jakmile na generátor nebude směřovat žádná reference, musí být odklizen. Každopádně generátor má i metodu close(), který zajistí jeho ukončení a spuštění bloku finally, pokud je v něm definován, viz http://wiki.ecmascript.org/doku.php?id=harmony:generators#methodclose

keff

Diky, to me mohlo napadnout :))). A dik za metodu close!

Jiří Knesl

No jo, jenže co když je generátor globálně přístupná funkce?
Tam bude vývojář nejspíš odkázán na ruční volání close()

Martin Hassman

I takovou globální funkci můžeš zrušit třeba jejím předefinováním. Ale jinak ano, close v takovýchhle případech ideálně poslouží.

fos4

Díky za díl a těším se na dalším. Rád bych byl za mongo/mongoosejs – neboť s tím nepracuji každý den..

eles

Určitě ukažte i MySQL. Díky za článek.

Ondra Melkes

Co třeba relační databázi (je celkem jedno zda postgree, nebo mysql/maria) s právě zmíněným redisem jako session a cache?

capajj

Taky by raději viděl koa+mongoose, ale i koa +http://sequelizejs.com/ by mě zajímalo.

Jeník

Tak by mě zajímalo, jak se řešíte ORM a cache.

jlx

Sequelize a Mongoose zmíněné výše jsou v podstatě ORM knihovny.

Jeník

A na MVC je ten fw koa umí i šablony jako latte?

Jeník
Jeník

To je jasné, ale zas tak špatné to není.

Ondra Melkes

Myslím že je to špatné… porovnávat by ještě šlo platformu, tedy NodeJs vs PHP ale ne platformu a framework, tedy NodeJs vs Nette.

Martin Hassman

V tomhle s vámi Pavle a Ondřeji nemůžu souhlasit. Ale to je tím, že já nesouhlasím s běžným rčením „Nelze srovnávat jablka s hruškami.“ Pro mnohé je tohle axiom, který je pravdivý a z toho se pak odvíjí třeba i ono srovnání Node vs Nette. Podle mě je tohle tvrzení nepravdivé, vyvrátím ho snadno během jediného nákupu, kdy se budu rozhodovat mezi nákupem jablek a hrušek a jejich srovnání se tak prostě nevyhnu (a moc nevěřím, že to někdo dokáže).

Podle me je zcela regulérní srovnávat Node a Nette, pokud se z onoho srovnání nebudme snažit vyčíst to, co tam není. Ono můžete klidně srovnávat třeba tvorbu webu v Node.js třeba s vylepením billboardu u dálnice – pro někoho i taková otázka může mít význam a odpověď může také dávat smysl: Zvýší se návštěvnost mé kavárny víc vylepením billboardu nebo vytvořením microsity v Node.js? Je to správná otázka. Pochopitelně z odpovědi bychom vůbec nezjistili, zda je Node.js lepší/horší než billboard, takový závěr by byl špatný. Ale otázka je zcela správná jako majitelk kavárny si ji klidně mohu klást.

Stejně tak ze srovnání Nette a Node.js nezjistíme, kdo z nich je lepší, ale jak jsem si právě přečetl v úvodu té diplomky, cíl byl hlavně studijní a z toho pohledu je to správná otázka. Nečetl jsem celou práci, nevím jaká je a nehodnotím ji, jen jsem se podíval do závěru a vidím tam např. že „Node má horší dokumentaci“ – to může být korektní závěr, nevím zda je pravdivý, po nedávném rozhovoru s Davidem mám naopak pocit, že je težké mít horší dokumentaci než Nette, ale může to být jen dojem. Ale je to validní závěr, který můžeme potvrdit anebo zpochybnit (nedělám ani jedno, nesrovnával jsem ty dokumentace) a můžeme z toho i vyvozovat (jak ona práce dělá), že XY je horší pro začátečníky, protože má horší dokumentaci.

Dokud to budeme brát takhle a nebude z toho vyvozovatm že XY je horší (protože to už je jiný výrok a jeho pravdivost z takového porovnání ověřit nejde), tak je vše v pořádku.

Stejně tak můžu tvrdit, že mám radši jablka a tudíž jablka jsou pro mě lepší, což ovšem nic neříká o tom, zda jablka jsou lepší než hrušky.

Z toho pohledu mi takové zadání přijde v pořádku. Ano, je to akademické, možná nepraktické a v rukou „amatéra“ mohou z takové diplomky vyplynout třeba zcela zcestné závěry, ale to srovnávání samo o sobě je v pořádku.

Ondra Melkes

Pozor, ono má smysl srovnávat hrušku s jablkem, ale nevím jak velký má smysl srovnávat hrušku s jabloní.

Neboli, jako majitel kavárny porovnám bilboard s webstránkou, nebo reklamou v rádiu. Ale už mě jako majitele nezajímá to jestli bude udělaná v Angularu, nebo Nette, zda běží nad Javascriptem, nebo nad PHP.

Naopak jako programátora mě velmi zajímá použitá technologie a proto chci rozumě porovnat nástroje. Můžu porovnat vývoj nad čistým NodeJs s vývojem nad PHP+Nette, ale měl bych obojí dobře znát, používat best-practicies. A porovnávat alespoň s čistým Node bez MVC. Protože pak není patrné co vlastně porovnává – Locomitive? Node? Jiný z použitých balíčků?
Odkazovanou studii jsem čelt trochu víc než jen úvod se závěrem a právě to mi na ní vadí. Autor zná PHP + Nette, Node a Javascript méně.

A tímto se dostávám k tomu že bych neměl porovnávat hrušku s jabloní o které tvrdím že je to jablko. Jak by celé srovnání dopadlo, když by místo Locomotive použil třeba SailJs?
Nebylo by dobré na úvod zmínit menší zkušenost autora s Node? Minimálně je vhodné v úvodu zmínit, že se jedná o platformu PHP a Nete, kterou porovnávám s NodeJs a MVC frameworkem Locomotive.

Martin Hassman

Pavle a Ondřeji, díky za vysvětlení, myslím, že vás teď chápu o něco líp. Přesto nedokážu s vámi souhlasit, z pohledu zadavatele tématu mi přijde pokus o takové srovnání v pořádku. Nicméně nechci obhajovat tu konkrétní práci, to nebylo mým cílem, ani jsem ji na to pořádně nečetl.

Jeník

Stačí si nainstalovat Virtual Box?

jlx

Virtual box snad ani není potřeba. V Linuxu a v OSX stačí prostě stáhnout binárku z http://nodejs.org/download/ a vybalit do home adresáře (stačí jenom /bin a /lib). Pak ještě mrknout do ~/.profile, jestli je tam cesta do ~/bin. Pokud ne, tak doplnit
PATH="$HOME/bin:$PATH"

Takhle to používám s úspěchem lokálně a občas i na serveru. V package systémech bývá většinou dost zastaralá verze. Pro Windows úplně netušim, i když tam je zase k dispozici instalátor.

Jeník

Chápu, ale co v případě, že používám php + mysql, postgre na osx?
Připadá mi, že pak moc drátuji a může dojít k konfliktům.

jlx

Toho bych se nebál. Celej NodeJS se skládá ze dvou spustitelných souborů („bin/node“ a „bin/npm“) a knihovny modulů („lib/*“) – takže není moc míst, kde by ten konflikt mohl nastat. A smazat z home adresáře to jde vždycky..

LesTR

Jo, a pak budu chtít jinou verzi node.js a zvencnu se z toho. Ne už sakra dlouhou dobu je zde https://github.com/creationix/nvm

Jeník

K testování používate Mocha?

Jan Prachař

Pointa článku mi přijde dost zavádějící. Asynchronních callbacků jsem se nezbavil díky generátorům, ale díky thunkům a funkci co. Abych podpořil své tvrzení argumenty, tak tento příklad uveděný ve článku

co(function *() {
    return (yield read('./generator1.js', { encoding: 'utf-8' })) +
        (yield read('./generator2.js', { encoding: 'utf-8' }));
})(function(err, result) {
    console.log(result);
});

lze jednoduše přepsat bez použití generátorů takto:

co2(function () {
    return [read('./generator1.js', { encoding: 'utf-8' }),
        read('./generator2.js', { encoding: 'utf-8' })];
}).then(function(err, result) {
    console.log(result.join());
});

Přičemž funkce co2 bude vypadat nějak takto:

function co2(fn) {
    var i = 0, results = [];
    fn = fn();
    return new Promise(function(resolve, reject) {
        next(fn[i++]);
        function next(ret) {
           ret(function (err, res) {
               if (err) return reject(err);
               results.push(res);
               if (i >= fn.length) return resolve(results);
               next(fn[i++]);
           });
        }
    });
}
Jan Prachař

Podrobněji vysvětlené je to v tomto článku http://www.zdrojak.cz/clanky/promise-v-javascriptu/

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.