ugrás a tartalomhoz

Valós idejű web Javascript alapokon – áttekintés

bh · 2010. Dec. 13. (H), 00.28
Valós idejű web Javascript alapokon – áttekintés

Az utóbbi néhány évben szaporodni kezdtek azon alkalmazások, melyek megkövetelik a lehető legkisebb késleltetéssel a közel valósidejű információ elérést. Friss élményként említhetnénk a közösségi oldalakat is, de persze az igény már jóval ezek előtt megszületett: gondoljunk csak a pénzügyi alkalmazásokra, vagy akár a több szereplős játékokra. Az alábbiakban megpróbálok átfogó képet nyújtani azon javascriptes technikákról, amelyekkel képesek lehetünk valós idejű információ elérésre.

Belecsaphatnánk a dolgok mai állásának közepébe, a HTML5-tel érkező WebSocketek téma körébe. Mielőtt azonban ezzel a viszonylag új technikával foglalkoznánk, tekintsük át, hogy milyen opciók állnak a rendelkezésünkre a WebSocketen kívül.

„Szegény ember vízzel főz...”

Tegyük fel, hogy van egy oldalunk, amely mindig visszaadja a pontos időt.


<html>
<!-- ... -->
<?php echo date('H:i:s'); ?>
<! ... -->
</html>

Alap esetben, amikor ezt az oldalt böngészőből próbáljuk elérni, egy HTTP kérés érkezik a webszerverhez, amely értelmezi azt majd visszaküld egy választ, amit a böngésző megjelenít. A fenti példában az aktuális időt fogjuk látni, de mivel a böngésző önmagától nem frissül, a pontos időhöz muszáj lesz aktivizálni magunkat és manuálisan frissíteni a lapot. Bár a módszer rendkívül egyszerű, mégis rengeteg energiánkba kerül a sok gombnyomás vagy éppen az egér kattintás. A sok erőforrás pazarlást és a nem hatékony működést látván a szakemberek elkezdtek különböző megoldásokkal szolgálni, hogy a böngésző képes legyen automatikusan frissíteni az oldalt vagy annak egy részét.

Polling/Push (Streaming)

Képzeljük el a helyzetet, hogyha túl gyorsan frissítünk, megeshet az is, hogy még ugyanabban a másodpercben vagyunk, tehát teljesen feleslegesen kértük le újra a pontos időt. Ha pedig túl lassúak vagyunk, sokszor lemaradunk az aktuális idő kijelzéséről. Mennyivel egyszerűbb lenne, ha esetleg a böngésző minden másodpercben frissülne automatikusan, azaz ha a kliens minden másodpercben egy kérést küldene a szervernek (polling). Vagy a kliens egyetlen kérést küldene a szervernek aki onnantól kezdve mindig küldené a friss időpontot, anélkül hogy a kliensnek külön kérnie kelljen azt (push, streaming).

Illusztráció Illusztráció
forrás: TheServerSide.com

HTTP-folyamkezelés (streaming)

Tegyünk egy kis kitérőt a HTTP-folyamkezelésre, amely a böngészőkben a nagyobb adatmennyiség megjelenítésének gyorsítására szolgál. Egy ilyen technika a folyamatos kirajzolás (incremental/progressive rendering). Ebben az esetben az oldal megjelenítése már akkor elkezdődik, amikor a <body> elem megérkezik adatként a böngészőbe. Majd az egyes részek (darabok, chunks) érkezésénél folytatódik a kirajzolás. Mindez megvalósítható a Chunked Transfer Coding módszerrel.

Arról, hogy a böngészőnek meddig kell várnia az adatokra, a HTTP-folyamkezelés gondoskodik, a böngészőnek nincs tudomása róla. Ilyennel bizonyára már mindenki találkozott. Érdekesség, hogy a progressive rendering már a Netscape 1.0 betában elérhető volt. A HTTP 1.1-es specifikációjában a következőekre lehetünk figyelmesek, amely bizalommal tölthet el minket:

All HTTP/1.1 applications MUST be able to receive and decode the "chunked" transfer-coding.

Azaz az 1.1-es HTTP előírja, hogy fel tudjuk dolgozni a darabolt információt.

A The lost art of progressive html rendering cikkben található egy példa a megjelenítésre.

A folyamatos kirajzolást úgy tudjuk előcsalni, hogy a szervert kényszerítjük az adatok elküldésére. PHP alól ezt az ob_flush() és a flush() függvények együttes hívásával érhetjük el, amely függvények kiürítik a kimeneti puffert. Ezek a függvények viszont nem érhetőek el minden kiszolgáló alól. Erről több információt a PHP kézikönyvben találhattok (a flush() függvénynél).

Kezdetek – Netscape 1.1

Kezdeti megoldások már 15 éve születtek, 1995-ben a Netscape megjelenésével. A Netscape ugyanis bevezette a széles körben ismert meta refresh tagot, mellyel lehetőségünk van adott másodperc után egy URL-t elérni. Ez egy polling technika, azaz periodikusan kérést indítunk a webszerver felé. A következő példában másodpercenként lekérjük az oldalt a pontos időért.


<html>
    <head>
        <meta http-equiv="Refresh" content="1">
        <title>Pulling by Netscape 1.1</title>
    </head>
    <body>

    <?php echo date('Y-m-d H:i:s'); ?>

    </body>
</html>

A Netscape fejlesztései közé tartozik a multipart/x-mixed-replace MIME tartalomtípus is. Ennek a típusnak a segítségével megvalósítható a push technológia. A szerver válasza részekre van osztva, amely részek egy bizonyos határoló karakterláncal vannak elválasztva. Kliens oldalon az előzőleg megérkezett adat felülírásra kerül az újjal, amint megérkezett az új részüzenet. Mai napig támogatva van, a modern böngészőkben is. Ezt a technikát motion JPEG-hez is használják.

A válasz szintaktikája így néz ki:

Content-type: multipart/x-mixed-replace;boundary=xxx-end-xxx;
--xxx-end-xxx
Content-type: text/plain

[üzenet törzs]
--xxx-end-xxx
Content-type: text/plain

[üzenet törzs]
--xxx-end-xxx
Content-type: text/plain
...
--xxx-end-xxx--

A szintaktikáról bővebben például a 2046-os RFC-ben olvashattok. Egyéb érdekesség az X-es típusokról.

Nézzük meg, hogy néz ki a dolog amennyiben PHP segítségével szolgáltatunk választ. Ezt elvileg minden mai böngészőnek meg kell tudni jeleníteni.


$ct         = "Content-type: text/plain\n\n";
$boundary   = 'xxx-end-xxx';

$datas      = array('Sziasztok', 'kedves', 'Weblabor', 'olvasok.');

header('Pragma: no-cache');
header('Content-type: multipart/x-mixed-replace;boundary=' . $boundary);
echo "--{$boundary}\n";

foreach ($datas as $data)
{
    sleep(1);

    echo $ct;
    echo $data;
    echo "--{$boundary}\n";

    ob_flush();
    flush();
}

sleep(2);
echo $ct;
echo "--Uzenet vege--\n";
echo "--{$boundary}--\n";

// EOF

Ajax (XHR) polling

Az XMLHttpRequest objektum segítségével periodikusan intézünk kérést a webszerverhez. Nagyon egyszerűen megvalósítható, ideális esetben adott időközönként, nem túl gyakran jelenik meg új adat a szerveren. Problémát okoz, amennyiben túl gyakran kérdezünk le, hiszen sok kapcsolatot kell nyitni rövid idő alatt. Ezenkívül erőforrás pazarláshoz vezethet ha az adatok véletlenszerűen állnak rendelkezésre. Formátumként használhatunk XML-t is, vagy bármi nekünk tetszőt, hiszen a válasz egy darabban érkezik.

Kliens oldali kód:


var connect_num = 0;
var xhr = null;

function xhr_request()
{
    xhr = new XMLHttpRequest();
    xhr.onreadystatechange=function()
    {
// Rendelkezésre áll az adat
if (xhr.readyState == 4 && xhr.status == 200)
{
    document.getElementById('content').innerHTML = xhr.responseText;

    setTimeout('xhr_request()', 1000);
}
// Hibakód: 404
else if (xhr.status == 404)
{
    document.getElementById('content').innerHTML = '--Uzenet vege--'
}
    }

    xhr.open("GET", "server.php?req_num=" + connect_num, true);
    xhr.send(null);

    ++connect_num;
}

// Request
window.onload = xhr_request;

Szerver oldali kód:


<?php

header('Content-type: text/plain');

define('ENDMSG', 'xxx-end-xxx');

$datas      = array('Sziasztok', 'kedves', 'Weblabor', 'olvasok.', ENDMSG);

if ( ! isset($_GET['req_num']) || $datas[(int)$_GET['req_num']] === ENDMSG)
{
    // Természetesen adott karakterláncal is jelezhetjük az interakció végét.
    header("HTTP/1.0 404 Not Found");
    exit(0);
}

echo $datas[$_GET['req_num']];

// EOF

Comet

2006 márciusában Alex Russell közzé tett egy bejegyzést, amelyben definiálta a Cometet, mint webes alkalmazás modellt. A Comet a push technológiát hivatott emulálni. Lényege, hogy egy hosszabb HTTP kapcsolatot tart (long-lived HTTP connection) fenn, és ezzel éri el azt, hogy a kapcsolat ideje alatt bármikor tud adatot küldeni a kliensnek, anélkül, hogy az külön kérné. A kapcsolat lehet örök életű vagy adott ideig fenntartott. Van azonban néhány probléma. Az egyik a hosszú kapcsolatból eredhet, ugyanis a tűzfalak bonthatják őket. A másik pedig a kapcsolatok számából adódhat.

A kapcsolatok számára megoldást jelenthet subdomainek használata, de lentebb láthatjuk hogy az újabb böngészőknek már ez sem probléma. Míg az időkorlát problémáját a megfelelően időzített kapcsolat-újraépítés oldhatja meg.

Nézzük meg egy kicsit mi a helyzet a kapcsolatok számával. A HTTP/1.1-es speckóban található ajánlás alapján alapértelmezetten maximum 2 perzisztens kapcsolatot szabadna fenntartani.

Clients that use persistent connections SHOULD limit the number of simultaneous connections that they maintain to a given server. A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy.

Amennyiben a böngésző limitje 2, ez annyit jelent, hogy ha már van 2 HTTP kapcsolat, akkor minden későbbi kérés várat magára amíg a 2-ből valamelyik be nem fejeződik.

Alapértelmezetten a maximum HTTP/1.1 perzisztens kapcsolatok böngészőnként
BöngészőKapcsolat
IE 62
IE 72
IE 72
Firefox 22
Safari4
Opera 94
Firefox 36
IE 86
Chrome 67

(Firefoxnál az about:config-ban a max-persistent-connections-per-server opció alatt állítható át. IE-ről részletesebben a tudásbázisban olvashattok.)

Comet: Long-polling

A Long-polling technikának az egyik célja, hogy az egyszerű pollingnál esetlegesen fennálló kapcsolatkiépítési erőforrás problémákat kiküszöbölje. Mégpedig úgy, hogy a kliens indít egy kérést, viszont a szerver nem feltétlenül küldi azonnal a választ. A szerver addig (és/vagy bizonyos ideig) tartja fenn a kapcsolatot, amíg nem érkezik hozzá adat, és csak ezután juttatja el a klienshez az adatot. A kliens az adat fogadása után bontja, majd újra kiépíti a kapcsolatot. Tehát takarékosabban bánunk a kapcsolatokkal, és mivel a kapcsolatot újból építjük, így nem veszélyeztet minket annak bontása egy külső egyed által. Probléma természetesen itt is lehet: amennyiben túlságosan gyakoriak a kapcsolat kiépítések, akkor visszajutunk a szimpla pollinghoz.

Kliens oldali kód: (hasonló kód, mint a pollingnál, annyi különbséggel, hogy a 17-20. sort felülírjuk az alábbival):


else if (xhr.status == 404)
{
    connect_num = 0;
    setTimeout('xhr_request()', 5000);
}

Szerver oldalon a változás mindössze annyi, hogy az adatot nem feltétlenül azonnal szolgáltatjuk. Hanem mondjuk a szerver pingeli az adatbázist, hogy beírásra került-e egy új sor és amennyiben igen, akkor végrehatja annak a küldését.

Comet: Forever frame

Ez a technika egy iframe elemen keresztül valósítja meg a kommunikációt a szerverrel. Létrehozunk egy iframe-et, aminek forrásként beállítjuk a szervert. Kihasználjuk az iframe egyik tulajdonságát, miszerint az erőforrás beolvasása és megjelenítése után automatikusan lefuttatja a beágyazott JavaScript kódot. Az inkrementális megjelenítést alapul véve üzenet darabokat küldünk az iframe-nek, amelyek valójában JavaScriptet tartalmaznak. Ezeket a script darabokat az iframe ugyanúgy lefuttatja, így lehetőséget kaptunk a kommunkiációra. A szervertől kapott javascriptben a szülő egy függvényét fogjuk meghívni, átadva annak egy tetszőleges tartalmat. Ez a megoldás – amely egyébként a GMailben jelent meg – minden böngésző alatt működik, de itt is adódnak problémák.

Amíg nyitott a kapcsolat a böngésző ezt jelzi nekünk, úgy mintha töltené a weblapot (igazából azt is csinálja, csak az iframe-en belül): IE-ben az animált ikon és a progress bar, Firefoxban az állapotsor jelzi, hogy bizony a háttérben még folyik valami. Safariban és Operában is hasonló problémák jelentkeznek.

Egy másik probléma, hogy egyes böngészőknél szükséges elküldeni egy bizonyos mennyiségű tartalmat, hogy a folyamat elkezdődjön. Ezt például whitespace-ekkel megoldhatjuk.

Tehát amennyiben a forever frame megoldás mellett döntünk, elfogadjuk hogy a felhasználói élményből bizony vesztünk. Internet Explorer esetében még egy taget is el kell küldenünk, például <span></span> vagy akár egy <br /> is megteszi.

Kliens oldali kód:


<html>
    <head>
        <script type="text/javascript">
            var refresh_interval = null;

            function update_page(content)
            {
                if (content == 'xxx-end-xxx')
                {
                    clearInterval(refresh_interval);
                    return;
                }

                var new_div = document.createElement('div');
                new_div.innerHTML = content;
                document.body.appendChild(new_div);
            }

            function start()
            {
                window.frames['forever_frame'].location = 'server.php';

                // 10 másodpercenként újraindítjuk a kapcsolatot
                refresh_interval = setInterval('refresh()', 10000);
            }

            function refresh()
            {
                window.frames['forever_frame'].location = 'server.php';
            }

            // Request
            window.onload = start;
        </script>
    </head>
    <body>
        <iframe width="0" height="0" style="visibility: hidden" src="about:blank" name="forever_frame"></iframe>
    </body>
</html>

Szerver oldali kód:


<html>
    <head>
    </head>
    <body>
        <?php

        // Sok whitespace (Chrome, Safari)
        for ($i = 0; $i < 1024; ++$i)
        {
             echo ' ';
        }
        ob_flush();
        flush();

        function push($content)
        {
            echo "<script type=\"text/javascript\">parent.update_page('{$content}');</script>";
        }

        $datas      = array('Sziasztok', 'kedves', 'Weblabor', 'olvasok.', 'xxx-end-xxx');

        foreach ($datas as $data)
        {
            sleep(1);

            push($data);

            ob_flush();
            flush();
        }
        ?>
    </body>
</html>

Comet: XHR streaming

Amikor egy az XHR objektumon keresztül egy Ajax kérést indítunk egy szerver felé, akkor türelmesen várunk, hogy végre a readyState tulajdonság státuszánál beálljon a négyes (readyState == 4 – a kérés befejezettnek minősül és rendelkezésre áll a válasz) stádium, amikor az adat megérkezik hozzánk. Mi van azonban akkor, ha mi folyamatosan küldjük az adatokat a böngészőnek egy bizonyos MIME tartalom típussal megbélyegezve. Nos, ilyenkor a kérés „feldolgozás alatt” állapotban marad, ő lesz a 3-as státusz. Ez nekünk nagyon jó, mert így minden egyes új adat érkezésénél, azaz ha a readyState egyenlő 3-mal, mi kiolvashatjuk az éppen a szerver által elküldött adatot. Tehát az értelmesebb böngészőkben az onreadystatechange minden egyes új adat érkezésnél triggerelődik. A korábban említett MIME típus pedig a application/x-dom-event-stream. Az adatformátumot nem ajánlott XML-nek megválasztani, azt szeretjük egyben látni, nem pedig darabokban.

Internet Explorerben nem csalódhatunk, ugyanis az elvártakhoz képest másképp működik, mint a többi böngésző, és nem triggerelődik minden új adat esetén az onreadystatechange. Így IE alatt erről a módszerről le is mondhatunk.

Egy apróságra még érdemes odafigyelni, ami mindössze annyi, hogy a válasz üzenet inkrementálódik. Azaz mindig megkapjuk egyben az előző üzeneteket és az aktuálisan elküldöttet, így valamilyen módon szükséges kiválasztanunk a számunkra értékes adatot.

A felhasználói élményt figyelembe véve jól állunk, a böngésző nem jelzi, hogy a kapcsolat él és adatra várunk. Nálam Firefoxban 1 másodpercig futott az ajaxos kérés, majd befejeződött, de az adatok jöttek tovább.

Chrome alatt csak abban az esetben működik a dolog, ha megadjuk (ez a helyes eljárás) a(z elvárt) tartalom típusát, a már korábban említett dom-event-stream-et.

Safariban egy 256 byte-os „előtartalomra” van szükség, ahhoz hogy egyáltalán elkezdjen velünk foglalkozni a böngésző (WebKites kezelésről részletesebben).

Kliens oldali kód:


<html>
    <head>
        <script type="text/javascript">
        var xhr = null;
        var boundary = 0;
        function xhr_request()
        {
            xhr = new XMLHttpRequest();
            xhr.onreadystatechange=function()
            {
                if (xhr.readyState == 3 && xhr.status == 200)
                {
                    msg = xhr.responseText;

                    msg = msg.substring(boundary + 1, xhr.responseText.length-1);
                    boundary = xhr.responseText.lastIndexOf(';');

                    document.getElementById('content').innerHTML = msg;
                }
                else if (xhr.readyState == 4 && xhr.status == 200) // Ide akáridőtúllépés
                                                                   // miatt is kerülhetnénk!
                {
                    document.getElementById('content').innerHTML = '--Uzenet vege--';
                }
            }
            xhr.open("GET","server.php",true);
            xhr.send(null);
        }

        // Request
        window.onload = xhr_request;
        </script>
    </head>
    <body>
    <div id='content'></div>
    </body>
</html>

Szerver oldali kód:


<?php

header("Content-Type: application/x-dom-event-stream"); // Firefox alatt enélkül is hasít

$datas      = array('Sziasztok', 'kedves', 'Weblabor', 'olvasok.', 'xxx-end-xxx');

echo ';'; // boundary
foreach ($datas as $data)
{
    sleep(1);

    echo $data;
    echo ';'; // boundary

    ob_flush();
    flush();
}

// EOF

IE HTTP-folyamkezelése

A forever frame a felhasználói élményből vesz el egy keveset, míg az XHR streaming IE alatt nem nyújt megoldást. Viszont IE alatt is van lehetőség a HTTP-folyam kialakítására anélkül, hogy áldoznánk a megjelenítés oltárán. A Google mérnökei a GTalk fejlesztése közben akadtak rá (állítólag hiányosan volt dokumentálva egy-két rész, ezért nevezhető „felfedezésnek”) egy ActiveX elemre, a HtmlFile-ra. Alex Russel írt először kézzel foghatót róla. A HtmlFile készít egy olyan HTML dokumentumot a memóriában, amely nem tartozik a böngészőnkhöz. És pont ez a jó benne, mert úgy tudunk vele műveletet végezni, hogy az nincs kihatással a böngészőnkre. Szóval a Google-nél egy ilyen HtmlFile objektumba tettek egy iframe elemet, amellyel kész is a streaming IE alatt iframe-mel, amely nem befolyásolja a böngészőt, így az nem ad visszajelzést a nyitott kapcsolatról. (A bejegyzésben látható a technika, gyakorlati megvalósításának lényege.)

Végszó

Nos, ide csak annyit, hogy remélem sikerült egy átfogó képet nyújtani a különböző WebSocket előtti technológiákról és azok implementálásáról. Következőnek jönnek a Websocketek!

A cikk illusztrációját AJ Canntől vettük kölcsön.

 
bh arcképe
bh
Webfejlesztőként tevékenykedem. Leginkább a szerver oldal iránt, elsősorban az architekturális tervezés, webes biztonság és az új technológiák iránt érdeklődöm.
1

2 apróság

T.G · 2010. Dec. 13. (H), 09.33
Szia! Bár még csak átfutottam, de elsőre érdekes, és tanulságos cikknek tűnik.

Szerintem szebb lenne a setTimeout('xhr_request()', 1000); helyett a setTimeout(xhr_request, 1000); forma. Ha nem szükséges, akkor ne használjunk eval-t!

A másik is csak egy apró egyszerűsítés: echo str_repeat(' ', 1024); a ciklus helyett.
4

Köszönöm és hadd tegyem

bh · 2010. Dec. 16. (Cs), 10.42
Köszönöm és hadd tegyem hozzá, hogy bár nincs megemlítve, de a kódok csupán a technikák megértését szolgálják, produktív kód írása nem volt cél. Ettől függetlenül teljesen igazad van :)
2

inf · 2010. Dec. 13. (H), 22.42
Szia!
Tetszik a cikk. Talán még annyival kiegészítettem volna, hogy a timeout-ot milyen beállításokkal lehet elkerülni.
5

Köszi. A kiegészítés pedig

bh · 2010. Dec. 16. (Cs), 10.53
Köszi. A kiegészítés pedig tényleg elfért volna még, sajnos erre nem gondoltam.
3

WebSocket

presidento · 2010. Dec. 15. (Sze), 09.24
A WebSocket azért még - biztonsági hiba miatt - várat magára...
http://hacks.mozilla.org/2010/12/websockets-disabled-in-firefox-4/
6

Ez számomra kimaradt,

bh · 2010. Dec. 16. (Cs), 11.04
Ez számomra kimaradt, viszonylag új paper-ről van szól. Amely böngészőkben eddig teszteltem (Chrome8, Firefox4beta7-ig bezárólag) még ment rendesen, de úgy látom a beta8-nál ért volna a meglepetés. Így a cikk is várat majd magára, amíg nem javítják a bugot. Mindenesetre köszönöm az infot.
7

Írd meg a cikket

presidento · 2010. Dec. 16. (Cs), 22.48
Szerintem írd meg a cikket, amúgy is sokan körbejárták már – angolul. És egyébként nem mindenki osztja Mozilláék és Operáék véleményét: http://blog.pusherapp.com/2010/12/9/it-s-not-websockets-it-s-your-broken-proxy
8

Úgyis csak jövő év eleje

bh · 2010. Dec. 17. (P), 09.54
Úgyis csak jövő év eleje felé, jóval a kijózanodás után tervezem az elkezdését :) A bugot javítók már remélhetőleg aktívabbak lesznek. Az implementáció módja valószínűleg nem változik sokat (ha egyáltalán vátozik), de ha Firefox bácsi tétovázik az semmiképpen sem jó.
9

elgépelés

Visko · 2011. Feb. 21. (H), 16.36
Szia!

A Comet: Forever frame rész kliens oldali kódjában van egy apró elírás:
// 15 másodpercenként újraindítjuk a kapcsolatot
refresh_interval = setInterval('refresh()', 10000);
15sec ? 10000

És ha már ajaxos technikák: egyik alkalmazási területe egy adott rész periodikus, vagy igény-szerinti frissítése, de egy másik, hogy felhasználói eseményre az oldal csak egy részét kelljen újratölteni. Viszont ez esetben megoldott, hogy az URL-ben is látszódjon a dolog? Így ügye az URL-t átmásolva más felhasználónak ugyanazt az oldalt látják mindannyian.
10

Javítva

Török Gábor · 2011. Feb. 21. (H), 16.38
Kösz a jelzést, javítottam.
11

Hash

Poetro · 2011. Feb. 21. (H), 16.51
Az URL-ben a hash-t lehet változtatni, és akkor megjelenik az URL-ben is a különbség. Milyen URL-t másolnak át, és miért látják ugyanazt az oldalt? Tudtommal a HTTP lekérések elküldik a cookie információt, így lehet session-t is kezelni szerver oldalon. Azaz két különböző felhasználónak ugyanaz az oldal már jelenthet teljesen más tartalmat is.