Konečne poriadny Javascript, edícia 2017

napísal , 8 Jun 2017 [ JavaScript NodeJs ]

Ešte stále používaš v Javascripte Promises, ako za čias Márie Terézie? Alebo nebodaj callbacky, ako ich písal ešte Július Cézar? (čo malo za následok pád Rímskej ríše)

To nevadí. Svet Javascriptu sa hýbe šialeným tempom a treba vynakladať veľké úsilie, aby bol človek v obraze. Ukážeme si, ako sa v roku 2017 vieme definitívne vysporiadať s odvekým problémom Javascriptu a hlavne node.js - asynchrónnymi volaniami a takzvaným callback hell (pre ortodoxných slovenčinárov: peklo spätných volaní).

Všetky metódy si ukážeme na pomerne jednoduchom node-ovskom príklade s Mongooose/MongoDB, kde chceme:

  • vymazať článok z databázy
  • vymazať súbory, ktoré boli k nemu uploadnuté
  • aktualizovať čas poslednej aktivity autora článku

Tento príklad bude stále jednoduchší, čím viac budeme postupovať do prítomnosti.

Callbacks

Pôvodne bol node postavený čisto na callbackoch, čo bol absolútne šialený nápad, pretože z písania akéhokoľvek kódu, ktorý by bol v iných jazykoch triviálny, išla expódovať hlava. Tento škaredý príklad ukazuje aké utrpenie bol node vo svojich začiatkoch a pre zachovanie duševného zdravie ho kľudne iba prebehni očami.

var fs = require('fs');

function deleteArticle(articleId, callback) {
  ArticleModel.findById(articleId, function(err, article) {
    if (err) return callback(err);
    if (!article) return callback(new Error('Not found'));

    var filesToDelete = 0;

    for (var i in article.files) {
      deleteFile(article.files[i].filename);
    }

    function deleteFile(file) {
      filesToDelete++;

      fs.unlink(file, function() {
        filesToDelete--;
        if (!filesToDelete) onFilesDeleted();
      });
    }

    function onFilesDeleted() {
      article.remove(function (err) {
        if (err) return callback(err);

        UserModel.update(
          { _id: article.author },
          { lastActivity: article.updated },
          function(err, author) {
            if (err) return callback(err);

            callback(null, articleId);
          } // Pyramid
        ); // of
      }); // doom
    } // Pyramid
  }); // of
} // doom

deleteArticle('56245367e4b0ce6cb5295880', function(err, id) {
  if (err) {
    console.error('Článok sa nepodarilo vymazať, lebo', err);
  } else {
    console.log('Článok ' + id + ' bol vymazaný');
  }
});

Jednak je tu problém, že čím viac asynchrónnych operácií po sebe nasleduje, tým viac sa callbacky do seba zanárajú a vzniká pyramid of doom. Dvak, kvôli obyčajnému prejdeniu a pomazaniu súborov je potrebné si počítať koľko ich zostáva zmazať* (filesToDelete) a vytvoriť kvôli tomu 2 ďalšie funkcie deleteFile, onFilesDeleted. A do tretice, na začiatku každého callbacku je potrebné kontrolovať, či nenastala chyba. Celkovo je takýto kód viac o bojovaní s asynchrónnosťou, ako o samotnej logike. Je to celé zle.

Tu možno pozornému oku neunikne, že namiesto fs.unlink() by sa dala použiť funkcia fs.unlinkSync(). To je síce pravda, ale táto funkcia zablokuje celý node, kým sa nedokončí operácia, čo by na produkčnom serveri zabíjalo výkon.

*toto sa viac menej dá poriešiť knižnicou async, nie je to ale príliš elegantný spôsob.

Promises

Promise (Ludevít Štúr mi cez plece kričí, že po slovensky je to prísľub) je v zásade len spôsob ako baliť callbacky do stráviteľnejšej formy. Zo začiatku ťažko uchopiteľný koncept, ale extrémne užitočný a modernejšie metódy budujú práve na ňom. Objekt Promise je natívne podporovaný prehliadačmi aj Node-om zhruba od roku 2015, predtým bolo treba používať knižnice ako Bluebird. Bluebird budeme používať aj my, pretože má kopec užitočných funkcií navyše, ako napríklad promisify (ehm, sprísľubuj), ktorá zmení nodeovskú "callbackovú" funkciu na funkciu vracajúcu promise.

var Promise = require('Bluebird');
var fs = require('fs');
var unlink = Promise.promisify(fs.unlink);

function deleteArticle(articleId) {
  var article; // toto budeme potrebovat neskor

  return ArticleModel.findById(articleId).then(function(article_) {
    if (!article_) throw new Error('Not found');

    article = article_;
    var deletePromises = [];

    for (var i in article.files) {
      var deletePromise = unlink(article.files[i].filename)
      deletePromises.push(deletePromise);
    }

    return Promise.all(deletePromises);
  }).then(function() {
    return article.remove();
  }).then(function() {
    return UserModel.update({ _id: article.author }, { lastActivity: article.updated });
  }).then(function() {
    return articleId;
  });
}

deleteArticle('56245367e4b0ce6cb5295880').then(function(articleId) {
  console.log('Článok ' + articleId + ' bol vymazaný');
}).catch(function(err) {
  console.error('Článok sa nepodarilo vymazať, lebo', err);
});

Toto je o kus čistejšie riešenie ako s callbackami a akonáhle sa zžiješ s Promismi, z takéhoto kódu už nejde vybuchnúť hlava. Stále to ale nie je ideálne a zložitejšia logika s Promismi je problematická.

Čo sa tu deje: posúvame si promisy a nadpájame na ne ďalšie a s Promise.all čakáme, kým sa naplnia všetky promisy na zmazanie súborov v poli. Výhoda je, že ak nastane kdekoľvek chyba, tak pekne vybuble nahor, až na úroveň, kde bol definovaný catch. Finálna hodnota takisto vybuble až hore. Nevýhoda môže byť predávanie si parametrov - viď article_.

Generátory a koprogramy

A teraz prichádza najväčší skok, kedy sa písanie asynchrónneho JS stáva celkom príjemným. ES6, resp. ES2015 (2 názvy pre to isté - novší Javascript) priniesol kopec zaujímavých zmien. Jednou z nich sú generátory, ktoré mierne zneužijeme ako koprogramy (Coroutines). Generátor je taká divná funkcia (píše sa s hviezdičkou function* bla() { }), ktorá sa volá na viackrát a zakaždým vráti nejakú hodnotu, až kým nedobehne do konca. Koprogram/Coroutine je niečo vzdialene podobné vláknu/threadu, v zmysle, že akoby samostatne beží a v určitých bodoch čaká na nejakú hodnotu alebo udalosť.

Zase raz použijeme Bluebirda, pretože má ďalšiu super funkciu coroutine - s tou obalíme a zmeníme našu hviezdičkovú funkciu na normálnu funkciu, vracajúcu promise. Na toto sa zvykne používať aj knižnica co. V nasledovnom kóde používam ES6 a ak si sa s ním ešte nestretol, let a const si predstav ako var a for..of ako for..in, s rozdielom, že vracia priamo hodnoty, nie indexy.

const Promise = require('Bluebird');
const fs = require('fs');
const unlink = Promise.promisify(fs.unlink)

const deleteArticle = Promise.coroutine(function*(articleId) {
  let article = yield ArticleModel.findById(articleId);
  if (!article) throw new Error('Not found');

  for (let file of article.files) {
    yield unlink(file);
  }
  
  yield article.remove();
  yield UserModel.update({ _id: article.author }, { lastActivity: article.updated });

  return articleId;
});

deleteArticle('56245367e4b0ce6cb5295880').then(function(articleId) {
  console.log('Článok ' + articleId + ' bol vymazaný');
}).catch(function(err) {
  console.error('Článok sa nepodarilo vymazať, lebo', err);
});

Podarilo sa nám dosiahnuť to, že asynchrónny kód vyzerá ako klasický kód. Kľúčové slovo yield (daj/prines, poznámka Ludevíta) spôsobí, že naša hviezdičková funkcia sa v danom bode zastaví, až kým sa nevyrieši promise. Hodnota z promisu sa potom dá normálne priradiť do premennej, ako keby sme volali synchrónnu funkciu. Ak je promise zamietnutý, funkcia sa zastaví a chyba spropaguje až do promisu koprogramu, na samom vrchu. To isté sa stane, keď hodíme v hviezdičkovej funkcii chybu s throw.

Poznámka: hodnota z yield sa dá priradiť len do premennej, takže nemôžme robiť konštrukcie ako napríklad if (yield funkciaSPromisom()) { ... }, treba ísť vždy cez premennú.

Oproti predchádzajúcim dvom príkladom je tu tiež drobný rozdiel a to, že súbory vymazávame sériovo (počkáme na vymazanie prvého a až potom sa zapodievame ďalším), narozdiel od paralelného mazania vyššie. Niekedy sú výhodnejšie paralelné, niekedy sériové operácie. Každopádne, sériové async operácie vieme v koprogramoch zapísať veľmi elegantne, narozdiel od predchádzajúcich spôsobov.

Async/await

Async/await (časovo nesúrodý/vyčkaj, poznámka Ludevíta) sú ešte novotou voňajúce kľúčové slová z ES2017, resp. ES7. Dajú sa použiť bez akýchkoľvek ďalších pomôcok už pár mesiacov, od kedy vyšiel Node.js v7.6.0, resp. Chrome 55. V podstate to sú len o kúsok prehľadnejšie a jazykom odobrené koprogramy/coroutines. Hlavná výhoda je, že vďaka oficiálnemu odsúhlaseniu sú definitívnym riešením vsetkých asynchrónnych problémov a zrejme ním zostanú až do konca Javascriptu.

const Promise = require('Bluebird');
const fs = require('fs');
const unlink = Promise.promisify(fs.unlink)

async function deleteArticle (articleId) {
  let article = await ArticleModel.findById(articleId);
  if (!article) throw new Error('Not found');

  for (let file of article.files) {
    await unlink(file);
  }
  
  await article.remove();
  await UserModel.update({ _id: article.author }, { lastActivity: article.updated });

  return articleId;
}

deleteArticle('56245367e4b0ce6cb5295880').then(function(articleId) {
  console.log('Článok ' + articleId + ' bol vymazaný');
}).catch(function(err) {
  console.error('Článok sa nepodarilo vymazať, lebo', err);
});

Oproti predchádzajúcemu príkladu sa toho moc nezmenilo, iba sme vymenili yield za await a Promise.coroutine(function*() ...) za async function() .... Toto je momentálne najčitatelnejší spôsob písania asynchrónneho JS. Oproti callbackovej abominácii z prvého príkladu, je to seriózny rozdiel.

Can I use...

Koprogramy/coroutines sú k dispozícii už pomerne dlho (od node 4.0) a async/await sa dá použiť v aktuálnom Node.js (7.6.0+, v LTS verzii zatiaľ nie) a najnovších prehliadačoch. S Babel-om môžme používať obe prakticky hocikde, dokonca aj v zaprdenom IE. Babel má, samozrejme, tú nevýhodu, že sa s ním treba serkať. Ale akonáhle robíš na väčšom projekte s veľa súbormi, tak či tak potrebuješ Webpack alebo niečo podobné a tam sa dá Babel pridať s minimálnou námahou. Takže ak môžeš, ušetri si nervy a používaj async/await.

Pozdravuje Mária, Terézia, aj Cézar a želajú príjemné písanie Javascriptu z roku 2017, ktorý by aj oni radi používali, keby môžu.

napísal , 8 Jun 2017

8 komentárov

komentuj ku každému komentáru sa v databáze ukladá iba meno, text a dátum, iba za účelom zobrazenia pod článkom
neukladá sa email, IP adresa ani informácie o prehliadači a údaje sa nepoužívajú na reklamu, newsletter, na žiadnu ekonomickú aktivitu, nikam sa neposielajú, sú v databáze len aby sa mohli zobraziť pod článkom
  1. marek [ Štvrtok 8.6.2017, 13:40 ]

    A čo zmaže ten článok v db v ostatných príkladoch okrem callbacku? Nejako tam nevidím article.remove() ...

  2. blade [ Štvrtok 8.6.2017, 13:59 ]

    Marek, vyhravas cukrik za to, ze si aj cital kod a ze si si to vsimol. :) Opravene.

  3. St.anus [ Štvrtok 8.6.2017, 18:40 ]

    Super článok, ale keďže chcem cukrík, tak kuk v promises verzii

    catch(function() {
    console.error('Článok sa nepodarilo vymazať, lebo', err);

    Kde sa vzal err?

  4. Mario [ Štvrtok 8.6.2017, 20:02 ]

    Preco pouzivas "callback" parameter v poslednom priklade vo funkcii:

    async function deleteArticle (articleId, callback)()

    ked ho nikde nevolas?

  5. blade [ Piatok 9.6.2017, 09:35 ]

    St.anus - diky, err chybal v Promises verzii aj vsetkych nasledujucich.

    Mario - hej, to je chyba, dik. Bolo to aj v predoslych, lebo tie priklady som kopiroval a upravoval, tak sa aj chyby skopirovali.

    Obidvaja ziskavate samozrejme cukrik a ked ma IRL stretnete, nezabudnite si ho vypytat :)

  6. Robert [ Piatok 9.6.2017, 12:05 ]

    nebolo by lepsie pri tom poslednom priklade
    " for (let file of article.files) {
    unlink(file);
    } "
    nech to robi asynchronne? ved ten unlink sam je promise a ked zahlasi pri kt. file sa to dosralo (resp detaily erroru?.
    Pri tych ostatnych chapem zmysel await-u

    Dik, Robo

  7. blade [ Sobota 10.6.2017, 08:32 ]

    V zasade by to fungovalo, ale nebolo by to korektne, pretoze necakas na tie promisy a ak by niekde v nich nastala chyba, nic by ju nezachytilo a node by ti hodil warning, ze unhandled promise rejection.

    S awaitom je ten kod stale asynchronny. Await neblokuje cely node, iba danu async funkciu (resp v 3. priklade generatorovu funkciu).

    Mozno si ale myslel, ci by nebolo lepsie mazanie suborov pustit paralelne. To by si spravil tak, ze by si nastrkal promisy mazania do pola a nasledne:
    await Promise.all(polePromisov);
    podobne ako v 2. priklade.

    Ci je z hladiska vykonu lepsie v node mazat subory seriovo alebo paralelne, neviem posudit, to by chcelo zbenchmarkovat.

  8. Samuel [ Nedeľa 6.10.2019, 15:50 ]

    Ahoj rok 2019

ku každému komentáru sa v databáze ukladá iba meno, text a dátum, iba za účelom zobrazenia pod článkom
neukladá sa email, IP adresa ani informácie o prehliadači a údaje sa nepoužívajú na reklamu, newsletter, na žiadnu ekonomickú aktivitu, nikam sa neposielajú, sú v databáze len aby sa mohli zobraziť pod článkom