A JavaScript programozásban rengeteg aszinkronitással találkozunk kezdve az események kezelésétől egész az Ajax hívásokig. Ez a fejezet a korszerű aszinkron programozási mintákba és technológiákba nyújt betekintést.
Talán nem túlzás kijelenteni, hogy a szoftverfejlesztés egyik legnagyobb hatású, legmeghatározóbb eseménye az volt, mikor széles körben elterjedtek a több szálra írt programok. Az úgynevezett MultiThreaded alkalmazások kódjuk egy bizonyos részét párhuzamosan is képesek futtatni, így például egy bonyolultabb matematikai számítást részekre bontva és azokat egyszerre végrehajtva jelentősen hatékonyabban futhatnak, mint az egy szálon működő társaik. Mivel a különböző szálak viszonylag jól elszeparálhatóak, így az operációs rendszer az ilyen programot egyszerre több processzor-magon is futtathatja, bár ez nem szükségszerű. Ennek fényében nem meglepő, hogy a párhuzamos programozás mainstream elterjedése, kitörése a szerverek világából akkorra datálható, mikor a Moore törvényt feszegetve a processzor-gyártók már nem tudták tovább növelni az egységek sebességét, ehelyett az asztali CPU-k is egyre több magot kaptak.
A többszálú alkalmazások írása nem csupán játék és móka, rengeteg problémával szembesülünk, ha egy hagyományos MultiThreaded szoftvert írunk. A legváratlanabb pillanatban alakulhat ki Deadlock, vagyis olyan helyzet, mikor több különböző folyamat verseng ugyanazokért az erőforrásokért és valamilyen szerencsétlen véletlen folytán vagy nem várt esemény hatására egymást kezdik blokkolni. Számolnunk kell a Race Condition-ökkel, azaz olyan eseményekkel, mikor a konkurens szálak ugyanahhoz az adathoz nyúlnak hozzá és módosítják úgy, hogy a módosítás sorrendje nem garantált. Ennek köszönhetően előállhat olyan helyzet, mikor egy feltétel az egyik szálon igazra értékelődik ki, de mire belép a feltétel igaz ágába, az adatot már megváltoztatta egy másik szál és ő erről nem tud semmit!
Egy többszálú programban előállhat a következő anomália:
if (x === 5) { // Itt a feltétel még igaz // Ezen a ponton a másik szál módosítja // x értékét... // ... így itt már nem 10-et fogunk kapni print(x*2); }
E problémák elkerülésére rengeteg módszer létezik, azonban a konkurencia-kezeléssel mindvégig számolni kell, számtalanszor megnehezíti a fejlesztő életét. A konkurens-szálakra visszavezethető bugokat nagyon nehéz megtalálni és általában kiszámíthatatlanul jelennek meg.
A JavaScript ezzel szemben nem MultiThreaded platform, minden program — fusson a böngészőben vagy Node.js alatt — csupán egyetlen szálat használ. A nyelv egy sokkal átláthatóbb, egyszerűbb, mindazonáltal korlátozottabb modellt biztosít az aszinkron kód emulálására, amely azonban mégis kellőképp hasznos és mentes a fent említett konkurencia-problémáktól. Bár a JavaScript megvalósítása sem garantálja az aszinkron kódok lefutásának sorrendjét, azonban ez mégis más, mint a többszálú programok esetén, hiszen a futtatókörnyezet minden esetben csak egyetlen kódot futtat egyszerre.
Ahhoz, hogy megértsük, hogyan is vezérlik az aszinkron események egy JavaScript program működését, a futtatókörnyezet három fontos részével kell megismerkednünk: ez a Heap, a Stack és a Queue.
A Heap tartalmazza a programunk azon részét, ami már lefutott — így ide kerülnek a függvénydefiníciók, a lefuttatott függvények által létrehozott, még létező névterek, az alkalmazás jelen állapotában elérhető összes változó, és így tovább. Ez a referencia és adathalmaz meglehetősen kaotikus, semmi sem biztosítja a rendezettségét.
A Stack-ben az éppen futás alatt álló függvények, programrészek vannak — egy olyan rendezett tárként képzelhetjük el, amely egyre mélyül, ahogy a függvények egymást hívogatják és várnak a másik visszatérési értékére. A futtatókörnyezet ezen része folyamatos kapcsolatban áll a Heap-el, hiszen bármikor új elemet emelhet át onnan a Stack-be, attól függően, hogy az épp futó folyamatok hogyan is döntenek. Bár meglehetősen ritkán fordul elő, azonban a túlságosan mély rekurzív hívások elérhetik a rendelkezésre álló Stack maximális méretét, ekkor megszakadhat a program futtatása. A futtatókörnyezet szinkron módon működik, azaz egyszerre csak egy parancsot képes végrehajtani, így például, amíg a böngésző a Stack-en dolgozik, addig a UI blokkolva van — vagyis hosszasabb műveletek során a felhasználó akadásokat, sőt a felület teljes fagyását észlelheti.
Minden JavaScriptben írt aszinkron kód egy callback függvényt hív meg egy, az őt befejező esemény bekövetkezésekor. Egy egyszerű setTimeout esetén ez a beállított késleltetéskor következik be; egy AJAX hívásnál akkor mikor visszatér valamilyen eredménnyel az aszinkron request; egy click eseménynél pedig a felhasználó által kiváltott klikkelésre várjuk egy függvény lefutását. Ezeket a függvényeket a megfelelő időben a Message Queue-ba pakolja a futtatókörnyezet, egy sorba a jövőben futtatandó feladatok számára. Ebből a tárból már egyesével kerülnek át a callback függvények a Stack-be, egymás után, ahogyan az képes feldolgozni őket. Mivel a feladatok lefutási ideje különböző lehet és több ugyanakkor indukált függvény feltorlódhat a Queue-ban, ezért sohasem várható el azok pontos futtatási ideje. A setTimeout második paramétereként átadott 500 csak annyit jelent, hogy függvényünk 500ms múlva kerül a Queue végére, de semmi sem biztosítja, hogy ekkor le is fog futni. Amennyiben több konkurens aszinkron callback is várakozik, akkor a késleltetett metódusunk is megvárja a sorát.
// Aszinkron hívás késleltetett metódussal: // a setTimeout 1000ms-al a deklaráció után a Queue-ba // teszi az első paraméterben található függvényt setTimeout(function() { console.log('Aszinkron hívás történt!'); }, 1000); console.log('Az Aszinkron hívás előtt lefut, hiszen' + 'ez a parancs már a Stack-ben van!'); // Aszinkron hívás user inputra: // a callback függvény akkor kerül be a Queue-ba, ha // a felhasználó rákattint a gombra document.getElementById('supaButton').addEventListener( 'click', function() { console.log('Aszinkron hívás történt!'); } );
Néhány fontos dologról még nem esett szó: vajon mi történik az aszinkron hívás közben, és mi indukálja a callback-ek Queue-ba kerülését?
A JavaScript a Heap-Stack-Queue felépítés fölött egy úgynevezett Event loop folyamatot futtat, ami az aszinkron hívások külső eseményeit figyeli és az általuk visszaküldött jelzéseket, callback-eket ütemezi a Queue-ba. Az Event loop legfontosabb tulajdonsága, hogy mindeközben nem blokkolja a JavaScript folyamatokat!
Ha jobban megvizsgáljuk, akkor az aszinkron műveletek belső működése (AJAX kérés, böngészőesemények figyelése) a programunktól elszeparált, rendszer- vagy kliens-közeli műveletek — nekünk csak jól kell felparamétereznünk őket. Ezek a környezet-specifikus feladatok a JavaScript motortól teljesen függetlenül futnak, eközben pedig nem akasztják meg a programunkat! Ha az előbbi példákat nézzük, akkor se a setTimeout, se a click eseményre váró folyamat nem blokkolja a JavaScript futást – az ezeket az eseményeket kezelő folyamatok a háttérben zajlanak. Amint valami olyan történik, amiről a programunknak tudnia kell, az átadott callback függvényt fogják a Queue-ba küldeni.
Egy webszerverre párhuzamosan sok különböző felhasználó csatlakozhat, a JavaScript azonban csak egyetlen thread-et használ az összes request kielégítésére. Ha az alkalmazásunk teljesen szinkron módon működne, akkor egy felhasználónak mindig meg kellene várnia, hogy az előző ki legyen szolgálva. Könnyen belátható, hogy ez a módszer az adatbázis-lekéréseket és I/O műveleteket tartalmazó kérések esetén tarthatatlan lenne. Ehelyett a JavaScript programokat úgy próbáljuk megírni, hogy minél több aszinkron hívást tartalmazzon, hiszen, amíg ezek kielégítésére vár a futtatókörnyezet, addig egy másik felhasználó kérésével tud foglalkozni. Ha minden komplexebb műveletet hasonlóan gyors aszinkron futási egységekre tudunk bontani, akkor a Node.js párhuzamosan és nagyon gyorsan tudja kiszolgálni az ügyfelek tömegeit. Ezek után nem meglepő, hogy a Node.js API-k többnyire aszinkron futásra lettek tervezve és általában callback függvényeket várnak paraméterként.
Az előbbiekre szemléletes példa egy fájl tartalmának beolvasása:
fs.readFile('usernames.csv', function (err, data) { if (err) throw err; console.log(data); });
Az fs.readFile meghívása után a vezérlés továbbadódik — mivel a fájl beolvasása a háttérben történik, nem blokkol, így a szerver a többi kérés kiszolgálásával foglalkozhat. Amint a futtatókörnyezet beolvasta a fájlt, az Event loop segítségével a Queue-ba teszi a callback függvényt, paraméterként átadva a fájl tartalmát.
A szerver oldalhoz hasonlóan a böngészőkben való futtatás esetén is törekedni kell arra, hogy a számításigényes feladatokat aszinkron műveletekre osszuk fel, mivel a Stack feldolgozása során a felhasználó interfész blokkolva van!
Nézzünk egy egyszerű példát kliens oldali aszinkron kódra: tegyük fel, hogy egy óra alkalmazást írunk mégpedig úgy, hogy egy gombbal el tudjuk indítani, illetve, ha épp jár az óra, akkor meg tudjuk vele állítani.
var Clock = function(buttonElement) { this.tickTimer = 0; if (buttonElement) { buttonElement.addEventListener( 'click', this.toggleRun.bind(this)); } }; Clock.prototype.start = function() { ... // Az óra UI és adatok inicializálása this.scheduleTick(); }; Clock.prototype.pause = function() { this.tickTimer = this.tickTimer && clearTimeout(this.tickTimer); }; Clock.prototype.scheduleTick = function() { if (this.isRunning()) { this.pause(); } this.tickTimer = setTimeout(this.tick.bind(this), 1000); }; Clock.prototype.toggleRun = function() { this.isRunning() ? this.pause() : this.scheduleTick(); }; Clock.prototype.isRunning = function() { return !!this.tickTimer; }; Clock.prototype.tick = function() { ... // Óra animáció this.scheduleTick(); }; // Óra indítása: var button = document.getElementById('clockBtn'), clockWidget = new Clock(button); clockWidget.start();
A program első körben felépíti az órát, lerendereli a HTML kódot és beilleszti a weboldalba. Ezt követően elindít egy timer-t, másodpercenként meghívva az óra mutatóját arrébb mozgató metódust. Ez az időzítés valójában már egy aszinkron kód, hiszen az eredeti program lefutását követően, egy esemény kiváltódásakor fog lefutni. A gombra egy másik eseménykezelőt kötöttünk: a click eseményre megvizsgálja az óra állapotát és annak megfelelően leállítja vagy elindítja az előbbi timer-t. Ez ugyancsak aszinkron esemény, melynek kiváltása ráadásul a felhasználótól függ (klikkelés).
Most már talán mindenki számára világossá vált, hogy milyen előnyei és hátrányai vannak a JavaScript által prezentált aszinkron módnak. Foglaljuk össze ezeket pár szóban:
A JavaScript a Web Worker-ek segítségével lehetőséget ad valódi, külön szálon futó aszinkron kód írására is, ami azonban — elkerülendő a szál-szinkronizálási problémákat — jelentős megszorításokkal rendelkezik: teljesen el van szeparálva a főszáltól, azzal üzenetek küldése-fogadása segítségével kommunikál. Egy Web worker thread külön Event loop-al, Heap-el, Stack-el és Queue-val rendelkezik, természetesen a maga kis világában szinkron módon fut.
Ahogy a fenti példákban is látható, a JavaScriptben lépten-nyomon aszinkron kódot írunk. Nem elég, hogy a beépített API-k nagy része és az eseménykezelők is aszinkron módon működnek - a jó performancia és a konzekvens kód érdekében minket is erre ösztönöznek. Szerencsére a First class függvények, a callback-ek segítségével nagyon egyszerű mindezt kivitelezni — azonban az így kialakuló, egymás után láncolt események kezelése idővel kód-tervezési, karbantartási problémákhoz vezethet.
Nézzünk meg egy egyszerű példát: képzeljük el, hogy webalkalmazásunkban beállíthatjuk az otthoni lakcímünket, melyet később több helyen is megtekinthetünk, ha rákattintunk a megfelelő gombra. Ha így teszünk, akkor egy ablak nyílik egy Google térképpel, ahol be van jelölve a megadott cím és ránézhetünk a környékre is.
A feladathoz néhány dolog a rendelkezésünkre áll:
Vázoljuk is fel a legegyszerűbb megoldást, mely a feladat megoldásához szükséges API-k callbackjeit használja a lehető leghagyományosabb módon:
var dialog = new Dialog(), mapRenderer = new MapRenderer(dialog); $('#homeButton').on('click', function() { dialog.open(function() { mapRenderer.render(); $.getJSON('/getHomeInfo', function(home) { GMaps.geocode({ address: home.address, callback: function(position) { mapRenderer.addAddress(home, position); } }); }); }); });
Először is létrehozzuk a Dialog és a MapRenderer objektumokat. Ezt követően a következő aszinkron eseményeket állítjuk be:
Már ezen a viszonylag egyszerű kódon is láthatjuk azt a problémát, ami gyakran kialakul a sok egymásba ágyazott callback használata esetén: az egyre több futási szint egy folyamatosan növő piramist eredményez, melyet egyre nehezebb áttekinteni, debuggolni és karbantartani. Ezt a jelenséget szokás Pyramid of doom vagy Callback Hell néven is emlegetni és a feloldására több különböző megoldás is napvilágot látott.
A következőkben áttekintjük a láncolt események problémáinak elkerülését célzó, legnépszerűbb módszereket.
Egy egyszerű és hatásos megoldásnak tűnik, hogy ahelyett, hogy helyben megírt anonim callback-eket használnánk, kiemeljük ezeket külön metódusokba.
Legjobb lesz, ha a Dialog osztályunkat körbeöleljük egy saját osztállyal, amely már azt a viselkedést valósítja meg, amire szükségünk van:
var HomeDialog = function() { this.dialog = new Dialog(); this.mapRenderer = new MapRenderer(this.dialog); }; HomeDialog.prototype.open = function() { var render = this.render.bind(this); this.dialog.open.call(this.dialog, render); }; HomeDialog.prototype.render = function() { this.mapRenderer.render(); this.getHomeData(this.renderHome); }; HomeDialog.prototype.getHomeData = function(callback) { $.getJSON('/getHome', (function(homeInfo) { this.home = homeInfo; callback(); }).bind(this); }; HomeDialog.prototype.renderHome = function() { GMaps.geocode({ address: this.home.address, callback: this.renderPosition.bind(this) }); }; HomeDialog.prototype.renderPosition = function(position) { mapRenderer.addAddress(this.home, position); }; var homeDialog = new HomeDialog(); $('#homeButton').on('click', homeDialog.open.bind(homeDialog));
Ennek a megoldásnak több előnye is lett. Egyrészt az anonim függvények felszámolásával sikerült jól elkülöníthető, átlátható és önmagukat dokumentáló metódusokra szétszedni a callback piramist. Másrészt lehetőséget ad további refaktorálásokra, amivel szebb és tisztább kódot kaphatunk.
A fenti esetben például a HomeDialog osztálynak túl sok különböző felelőssége van, olyan dolgokat tesz, amit nem biztos, hogy neki kellene. Ezen túl más probléma is van vele: nevezetesen a metódusok hívási sorrendje egyáltalán nem triviális, semmi sem akadályoz meg abban, hogy máshogy láncoljam az eseményeket, ezzel elrontva a működést.
Ha egy hasonló osztály tesztvezérelt módon íródik, akkor a metódus hívási sorrendet, de legalábbis a helyes működést az elkészült tesztek jól meghatározzák és a véletlen elrontás lehetősége a minimálisra szorul.
Javítsunk kicsit az előbbi problémákon egy kis refaktorálással. Könnyen látszik, hogy a getHomeData metódust és a renderHome-ban található Google API kérést ki lehetne emelni egy HomeInfo illetve egy PositionProvider osztályba, ezzel sokat tisztulna a kép:
var HomeDialog = function() { this.dialog = new Dialog(); this.mapRenderer = new MapRenderer(this.dialog); this.homeInfo = new HomeInfo(); this.positionProvider = new PositionProvider(); }; HomeDialog.prototype.render = function() { this.mapRenderer.render(); this.homeInfo.get(this.renderHome.bind(this)); }; HomeDialog.prototype.renderHome = function(homeInfo) { this.positionProvider.get(homeInfo, this.renderPosition.bind(this)); }; HomeDialog.prototype.renderPosition = function(homeInfo, position) { mapRenderer.addAddress(homeInfo, position); };
Természetesen más jellegű felbontás is elképzelhető, illetve a sokasodó osztály-függőségek csökkentésére, a függőségeket nagyobb absztrakciós szinten levő osztályokba is tömöríthetnénk.
Az újonnan létrejött HomeInfo például maga is szolgálhatná az otthoni lakcím pozícióját vagy összevonhatjuk a PositionProvider osztállyal egy HomePositionProvider osztályba. Bárhogy is teszünk, az a HomeDialog osztálynak csak javára válik:
var HomeDialog = function() { this.dialog = new Dialog(); this.mapRenderer = new MapRenderer(this.dialog); this.homePositionProvider = new HomePositionProvider(); }; HomeDialog.prototype.render = function() { this.mapRenderer.render(); this.homePositionProvider.get(this.renderHome.bind(this)); }; HomeDialog.prototype.renderHome = function(homeInfo, position) { mapRenderer.addAddress(homeInfo, position); };
Ez a kód már könnyedén egyszerűsíthető tovább:
var HomeDialog = function() { this.dialog = new Dialog(); this.mapRenderer = new MapRenderer(this.dialog); this.homePositionProvider = new HomePositionProvider(); }; HomeDialog.prototype.render = function() { this.mapRenderer.render(); this.homePositionProvider.get(mapRenderer.addAddress); };
A fenti műveletek végére az eredeti kódunk négy új osztállyal bővült, amelyek már specifikusan azt a feladatot fogják elvégezni, amire a nevük is utal.
Korábban nem esett szó a hibakezelésről, azonban nem mehetünk el mellette minden szó nélkül. Az aszinkron metódusok általában a sikeres állapot esetén meghívandó függvény mellett egy másik paraméterben, a hibás működés esetére is várnak egy callback-et. Könnyen beláthatjuk, hogy ennek függvényében az előbbi példakódok (az eredetit is beleértve) igencsak megnövekedtek volna. Ha azonban csak a végeredményt tekintjük, akkor egyáltalán nem vészes a helyzet:
HomeDialog.prototype.render = function() { this.mapRenderer.render(); this.homePositionProvider.get( mapRenderer.addAddress, this.showError); }; HomeDialog.prototype.showError = function() { alert('Sorry to say, but something bad happened! :( '); this.dialog.destroy(); }
Természetesen a megfelelő wrapper osztályokat (HomePositionProvider, HomeInfo, PositionProvider) is ki kell bővíteni a hibakezeléssel, azonban azok egyszerűen a saját névterükben megtörténhetnek, a feladat magját tartalmazó kódot nem érintik. A logika megfelelő helyre való elszeparálásával sokat javíthatunk a kód újrafelhasználhatóságán és tesztelhetőségén!
A Callback hell leküzdésére jó megoldás lehet az aszinkron hívások dedikált metódusokba való kiszervezése, azonban néhány problémával is jár: - Továbbra is nekünk kell menedzselnünk a párhuzamos feladatokat, fejben kell tartani, hogy melyik metódus aszinkron és melyik nem. - Adott esetben nehéz lehet átlátni a kódot: például a HomeDialog osztályra rátekintve fogalmunk sem lehet arról, hogy hány aszinkron hívás fog történni a háttérben. - A debuggoláson nem javít, hiszen egy hiba esetén a StackTrace-ben csak a legközelebbi aszinkron hívásig fogjuk látni a meghívott metódusokat — hiszen ilyenkor az utasítások már csak eddig vannak a Stack-ben! - Az egyre sokasodó osztályok jelentősen növelhetik a teljes kódbázis komplexitását. Sok olyan osztályunk is születhet, amit máshol nem fogunk felhasználni, így kiemelésük nem biztos, hogy megérte. Érdemes szem előtt tartani, hogy a nagyobb kódbázist mindig nehezebb is karbantartani!
A JavaScript nyelvi jellegzetességei, a függvények funkcionális programozásból örökölt képességei tették lehetővé a Promise-ok módszerének kidolgozását, melyet talán a nyelv legrövidebb specifikációjában sikerült összefoglalni.
A promise egy aszinkron hívást körbeölelő objektum, amely a művelet aktuális állapotát hordozza magában. Minden promise egyetlen egyszer futhat le, és a futás eredménye csak sikeres vagy sikertelen lehet. Az eredménytől függően képes a megfelelő callback metódusokat meghívni.
Egy promise a következő állapotokkal rendelkezhet: - fulfilled, vagyis teljesített, ha az aszinkron művelet sikeresen lefutott - rejected, ha az aszinkron művelet sikertelen volt - pending, ha a művelet még fut - settled, ha az aszinkron hívás már megtörtént, függetlenül a sikerességétől
A specifikáció a promise objektumoktól egyetlen metódust követel meg, a then-t. Ez a függvény két callback paramétert vár melyeket attól függően hív meg, hogy milyen eredménnyel fog járni az aszinkron művelet:
promise.then(onFulfilled, onRejected);
Természetesen ha nem szükséges akkor nem kötelező megadni a callback-eket, el lehet hagyni bármelyiket vagy mindkettőt egyszerre.
A Promise osztály az ES6-ban már natívan is elérhető, az azt nem támogató futtatókörnyezetekben számos letölthető promise library közül választhatunk vagy egyszerűen húzzunk be egy kis méretű polyfill-t.
Ha a fejezet elején bemutatott Node.js-ből kölcsönzött fájlbeolvasás promise-t adna vissza, akkor valahogy így nézne ki a használata:
fs.readFile('usernames.csv') .then(function(data) { console.log('Got data: ', data); }, function() { console.log('Error happened!'); });
A then egy nagyszerű tulajdonsága, hogy mindenképpen egy új promise-t fog visszaadni, ami az előző aszinkron hívás után hajtódik végre, így könnyedén egymás után láncolhatjuk az aszinkron műveleteinket. Ha azonban egy promise lánc bármelyik eleme visszautasításra kerül, akkor a rejected státuszú hívás utáni then-ek már nem futnak le!
Ha az előbbi példán tovább haladva azt szeretnénk, hogy a beolvasott felhasználói nevek közül szűrjük ki az üreseket, majd írjuk vissza a javított listát a fájlba, akkor az valahogy így nézne ki:
fs.readFile('usernames.csv') .then(function(data) { var validUsernames = data.split("\n"); // Őrizzük meg azokat a neveket, amik nem üresek validUsernames = validUsernames.filter(function(username) { return username.trim() !== ''; }); return fs.writeFile('usernames.csv', validUsernames.join("\n")); }) .then(function() { console.log('Successfully fixed the usernames!'); });
Ha az onFulfilled callback-el visszaadunk egy értéket, akkor az a következő then utasításban paraméterként fog megjelenni. Ennek segítségével és egy kis refaktorálás után a fenti kód sokkal érthetőbbé válhat:
fs.readFile('usernames.csv') .then(filterValidUsernames) .then(function(validUsernames) { fs.writeFile('usernames.csv', validUsernames); }) .then(function() { console.log('Successfully fixed the usernames!'); });
Amennyiben nincs szükségünk egyedi hibakezelésekre, a lánc végére tehetünk egy catch hívást, amely akkor hívódik meg, ha bármelyik promise rejected állapotba kerül vagy ha egy then callback-ben kivételt dobunk:
fs.readFile('usernames.csv') .then(filterValidUsernames) .then(writeUsernames) .then(showSuccessMessage) .catch(function() { alert('Something bad happened! :( '); });
Sajnos az előbbi példák ebben a formában nem használhatóak, ugyanis a Node.js fs.readFile és fs.writeFile metódusai nem promise-t adnak vissza — azonban semmi sincs veszve, egy kis kódolással elkészíthetjük a promise-t használó változatukat. Ehhez hívjuk segítségül a Promise konstruktorfüggvényt, amely egy callback függvényt vár:
var readFilePromise = function(filename) { return new Promise(function(resolve, reject) { fs.readFile(filename, function (err, data) { if (err) reject(); else resolve(data); }); }); }; readFilePromise('usernames.csv') .then(filterValidUsernames) .then(writeUsernames) .then(showSuccessMessage) .catch(function() { alert('Something bad happened! :( '); });
A Promise-nak átadott függvény két paramétert kap: a resolve-ot meghívva a promise fullfilled állapotba kerül és a rákötött then sikeres ágát indukálja; míg a reject hívásakor értelemszerűen a rejected állapot lép érvénybe, a hiba-callbackek futtatásával. Ezt követően a readFilePromise úgy használható, ahogy a fenti példákban az fs.readFile-al próbálkoztunk.
A JavaScript minden objektumot, amely rendelkezik egy then metódussal képes promise-nek tekinteni (egyetlen kivétel maga a Promise konstruktorfüggvény). Ennek köszönhetően nagyon könnyű a natív API-t használni más library-kkel. Sajnos a jQuery egy hibás promise implementációt valósít meg, így közvetlenül nem használhatjuk a beépített API-val — szerencsénkre azonban a natív Promise rendelkezik egy Promise.cast metódussal, amely bizonyos limitációk mellett segíthet egyszerű értékeket vagy objektumokat valódi promise-á alakítani:
var nativePromise = Promise.cast($.getJSON('/something.json')); nativePromise.then(function(things) { console.log(things); });
Ha azt szeretnénk elérni, hogy egy promise sikere esetén több, egymástól független then ág is lefusson, akkor az eredeti promise referenciáját használjuk:
var promiseOriginal = readFilePromise('something.txt'); // Első ág promiseOriginal .then(function(data) { console.log(data); return 'Ez egy üzenet'; }) .then(function(message) { console.log(message); }); // Második ág promiseOriginal .then(function(data) { console.log('data'); });
A fenti kód futásának eredménye az, hogy a promiseOriginal két külön ágat is meg fog hívni a fájlbeolvasás értékével. A második ág nem tud semmit az előzőről, teljesen független tőle. Ennek a tulajdonságnak köszönhetően rengeteg hatásos dolgot tudunk elérni: például egy AJAX hívás promise-át az alkalmazás több különböző helyére is elküldhetjük, ahol a modulok a then segítségével a saját callback-jeiket húzzák rá. Figyeljünk oda, hogy az egymástól független ágak lefutási sorrendje nem garantált!
A Promise-nak még rengeteg hasznos segédfüggvénye van az aszinkron hívások kezelésére, érdemes az MDN oldalán található példákat is végiglapozni.
Végül nézzük meg, hogy hogyan alakítható át a korábbi példánk, a térkép-ablak nyitása a promise-ok használatával:
// A Google API bewrappelése, hogy promise-t adjon vissza var geocode = function( address, datas ) { return new Promise(function(resolve) { GMaps.geocode({ address: address, callback: function(response) { datas = datas || ; datas.position = response; resolve(datas); } }); }); }; dialog.open() .then(mapRenderer.render) .then(function() { return Promise.cast($.getJSON('/getHomeInfo')); }) .then(function(homeInfo) { return geocode(home.address, { home: homeInfo }); }) .then(function(datas) { mapRenderer.addAddress(datas.home, datas.position); });
A promise-ok segítségével könnyen olvashatóvá, beszédessé tehetjük az aszinkron kódunkat, jól látható, hogy hol fog egy hívás eredményére várakozni a futtatókörnyezet. A korábbi módszerek sorrendiségi problémáján is sokat segítettünk, ugyanis a promise által biztosított hívási lánc elég kötött ahhoz, hogy ne lehessen véletlenül elrontani. A hibakezelésre lokális és globális módot is ad, így több szinten, akár egységesen is kezelhetjük a felmerülő problémákat.
Mindezekért cserébe viszont nehezebb automatikus teszteket írni rá, illetve a debuggolást és karbantarthatóságot is nehezítheti, ha egy promise objektumra több különböző helyen aggatunk callback-eket.
Az EcmaScript 6 a Promise-on kívül több olyan új nyelvi elemet is natívan biztosít, amellyel megkönnyíthetjük az aszinkron kódok írását és könnyebb érthetőségét.
Korábban már szó volt a generátorfüggvényekről, melyek a promise-okkal karöltve tovább egyszerűsíthetik az aszinkron műveletek egymásba ágyazását.
Emlékezzünk, hogy a generátor csak a yield kulcsszóig futtatja a kódját és a next metódus meghívására lép tovább. Mi lenne, ha ezt a léptetést egy promise állapotváltása idézné elő — ekkor könnyedén írhatunk olyan aszinkron kódot, ami első ránézésre már-már egy egyszerű szinkron kódnak fog tűnni:
var renderHome = function *() { yield dialog.open(); mapRenderer.render(); var home = yield Promise.cast($.getJSON('/getHome')); var position = yield geocode(home.address); mapRenderer.addAddress(data.home, data.position); };
Ha ránézünk a fenti kódra, akkor a yield kulcsszavaktól eltekintve egy szinkron kódot láthatunk, semmilyen callback nem szerepel benne. A generátor-függvény használatával sokkal természetesebbnek tűnik a kód, miközben az aszinkronitásról sem kell lemondani.
Nem csak az olvashatóságon és a karbantarthatóságon javítanak a generátorok, de a hibakezelést is leegyszerűsítik, hiszen nyugodtan használhatjuk a hagyományos kivételkezelést a függvényen belül.
A történet persze nem egészen kerek, ugyanis a renderHome.next()-et folyamatosan hívogatni kell, hogy végigfusson a függvénymagon. Az első híváskor ugyanis csak a yield-ig, vagyis az ablak megnyitásáig fut a kód, ekkor megáll. Mivel a dialog.open egy promise, így kívülről az első renderHome.next() ezt fogja megkapni. Innen már csak egy lépés, hogy a megkapott promise then-jére újabb next-et hívjunk és így tovább.
Mivel ezt kézzel elég kényelmetlen lenne vizsgálni és minden esetben egyesével elintézni (többet vesztenénk rajta, mint amit nyerünk a generátorokkal), ezért sokan egy általános wrapper metódust használnak a feladatra, melyet legtöbbször spawn-nak hívnak:
$('#homeButton').on('click', function() { spawn(renderHome); });
A spawn implementációja meglehetősen egyszerű, megtekinthető és átemelhető például Jake Archibald kitűnő Promise-okról szóló cikkéből!
A spawn metódus figyeli a neki átadott generátorfüggvény promise-ait és azok állapotváltásaikor meghívja a renderHome.next() metódust, továbbléptetve a függvényt a következő yield-ig, vagy annak hiányában a függvénymag végéig.
Végül nézzük meg a fájlbeolvasással foglalkozó példánk promise-t és generátorokat használó változatát, hogy lássuk mennyivel egyszerűbbé és átláthatóbbá válik a kód, ha ötvözzük e két technikát:
var fixUsernames = function*() { try { var usernames = yield readFilePromise('usernames.csv'); usernames = filterValidUsernames(usernames); yield writeUsernames(usernames); showSuccessMessage(); } catch() { alert('Something bad happened! :( '); } }; spawn(fixUsernames);
A generátorfüggvény és a promise-ok kombinációjával egy nagyon expresszív, rövid és könnyen debuggolható utasítássort kapunk, azonban még mindig kissé zajos marad a kódunk. Továbbra is szükségünk van egy külső metódusra (spawn), ami figyeli és kontrollálja a promise-ok működését.
Szerencsére az ES6 számos szintaktikai segítséget ad, hogy letisztázzuk a kódot a redundáns, sokszor ismétlődő nyelvi elemektől illetve, hogy tovább egyszerűsítsük az aszinkron kódok kezelését.
Az EcmaScript 6 bevezeti az úgynevezett Deferred function fogalmát, olyan függvényekre értve, melyek lefutása aszinkron módon is történhet, állapotát pedig nyomon lehet követni. Ehhez kapcsolódóan egy új vezérlési szerkezet is megjelenik: az await kulcsszó után álló Deferred futásának idejére a futtatókörnyezet elmenti, majd kiüríti a Stack-et, átadva helyét a következő esemény számára a Queue-ból. Az elmentett műveletsor akkor folytatódik, ha a Deferred lefutott.
A promise a Deferred function-ök részét képezi, így hasonló tulajdonságokkal rendelkezik. Ennek köszönhetően használható az await kulcsszó után arra, hogy a függvény futását felfüggesszük addig, amíg az aszinkron műveletünk visszatérésére várunk.
Lássuk, hogy ez hogyan is egyszerűsíti tovább a korábbi példakódjainkat:
var renderHome = function() { var home, position; await dialog.open(); mapRenderer.render(); await home = Promise.cast($.getJSON('/getHome')); await position = geocode(home.address); mapRenderer.addAddress(data.home, data.position); }; renderHome();
Az első await-hez érve a kód megvárja, hogy megnyíljon az ablak, vagyis felfüggeszti a renderHome függvény futását, elmenti a Stack aktuális állapotát és felszabadítja azt. Ha megnyílt az ablak (azaz a dialog.open() által visszaadott promise fulfilled állapotba kerül), akkor az elmentett Stack-et visszatölti és folytatja a függvény futtatását. Így megy ez egészen addig, amíg a függvény véget nem ér.
Az await-et tartalmazó függvény visszatérési értéke mindenképpen egy Promise lesz, melyet természetesen a hagyományos úton, vagy egy őt figyelő másik await-el együtt komplexebb műveletekre is felhasználhatunk:
var renderProfile = function() { await renderHome(); await renderFriend(); await renderTimeline(); }; var renderPage = function() { await renderProfile(); console.log('Paged rendered successfully!'); };
A hibakezelés is egyszerű, hiszen használhatjuk a natív kivételeket. Bármi probléma történik az await-et tartalmazó függvényben, az lokálisan kezelhető, az egész függvény futása szinkron jellegűnek tekinthető:
var fixUsernames = function() { var usernames; try { await usernames = readFilePromise('usernames.csv'); usernames = filterValidUsernames(usernames); await writeFilePromise('usernames.csv', usernames); showSuccessMessage(); } catch(err) { alert('Something bad happened! :( '); } };
Az await vezérlési szerkezet és a promise-ok kombinációjával a legtöbb esetben egyszerűbbé, olvashatóbbá és könnyen kezelhetőbbé válik az aszinkron kód.
A Funkcionális reaktív programozás (FRP) sok funkcionális nyelvben ismert technika, azonban a JavaScriptbe és a front-end oldali web-programozásba csak az utóbbi években gyűrűzött be. Mielőtt megismerkednénk vele, nézzük meg, hogy mit is jelent általában a reaktív programozás.
Az RP mögött álló ötlet szerint olyan adatstruktúrával dolgozzunk, amely értéke folytonos, vagyis módosítás esetén a tőle függő adatokat is változtassa meg. A következő egyszerű példa talán jobban szemlélteti:
var x = 1, y = x + 1; x = 2; console.log(y);
A JavaScriptben és a legtöbb programozási nyelvben alapesetben a kiírt y változó értéke 2 lenne, hiszen az x = 2 értékadásnak már nincs köze hozzá. A reaktív programozás során azonban az adataink értékét egy folyamatos adatfolyamnak kell elképzelnünk, így az y = x + 1 értékadáskor valójában azt mondjuk meg, hogy az y mindig legyen eggyel nagyobb az x-nél, így értéke a kiíráskor 3 lesz!
A reaktív programozás célja, hogy az ilyen jellegű kapcsolatokat mindenféle eseménykezelés vagy explicit adatkötés nélkül, csupán az adatfolyamokra támaszkodva oldja meg. Könnyű belegondolni, hogy egy hasonló technikával készülő programrész milyen expresszív is lehet:
var drawRectanglePointer = function() { var xPosition = <mouse-x-coordinate>, yPosition = <mouse-y-coordinate>; drawRectangle( xPosition - 10, yPosition - 10, xPosition + 10, yPosition + 10 ); };
A fenti pszeudokódban a drawRectanglePointer metódus egy olyan négyzetet rajzol ki az egérmutató alatt, amely folyamatosan követi az egér mozgását. A reaktív programozásnak köszönhetően ezt a metódust egyszer kellene meghívni, az egér pozíciói viszont nem egyszeri értékek lennének, hanem adatfolyamok, melyek a folyamatos változás hatására újra és újrarajzolnák a négyzetet, követve a mutató mozgását.
Láthatjuk, hogy a reaktív programozás nem más, mint egy új szemléletmód, az, hogy egy kicsit máshogy tekintsünk az egyenlőség műveletére, ne egy egyszerű értékadást jelentsen. Az adatok egy Excel táblázat mezőjére hasonlítsanak, melyet más mezők kifejezéseiben használunk. Ha módosítjuk, akkor azonnal megváltozik az összes tőle függő rekord értéke is.
A reaktív programozás feltétele néhány speciális nyelvi elem, amelyekkel az adatfolyamokat jelölhetjük, illetve annak megoldása, hogy a reaktív kód jól megkülönböztethető legyen a hagyományostól. Sajnos a JavaScript erre nem biztosít natív lehetőséget — azonban ez nem is szükséges, hiszen funkcionális nyelvi elemekkel könnyedén helyettesíthetőek.
Az FRP csupán funkcionális stílusban kivitelezett reaktív programozás. Természetesen ehhez szükséges néhány segédfüggvény — mi a következőkben a Bacon.js nevű library-t fogjuk használni.
Nézzünk egy egyszerű példát, hogy hogyan is alakíthatunk át egy hagyományos kódot reaktív stílusba. Feladatunk egy input mező átalakítása úgy, hogyha entert ütünk, akkor a mezőbe írt érték megjelenjen egy címkében. Hagyományos eseménykezeléssel valahogy így nézne ki a kódunk:
<input type="text" name="textfield"> <div class="label"></div>
var $input = $('input[name=textfield]'), $label = $('.label'); $input.on('keyup', function(e) { if (e.keyCode === 13) { $label.text($input.val()); } });
Ha a funkcionális reaktív programozást hívjuk segítségül, akkor a billentyűleütést egy úgynevezett Event Stream-é kell alakítanunk. Ez nem más, mint egy esemény-folyam, egy olyan Observer objektum, mely folyamatosan figyeli az eseményt és bármilyen változás esetén meghívja a rákötött callback-eket. Egy ilyen Observer-re legegyszerűbben az onValue metódussal iratkozhatunk fel:
$input .asEventStream('keyup') .onValue(function(e) { if (e.keyCode === 13) { $label.text($input.val()); } });
Az asEventStream metódus egy jQuery eseményből képes létrehozni egy Event Stream-et, az onValue pedig egy esemény kiváltódásakor a folyamba került értékkel hívja meg a callback-jét, jelen esetben ez a billentyű-esemény event objektuma.
Az előbbi egy egyszerű példa volt és önmagában nem igazán bizonyítja az FRP expresszivitását — azonban Event Stream-ek használata igazán izgalmassá és hatásossá válik, ha egy kicsit bonyolítjuk a példát. Tegyük fel, hogy szeretnénk egy gombot is az input mellé, melyre klikkelve ugyancsak beírjuk a címkébe a szerkesztő-mező tartalmát:
var $input = $('input[name=textfield]'), $label = $('.label'), $button = $('button'); var enterStream = $input .asEventStream('keyup') .filter(function(e) { return e.keyCode === 13; }); var clickStream = $button .asEventStream('click'); enterStream.merge(clickStream).onValue(function() { $label.text($input.val()); });
Ezúttal két különböző esemény-folyamot definiálunk, egyet az enter billentyű leütéseire, a másikat a gombra való kattintásra. Az FRP egyik legfontosabb jellemzője, hogy az Event Stream-ek rengeteg különböző módon kombinálhatóak, összevonhatóak, szétszedhetőek vagy épp átkonvertálhatóak.
Az Event Stream mellett a Bacon.js a Property fogalmát is bevezeti. Ez egy egyszerű, állandó állapottal rendelkező értéknek tekinthető, melynek van valamilyen kezdőértéke, majd folyamatosan követi a folyamot. Ilyen lehet például egy input mező tartalma vagy egy logikai érték, hogy éppen üres-e. Egy Property segítségével például könnyedén ki- és bekapcsolhatjuk a gombot, attól függően, hogy ki van-e töltve a szöveges mező:
var keyStream = $input.asEventStream('keyup'), enterStream = keyStream .filter(function(e) { return e.keyCode === 13; }), clickStream = $button.asEventStream('click'), showStream = enterStream.merge(clickStream), isEmpty = keyStream .map(function() { return $input.val().trim() === ''; }) .toProperty(true); showStream.onValue(function() { $label.text($input.val()); }); isEmpty.onValue(function(state) { $button.attr('disabled', state); });
Az újonnan megjelent isEmpty egy olyan Property, mely igaz kezdőértékkel rendelkezik, azonban minden keyStream-beli eseményre (vagyis billentyűlenyomásra) ellenőrzi, hogy üres-e még a szöveges mező — ha nem, akkor false-ra állítja a saját állapotát. A map metódussal egy Event Stream értékét transzformálhatjuk — bár most nem volt rá szükségünk, de — paramétereként a folyam aktuális értékét kapja meg, melyet a visszatérési értékkel felülírunk.
Az előbbi példában szereplő Property-t akár egyenesen a gomb állapotára köthetjük, ha az assign metódust használjuk:
keyStream .map(isEmpty) .toProperty(true) .assign($button, 'attr', 'disabled');
Természetesen az FRP által nyújtott módszertani előnyöket a szerver oldalon is kiaknázhatjuk — természetesen a Bacon.js Node.js alatt is ugyanúgy működik. Maradjon a példánk ugyanaz mint eddig, azaz olvassunk be egy fájlt különböző nevekkel és üres sorokkal, majd javítsuk ki úgy, hogy az üres sorokat kiszedjük:
var getUsernames = function(content) { return content.split('\r\n'); }, filterValidUsernames = function(usernames) { return usernames.filter(function(username) { return Boolean(username.trim()); }); }, fixUsernamesIn = function(file) { var fileContent = Bacon.fromNodeCallback(fs.readFile, file), usernames = fileContent.map(getUsernames), validUsernames = usernames.map(filterValidUsernames); validUsernames.onValue(function(usernames) { fs.writeFile(file, usernames.join('\r\n')); }); }; fixUsernamesIn('usernames.csv');
A Bacon.js szerencsére nem csak jQuery objektumokból hanem szinte bármiből képes Event Stream-et létrehozni — legyen az egy aszinkron szerkezet, egy Promise, egy DOM eseménykezelő vagy akár egy egyszerű tömb — a fromNodeCallback metódusa például Node.js függvényekre épül rá.
A fixUsernamesIn függvény sok szempontból egy Deferred function-re hasonlít, olyan mintha egy egyszerű szinkron kód lenne — a trükk az, hogy az összes benne szereplő változó egy Event Stream. Jelen esetben az egész javítási folyamatot kiváltó esemény — a fájlbeolvasás — egy egyszeri esemény, tehát a fixUsernamesIn függvény egyetlen aszinkron hívási sor, mely a fájlba visszaírással véget is ér. Ebből a szempontból más, mint a korábbi példák, ahol a stream-ek folytonosak voltak.
A fenti példában szereplő stream-eket természetesen újrafelhasználhatjuk más feladatokra is:
// Írjuk ki a beolvasott fájl tartalmát usernames.onValue(function(usernames) { console.log('Read data: ', usernames); }); // Írjuk ki a javított névhalmazt validUsernames.onValue(function(usernames) { console.log('Fixed data: ', usernames); }); // Írjuk ki, hogy hány sort töröltünk ki usernames .combine(validUsernames, function(usernames, validUsernames) { return usernames.length - validUsernames.length; }) .onValue(function(num) { console.log('Removed ' + num + ' rows!'); });
Az elérhető stream-ekre bármikor fel tudunk iratkozni, és akármennyi új esemény-folyam alapjául szolgálhatnak. Már korábban láttuk a merge metódust, mellyel két stream-et olvaszthatunk össze, azonban rengeteg más módon is összekapcsolhatunk folyamokat. Utóbbira példa a fent látható combine is, mely az összekapcsolandó stream mellett egy metódust is vár, amiben tetszőlegesen transzformálhatjuk az részt vevő eseményfolyamokat és a visszatérési értékben beállíthatjuk, hogy ettől a ponttól mi legyen a folyam értéke.
Az esemény-folyamokban is előfordulhatnak problémák, a Promise-okhoz hasonlóan itt az onError metódussal állíthatunk be hibakezelő callback-et egy Event Stream-en.
Az FRP segítségével egy új, az eddigiektől eltérő módon kerülhetjük el a Callback Hell-t. Igaz egy kicsit más gondolkodásmódot kíván meg, mint amit a korábbi aszinkron programozási módszerek igényeltek, de megéri kísérletezni vele, hiszen sok esetben nagyon rövid és kifejező kódot kapunk. A stream-ek tetszőleges kombinálásával és azzal, hogy könnyedén újrafelhasználhatóak különböző műveletekhez, nem csak expresszív, de meglepően optimális kódot is kaphatunk.
A tananyag az ELTE - PPKE informatika tananyagfejlesztési projekt (TÁMOP-4.1.2.A/1-11/1-2011-0052) keretében valósult meg.
A tananyag elkészítéséhez az ELTESCORM keretrendszert használtuk.