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

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.

Přes 15 let jsem programoval pro desktop, v posledních několika letech jen pro web. Vyzkoušel jsem si všechny možné programovací jazyky a prostředí od QBasicu, Pascalu, Assembleru (x51, x86), Deplhi (Object Pascal), C++, lehce Javu, dost C# a pak také ty dynamické jazyky jako PHP, Python, Perl. Ochutnal jsem LISP, byl to první interpret, který jsem si implementoval (hned po RPN), narazil jsem i na Haskell… Pak jsem se naučil JavaScript a zamiloval se, i když to chvilku trvalo :-) Ve volném čase se věnuji Node.js a komunitě kolem JavaScriptu. Občas si pro radost něco dám na GitHub

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 41

Přehled komentářů

Lukas Rychtecky Korektura
Martin Hassman Re: Korektura
Lukas Rychtecky Re: Korektura
Pavel Lang Re: Korektura
jjjjj diky za clanok
jjjjj fibonacci postupnost
Martin Hassman Re: fibonacci postupnost
keff Garbage collection generatoru?
Martin Hassman Re: Garbage collection generatoru?
Pavel Lang Re: Garbage collection generatoru?
keff Re: Garbage collection generatoru?
Pavel Lang Re: Garbage collection generatoru?
Jiří Knesl Re: Garbage collection generatoru?
Martin Hassman Re: Garbage collection generatoru?
Pavel Lang Co dál?
fos4 Re: Co dál?
Pavel Lang Re: Co dál?
eles Re: Co dál?
Ondra Melkes Re: Co dál?
capajj Re: Co dál?
Jeník Pokud MySQL
jlx Re: Pokud MySQL
Jeník Re: Pokud MySQL
Jeník Celkem zajímavé srovnání Nette a NodeJs
Pavel Lang Re: Celkem zajímavé srovnání Nette a NodeJs
Jeník Re: Celkem zajímavé srovnání Nette a NodeJs
Ondra Melkes Re: Celkem zajímavé srovnání Nette a NodeJs
Martin Hassman Re: Celkem zajímavé srovnání Nette a NodeJs
Pavel Lang Re: Celkem zajímavé srovnání Nette a NodeJs
Ondra Melkes Re: Celkem zajímavé srovnání Nette a NodeJs
Martin Hassman Re: Celkem zajímavé srovnání Nette a NodeJs
Jeník Co používat pro vývoj v začátcích?
jlx Re: Co používat pro vývoj v začátcích?
Jeník Re: Co používat pro vývoj v začátcích?
jlx Re: Co používat pro vývoj v začátcích?
LesTR Re: Co používat pro vývoj v začátcích?
Jeník Testování
Jan Prachař
Jan Prachař Re: Zavádějící pointa
Pavel Lang Re: Zavádějící pointa
Pavel Lang Re:
Zdroj: https://www.zdrojak.cz/?p=11277