HTML5 EventSource: Serverem zasílané události proudí nepřetržitě…

Nedivil bych se, kdyby někdo z vás nepřemýšlel, jestli o tom někdy v minulosti už slyšel. Co to sakra jsou „Server-sent events“? Mnoho lidí o nich nikdy neslyšelo, i když jsou s námi už docela dlouho. Za ta léta specifikace zaznamenaly významné změny a API vzalo za své, právě díky novějším sexy komunikačním protokolům, jako jsou třeba WebSockety.

Tento článek vychází z tutoriálu Stream Updates with Server-Sent Events od Erica Bidelmana, který vyšel pod licencí Creative Commons Attribution 3.0.

Myšlenka „Server-Sent Events“ může být přirovnávána ke známému modelu PUB/SUB (Publish – Subscripe): Webová aplikace se na serveru „přihlásí k odběru“ proudu aktualizací (subscribe) a vždy, když dojde k nové události (publish), klient obdrží oznámení. Pokud ale chcete opravdu pochopit Server-Sent Events (dále jen SSEs), musíte pochopit výhody oproti omezením staršího AJAXu nebo novějších WebSocketů.

Úvodem

Polling (dotazování) je tradiční technika používaná v drtivé většině AJAXových aplikací. Základní princip je, že se aplikace opakovaně dotazuje serveru na nová data pomocí XMLHttpRequestu. Pokud dobře znáte HTTP protokol, víte, co vše obnáší celý cyklus požadavku a odpovědi. Klient udělá dotaz na server a čeká, že server odpoví. Pokud ale není nic nového, vrátí se prázdná odpověď (ať už použijete HTTP kód 204 No Content nebo třeba prázdný JSON…)

Co je největším problémem? Každý dotaz vytváří větší zátěž (nový požadavek, hlavičky, cookies+session, …), která je zbytečná. To při velkém množství klietů a krátkém intervalu (třeba 1–2 sekundy) udělá se serverem divy. :-)

Long polling (dlouhé dotazování) též Hanging GET / COMET je variace na téma výše uvedeného. Při dlouhém dotazování server drží připojení a pozdrží odpověď, dokud nejsou nová data k dispozici nebo dokud nenastane nějaký timeout (typicky něco těsně pod minutu). Poté se celý cyklus opakuje. Efekt je, že server konstantně odpovídá a posílá data hned, jak jsou dostupná. Existují i varianty používající různé hacky, jako je např. přilepování HTTP script značek do neviditelného iframe elementu, vše v rámci jednoho požadavku. Pro vynucení udržení spojení se občas pošlou nějaké mezery, aby nebylo spojení ukončeno na routerech.

Server-Sent Events (SSEs – serverem odesílané události) byly naproti tomu navrženy od začátku tak, aby byly efektivní a nebylo potřeba takovýchto hacků. Pokud se komunikuje pomocí SSEs, server může nahrnout data do klientské aplikace, kdykoliv se mu zlíbí (pokud je připojen). Jinými slovy, události proudí ze serveru nepřetržitě a jsou odeslány ihned, jak se stanou.

SSEs otevírají jednosměrný kanál od serveru ke klientovi. Hlavní rozdíl mezi dlouhým dotazováním (long-polling) a Server-Sent Events je ten, že SSEs jsou obsluhovány přímo prohlížečem a uživatel prostě a jednoduše naslouchá novým zprávám.

Server-Sent Events vs. WebSockety

Proč zvolit Server-Sent Events před WebSockety? Dobrá otázka. Jeden důvod je te ten, že pozdější API jako WebSockety poskytují bohatší protokol pro vytváření plně obousměrného (full-duplex) spojení. Mít obousměrný kanál je atraktivní pro hry, chatovací aplikace a pro vše, kde je nezbytné obousměrné realtime připojení.

V některých případech klient nepotřebuje komunikovat se serverem. Prostě je třeba jen získávat aktualizace od serveru. Pár příkladů: aktualizace stavu přátel na sociálních sítích, aktuální stav na burze, zobrazování logů, novinky (třeba RSS/Atom čtečka) nebo aktualizace dat na straně klienta (IndexedDB). Pokud je potřeba něco občas odeslat, poslouží starý dobrý známý XMLHttpRequest.

SSEs jsou odesílány přez tradiční HTTP protokol, to znamená, že není třeba nového protokolu nebo serverové implementace. WebSockety naopak potřebují podporu jak na straně klienta, tak na serveru. Naproti tomu Server-Sent Events mají spoustu vlastností, které WebSockety postrádají už z návrhu, jako automatické znovu-připojení (automatic reconnection), event IDs a možnost posílat libovolné události.

JavaScript API

Pro přihlášení k odběru (subscription) vytvořte objekt EventSource, který přijímá URL proudu:

if (!!window.EventSource) {
  var eventSource = new EventSource('stream.php');
} else {
  // Použijeme náhradní implementaci nebo polyfill - zase ten IE :(
}

Pozor: Pokud URL předaná do konstruktoru EventSource je absolutní, její origin se musí shodovat s tím z volající stránky nebo musejí být správně nastaveny CORS hlavičky.

Dalším krokem je ošetřit událost message. Volitelně lze též odposlouchávat události open a error:

eventSource.addEventListener('message', function(messageEvent) {
  if (console.log) console.log('message', messageEvent);
  document.querySelector('#container').innerHTML += messageEvent.data + '<br/>';
}, false);

eventSource.addEventListener('open', function(event) {
  if (console.log) console.log('open', event);
  document.querySelector('#container').innerHTML += '<hr/><i>Open</i><br/>';
}, false);

eventSource.addEventListener('error', function(event) {
  if (console.error) console.error('error', event);
  document.querySelector('#container').innerHTML += '<i>Error: readyState = ' +
    event.target.readyState + '</i><br/>';
}, false);

Poté, co server odešle novou zprávu, je zavolána událost onmessage. Data jsou v obslužné funkci dostupná v property e.data.

Kouzelné na tom je to, že pokaždé, když je spojení uzavřeno se prohlížeč pokusí připojení obnovit automaticky po cca 3 sekundách. Implementace na serveru má nad touto vlastností kontrolu, ale to si ukážeme v následující části článku. A to je vše. Klient je nyní připraven zpracovávat události, které mu pošle třeba skript stream.php :-)

Formát dat zasílaných serverem — Event Stream

Odesílání proudu událostí ze serveru (Event Stream) je záležitostí skládání odpovědi prostého textu. Jediný rozdíl je hlavička Content-Type: text/event-stream, která říká browseru, že server skutečně bude odesílat sled událostí. V nejzákladnější formě musí odpověď obsahovat rádek začínající data:. Oddělovačem zpráv jsou pak dva znaky nového řádku, tedy \n\n. Zprávy jsou tedy odesílány pomocí formátu známého z HTTP hlaviček. Ukažme si tedy příklad kompletní odpovědi, včetně hlaviček:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/event-stream; charset=UTF-8
Cache-Control: no-cache

data: Zpráva 1

data: 2. zpráva na více řádků
data: vypadá takto. Všimněte si,
data: že každý řádek zprávy začíná
data: sekvencí data:

data: A toto je třetí zpráva

Všimněte si 2. zprávy, která ve výsledku bude jednou událostí (jednou string hodnotou), kde jednotlivé řádky budou spojeny znakem nového řádku \n.

Všimněte si rovněž hlavičky Cache-Control: no-cache, která pomáhá předcházet možným problémům s caching proxy servery.

I když SSEs neposkytují žádnou speciální podporu pro JSON, je možné použít klasickou konstrukci JSON.parse(e.data).

Jedinečný identifikátor zprávy

Každá zpráva může mít svůj jedinečný identifikátor id:, který se odešle serveru nazpět v hlavičce požadavku Last-Event-Id, pokud z nějakého důvodu spadne spojení. To neplatí, pokud uživatel stránku obnoví, což je logické, protože v takovém případě musí klientská aplikace nastartovat znovu od začátku.

Proud potom bude vypadat takto:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/event-stream; charset=UTF-8
Cache-Control: no-cache

id: 1
data: Zpráva 1

id: msg2
data: 2. zpráva na více řádků
data: vypadá takto.

id: 333
data: A toto je třetí zpráva

Na straně klienta je tento identifikátor dostupný v obsluze události zprávy jako messageEvent.lastEventId.

Časový limit pro nový pokus o připojení

Prohlížeč se pokouší automaticky znovu připojit na server asi po třech sekundách od uzavření spojení. Toto chování se dá ovlivnit pomocí parametru zprávy retry: následovaného počtem milisekund. Takto vypadá nastavení prodlevy na 10 sekund:

retry: 10000
data: Ahojky :-)

Události mají jména

Jeden zdroj může generovat více logických typů událostí. Pokud zpráva obsahuje řádek event:, bude na klientovi volána obslužná funkce přiřazená k tomuto jménu:

event: welcome
data: Ahoj, toto je uvítací zpráva,
data: liší se tím, že má pro klienta jinou sémantiku.

Obsluha na klientovi vypadá pak takto:

eventSource.addEventListener('welcome', function(messageEvent) {
  if (console.log) console.log('welcome message', messageEvent);

  document.querySelector('#container').innerHTML +=
    '<hr/><b>Uvítání:</b> ' + messageEvent.data + '<br/>';
}, false);

Pokud není jméno události zasláno, volá se výchozí obsluha 'message', dá se tedy říct, že výchozí hodnota je event: message.

Implementace Node.js serveru

Dal jsem dohromady jednoduchý ukázkový projekt, který naleznete na GitHubu, který využívá express framework. Můžete si projít postup i hezky po commitech. Zde uvedu kód pouze jedné routy, která implementuje vše důležité:

// routa pro express
app.get('/events', function(req, res) {
  console.log("Klient připojen");

  res.header('Content-Type', 'text/event-stream; charset=UTF-8');

  // doboručeno pro vyhnutí se problémům
  res.header('Cache-Control', 'no-cache');

  // hexadecimální ID poslední přijaté zprávy klientem,
  // pokud se obnovuje spojení
  var eventId = parseInt(req.get('Last-Event-Id') || 0, 16);

  // klient počká deset sekund, než se pokusí obnovit spojení
  res.write('retry: 10000\n');

  // zašleme uvítací zprávu při každém připojení (i po obnovení)
  res.write('event: welcome\n');
  res.write('data: Hello Server-Sent Events!\n');
  res.write('data: This is example of multiline message.\n');
  // Two following (line above and next line) newline characters
  // indicates event message end
  res.write('\n');

  // a od teď budem zasílat každou vteřinu zprávu
  var handle = setInterval(function() {
    eventId++;
    // převedeme ID opět na hexadecimální
    var hexEventId = eventId.toString(16);

    res.write('id: ' + hexEventId + '\n');
    res.write('data: Event 0x' + hexEventId + ': ' + new Date + '\n');
    // Jeden prázdný řádek pro dokončení zprávy
    res.write('\n');
  }, 1000);

  // pokud se klient odpojí...
  res.on('close', function() {
    console.log("Client disconnected");
    clearInterval(handle);
  });

  // pokud se server odpojí res.end()
  res.on('finish', function() {
    console.log("Server disconnected");
    clearInterval(handle);
  });

  // tento kód odpojí server za 30 sekund
  /*
  setTimeout(function() {
    res.end();
  }, 30000);
  */
});

A ještě pro zajímavost minimalistická implementace bez externích závislostí, kterou zkopírovat do node REPL konzole a běží:

var http = require('http');
var fs = require('fs');

var port = process.env.PORT || 3000;
var paths = {};

paths['/events'] = function(req, res) {

  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=UTF-8',
    'Cache-Control': 'no-cache'
  });

  var sendMessage = function() {
    res.write('data: ' + new Date + '\n');
    res.write('\n');
  };

  // první zpráva ihned
  sendMessage();

  // a od teď budem zasílat každou vteřinu další
  var handle = setInterval(sendMessage, 1000);

  // pokud se klient odpojí...
  res.on('close', function() {
    console.log("Client disconnected");
    clearInterval(handle);
  });

  // pokud se server odpojí res.end()
  res.on('finish', function() {
    console.log("Server disconnected");
    clearInterval(handle);
  });
};

paths['/'] = function(req, res) {
  res.end([
    '<!DOCTYPE html>',
    '<html>',
    '  <head>',
    '    <title>Simplest Event Source</title>',
    '  </head>',
    '  <body>',
    '    <script>',
    '      var eventSource = new EventSource("/events");',
    '      eventSource.addEventListener("message", function(messageEvent) {',
    '        document.body.innerHTML = messageEvent.data;',
    '      });',
    '    </script>',
    '  </body>',
    '</html>'
  ].join('\n'));
};

http.createServer(function (req, res) {
  var path = req.url;

  if (paths[path])
    paths[path](req, res);
  else {
    res.writeHead(404, {
      'Content-Type': 'text/plain; charset=UTF-8'
    });
    res.end('No route to ' + path);
  }

}).listen(port, '127.0.0.1');
console.log('Server běží na http://127.0.0.1:' + (process.env.PORT || 3000));

PHP

Na Github jsem uložil i projekt php-server-sent-events-demo, který demonstruje implementaci pro PHP. Kód pro events.php vypadá takto:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');

/**
 * Vytvoří a odešle zprávu klientovi
 *
 * @param string $id Jedinečný identifikátor zprávy
 * @param string $msg Text zprávy
 */
function sendMsg($id, $msg) {
  echo "id: $id\n";
  echo "data: $msg\n";
  echo "\n";
  ob_flush();
  flush();
}

$i = 1;

// Nekonečná smyčka
while(1) {
  sendMsg($i++, 'server time: ' . date("h:i:s", time()));
  sleep(1);
}

Všimněte si zde nekonečné smyčky na konci a hlavně volání sleep(1), která počká jednu sekundu. Pak se odesílá zpráva znovu.

V PHP (nebo spíše na serveru, který používáte) si dejte pozor, zda je omezený maximální počet současně připojených klientů a jaká je nastavena maximální doba běhu skriptu.

Uzavření proudu zpráv

Pro ukončení odběru lze v klientském skriptu zavolat metodu close:

eventSource.close();

Klient se pokouší znovu připojit, pokud je spojení nějak přerušeno. Pokud chce ale server klienta odmítnout tak, aby se již dále nesnažil připojit, stačí odpovědět s jiným Content-Type nebo HTTP status kódem odlišným od 200 OK, jako vhodný se jeví 404 Not Found.

Užitečné odkazy

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: 9

Přehled komentářů

Martin Kupec Chybka v kódu
Pavel Lang Re: Chybka v kódu
SUK Long polling?
Pavel Lang Re: Long polling?
SUK Re: Long polling?
Karel Bohuzel jako u vseho
MW Re: Bohuzel jako u vseho
ivec Pocet spojeni ...
Pavel Lang Re: Pocet spojeni ...
Zdroj: https://www.zdrojak.cz/?p=11634