node.js - streams & pipes

A fenti fogalmakat hajlamosak a node.js dokumentációban és úgy általában a cikkekben is elbonyolítani, amit úgy gondolom már nem hagyhatok szó nélkül, mint ügyeletes megmondóember. ;-)



A readable stream - pipe - writeable stream szentháromság a legegyszerűbben magyarázva arról szól, hogyha van egy hosszú szöveged, mint mondjuk A Gyűrűk Ura trilógia, akkor kiterítve az összes lapot, nem fogod tudni ránézésre elolvasni, és értelmezni mindet egyszerre. Ahhoz, hogy megértsd, miről van szó, végig kell olvasni a szöveget sorról-sorra, mondatról-mondatra. A számítógép is hasonlóan működik, neki is véges a kapacitása, és elfogy a memóriája ha egy csomó lapot kell egyszerre elolvasnia. Hogy ez ne legyen akkora a probléma, az ilyen hosszú szövegeket fel szokták darabolni kisebb egységekre, ha úgy tetszik mondatokra, amiket aztán egyesével feldolgozhat a gép. A readable stream ami felolvassa a mondatokat mondjuk egy txt fájlból. A pipe a csatorna, amin át tudja küldeni a mondatokat. A writeable stream meg le tudja írni ezeket a mondatokat mondjuk egy másik txt fájlba, ha másolni szeretnénk a könyvet. Természetesen a gép egy kicsit máshogy működik az embernél, nem mondatonként olvas, hanem string-ek esetében általában fix hosszúságú szövegrészt, mondjuk egyszerre 100 karaktert. Ezeket a fix hosszúságú szövegrészeket chunk-nak hívják, a méretük állítható. Ennyi a történet lényege dióhéjban.

Ezt lehet cifrázni azzal, hogy a stream általánosságban annyit jelent, hogy adatfolyam, és nincs megkötés arra, hogy ez az adat szöveg, esetleg bináris adat, pl. egy képfájl binary stream esetében, vagy éppen egy javascript tömb object stream esetében. Ez utóbbinál akár egy vagy több elemes chunk-ot is csinálhatunk.
Arra sincs megkötés, hogy ez az adatfolyam honnan származik és hova tart, szóval folyhat fájlba vagy jöhet fájlból, de ugyanúgy adatbázisba, vagy böngészőnek, vagy valami tök más protokollon, mondjuk SOAP webservice-nek is folyhat szóval csinálhatunk olyat, hogy felolvasunk egy fájlt az egyik stream-el aztán pipe-al átküldjük egy másik stream-nek, ami elküldi a fájl tartalmát chunk-onként a böngészőnek. Így gyakorlatilag azonnal elkezdhetjük a fájlok kiszolgálását, nem kell arra várnunk, hogy a teljes fájl beolvasásra kerüljön a memóriába, ami lassú és memória igényes kiszolgálási mód lenne.
További érdekesség, hogy az olvasást és írást mindkét kapcsolódó stream kezdeményezheti, amelyik írást kezdeményez, az push-ol, tolja az adatot a másik stream-re, amelyik olvasást kezdeményez, az pull-ol, húzza az adatot az előző stream-ről. A folyás iránya minden esetben upstream to downstream, szóval fentről lefele, nem balról jobbra megy az angolok gondolkodásmódja szerint. Vannak olyan stream-ek is, amelyek kétirányúak, egyszerre olvasni is tudnak meg írni is. Ezeket egymás után lehet kötni sorban pipe-okkal, ilyenkor a végeredményt pipeline-nak hívják.
A pipeline esetében tehát a stream-ek egymásnak adogatják az adatokat. Ez elég hasznos tud lenni, mert a köztes ún. transform stream-ek nem kell, hogy ugyanazt a chunk-ot adják tovább, mint amit átvettek, eszközölhetnek rajta módosításokat. Változtathatják a chunk méretét, átalakíthatják eltérő típusúvá, mondjuk JSON string-et objektummá vagy ugyanezt vissza, de akár az adatokat is felülírhatják vagy szűrhetik, pl a szöveget kicserélhetik csupa nagybetűsre, vagy kiszűrhetnek egy tömbből bizonyos elemeket, amik nem felelnek meg a feltételeinknek, és így tovább. Az ilyen jellegű pipeline-ok használatát pipes and filters mintának is hívják. Ennél az első readable stream, ami nem pipe-al, hanem általában másik protokollról olvas, az a pump. A végső writeable stream, ami nem pipe-ra, hanem általában másik protokollra, mondjuk fájlba ír, az a sink. A köztük lévő transform stream-ek, amik szűrik, alakítják az adatot, a filter-ek.
Ha objektumok küldése esetén letároljuk az adatot egy transformer stream-ben, amíg nem bizonyosodtunk meg arról, hogy a writeable stream-nek sikerült feldolgoznia, akkor beszélhetünk egy egyszerű message queue-ről. Ezeknél általában fontos, hogy megjöjjön az objektum (üzenet), mert sokszor eltérő gépek közötti kommunikációt látnak el, így ha összeomlik a szerver a fogadó félen, akkor meg kell várni, míg újraindul, mielőtt újra megpróbálnánk elküldeni az üzenetet. A bonyolultabb változat ennél annyival tud többet, hogy több stream-ről tud olvasni és több stream-re tud írni adatot. Az igazán komplikált megoldások, ezen kívül még valamilyen címzést vagy szűrést is használnak, és így nem minden olvasó kapja meg az összes üzenetet, hanem csak azok, amelyek megfelelnek a feltételeknek. Ez nagyon hasonló a mediator mintához. Annyi a különbség, hogy annál nem jellemző, hogy több gép, process vagy thread között osztanánk meg az üzeneteket. Általában ott inkább függvényekről, egy futó alkalmazás részeiről beszélünk. Szóval jóval alacsonyabb szintű minta, mint message queue-ekkel összekötni több eltérő thread-en vagy process-en, esetleg gépen futó alkalmazást.
Ami még érdekes lehet, hogy a stream-eket csomópontnak és a pipe-okat éleknek ábrázolva tudunk rajzolni egy irányított gráfot, amit dataflow graph-nak is hívnak. Ez nagyjából azt mutatja meg, hogy melyik stream-ből melyik másik stream-be folyik az adat. A pipeline-ok esetében ez gyakorlatilag egy egyenes, a message queue-k esetében viszont elágazások lehetnek, ami legjobb esetben egy fát ad, legrosszabb esetben meg egy ciklikus gráfot, ami a diagramon egy irányított körnek, a futó programnál meg egy végtelen ciklusnak felel meg. Ami problémás, hogy a szinkron programok fordításával vagy futásával ellentétben itt semmi nem jelzi az ilyen végtelen ciklusok kialakulását. Mivel aszinkron, esetleg multiprocess vagy multihread kódról van szó, így debuggolni is nagyon nehéz. Mindenképp érdemes ezért a dataflow graph-okat felrajzolni legalább nagy vonalakban, arról, hogy honnan hova áramlik az adat, mert azokon az ilyesmi általában ránézésre látható. A szinkron kóddal ellentétben az aszinkron kódnál nem mindig probléma egy-egy ilyen végtelen ciklus, fel lehet használni például végtelen animációk gyártásától elkezdve időzítőkön át egy csomó dologra. A dataflow graph-al kapcsolatban még érdekesség, hogy létezik egy olyan fogalom, hogy flow based programming, ami szinte minden nyelvi elemet úgy épít fel, hogy egymással adatfolyamokkal tudjanak kommunikálni. Van js keretrendszer is ilyen, noflo-nak hívják, van lehetőség benne kisebb programokat összekattintani vizuális felületen. A végeredmény egy dataflow graph. Kicsit hasonló ez a megközelítés a funkcionális programozáshoz, annyi eltérés azért van, hogy az utóbbinál nem az összes nyelvi elem, hanem inkább csak a függvények feleltethetőek meg egy-egy stream-nek. A procedurális programozás ott kezd el eltávolodni ettől, amikor bevezet ilyen fogalmakat, hogy global, illetve elkezd állapotokat letárolni a függvényekben, ami miatt azok nehezen párhuzamosíthatóvá válnak, mert az ilyen állapotokat vagy szinkronizálni kell thread-ek között vagy nem, ami bonyolítja a dolgokat. Van is egyébként olyan megközelítés, ami ötvözi a funkcionális és adatfolyam alapú programozást, functional reactive programming-nek hívják.

Én is próbálkozok valami ezekhez hasonlóval dataflower néven. Én csak az ES7 aszinkron függvényeket váltanám ki stream-ekkel, amíg nem terjednek el a mostaninál jobban. Esetleg ha elterjednének, még akkor is talán hasznos lehet ez a fajta megközelítés velük. A modellezésüknél értelemszerűen a stream-nek küldött adat felelne meg az argumentumoknak, a stream által továbbadott adat a visszatérő értékeknek, illetve a a stream-nek történő adatküldés a függvény hívásnak. A dolog ott kezd elbonyolódni, amikor már stack-et kell implementálni, meg scope-ot, és ezt valami olyan módon, hogy könnyen követhető legyen a kód a felhasználóknak. Nem egyszerű a dolog, de sok előnye van ennek a megközelítésnek. Egyrészt a pyramid of doom vagy más néven callback hell helyett csak egy több szintes fát kell építenünk, ami azért sokkal átláthatóbb. A másik előnye, hogy ezen a többszintes fán bármikor kicserélhetjük az egyes ágakat, leveleket, ami a hagyományos módszerrel vagy akár az async libbel csak nagyon nagy workaround-ok árán oldható meg, ha egyáltalán megoldható. Ami szintén új feature, hogy végig memóriában tudjuk tartani az egyes ágakat, így nem kell újra és újra értelmeznie a javascript motornak ugyanazokat a gyakran használt kódrészeket. Ezt meg lehet spékelni még azzal, hogy lelőhetjük a ritkán használt kódrészeket, ha memóriát akarunk felszabadítani, illetve áthelyezhetjük a fa egyes ágait külön thread-ekre, process-ekre, ha úgy látjuk, hogy nagyon belassult a rendszer a terheléstől. Nem tudok róla, hogy ilyen jellegű megoldás bárhol létezne, valószínűleg azért, mert még nincsen. Általában átírják a kódot, multithread-esre, ha ilyesmire van szükség. Az én esetemben lehetne egy általános monitoring programot írni, ami ezt automatikusan megcsinálja, és egy betűt nem kell módosítani később a programon, ha több thread-et szeretnénk, nem kell újrafordítani őket, leállítani a szervert, és így tovább. A munka nagyon hosszú lesz, mire idáig eljutok, és több, mint fáradságos, de engem valahogy a nehézségek motiválnak

Az eddigiek alapján azt hiszem elég világos, hogy mennyire sokrétű a stream-ek és pipe-ok felhasználása. Mindenképp érdemes megismerkedni velük, mert aszinkron programozásnál nagyon fontosak.

Nincsenek megjegyzések:

Megjegyzés küldése