Strumieniowanie aktualizacji ze zdarzeniami wysyłanymi przez serwer

Zdarzenia wysyłane przez serwer (SSE) wysyłają automatyczne aktualizacje do klienta z serwera za pomocą połączenia HTTP. Po nawiązaniu połączenia serwery mogą rozpocząć przesyłanie danych.

Możesz używać SSE do wysyłania powiadomień push z aplikacji internetowej. SSE wysyłają informacje w jednym kierunku, więc nie będziesz otrzymywać aktualizacji z klienta.

Koncepcja SSE może być Ci znana. Aplikacja internetowa „subskrybuje” strumień aktualizacji generowanych przez serwer i za każdym razem, gdy wystąpi nowe zdarzenie, wysyła powiadomienie do klienta. Aby jednak dobrze rozumieć zdarzenia wysyłane przez serwer, musimy poznać ograniczenia ich poprzedników opartych na AJAX. Obejmuje to m.in.:

  • Odpytywanie: aplikacja wielokrotnie odpytuje serwer o dane. Z tej techniki korzysta większość aplikacji AJAX. W przypadku protokołu HTTP pobieranie danych odbywa się w ramach formatu żądania i odpowiedzi. Klient wysyła żądanie i czeka na odpowiedź serwera z danymi. Jeśli nie ma żadnych dostępnych, zwracana jest pusta odpowiedź. Dodatkowe sondowanie zwiększa obciążenie HTTP.

  • Długie wywoływanie (wywołanie GET / COMET): jeśli serwer nie ma dostępnych danych, zatrzymuje żądanie otwarte do momentu udostępnienia nowych danych. Dlatego ta technika jest często nazywana „zawieszonym żądaniem GET”. Gdy informacje staną się dostępne, serwer odpowie, zamknie połączenie i powtórzy proces. W związku z tym serwer stale odpowiada nowymi danymi. Aby to zrobić, programiści zwykle stosują różne sztuczki, np. dołączają tagi skryptu do „nieskończonego” elementu iframe.

Wydarzenia wysyłane przez serwer zostały zaprojektowane od podstaw pod kątem wydajności. Podczas komunikacji z SSE serwer może przesyłać dane do Twojej aplikacji w dowolnym momencie, bez konieczności wysyłania początkowego żądania. Innymi słowy, aktualizacje mogą być przesyłane strumieniowo z serwera do klienta w miarę ich pojawiania się. SSEsotwiera jeden kanał jednokierunkowy między serwerem a klientem.

Główna różnica między zdarzeniami wysyłanymi przez serwer a długim pollingiem polega na tym, że zdarzenia wysyłane przez serwer są obsługiwane bezpośrednio przez przeglądarkę, a użytkownik musi tylko odbierać wiadomości.

Zdarzenia wysyłane przez serwer a WebSocket

Dlaczego warto wybrać zdarzenia wysyłane przez serwer zamiast WebSockets? Dobre pytanie.

WebSockets to bogaty protokół z dwukierunkową komunikacją pełnego dwukierunkowego. Kanały dwukierunkowe lepiej sprawdzają się w przypadku gier, aplikacji do przesyłania wiadomości i wszelkich zastosowań, w których potrzebne są aktualizacje w czasie zbliżonym do rzeczywistego w obu kierunkach.

Czasami jednak wystarczy jednokierunkowa komunikacja z serwerem. Na przykład gdy znajomy zaktualizuje swój status, tickery giełdowe, kanały z wiadomościami lub inne automatyczne mechanizmy przesyłania danych. Innymi słowy, aktualizacja bazy danych Web SQL po stronie klienta lub magazynu obiektów IndexedDB. Jeśli chcesz wysłać dane na serwer, XMLHttpRequest zawsze Ci w tym pomoże.

SSE są wysyłane przez HTTP. Nie trzeba stosować żadnego specjalnego protokołu ani implementacji serwera. WebSockets wymagają połączeń full-duplex i nowych serwerów WebSocket, aby obsługiwać ten protokół.

Poza tym zdarzenia wysyłane przez serwer mają wiele funkcji, których WebSockets nie obsługuje z powodu swojej konstrukcji, m.in. automatyczne ponowne nawiązywanie połączenia, identyfikatory zdarzeń i możliwość wysyłania dowolnych zdarzeń.

Tworzenie źródła zdarzenia za pomocą JavaScriptu

Aby zasubskrybować strumień zdarzeń, utwórz obiekt EventSource i podaj mu adres URL strumienia:

const source = new EventSource('stream.php');

Następnie skonfiguruj moduł obsługi zdarzenia message. Opcjonalnie możesz słuchać openerror:

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

Gdy aktualizacje są przesyłane z serwera, uruchamia się moduł obsługi onmessage, a nowe dane stają się dostępne w usłudze e.data. Magia polega na tym, że gdy połączenie zostanie zamknięte, przeglądarka automatycznie połączy się ze źródłem po około 3 sekundach. Implementacja serwera może nawet kontrolować ten czas oczekiwania na ponowne połączenie.

To wszystko. Twój klient może teraz przetwarzać zdarzenia z stream.php.

Format strumienia zdarzeń

Wysyłanie strumienia zdarzeń ze źródła polega na utworzeniu odpowiedzi w postaci zwykłego tekstu, która jest dostarczana z nagłówkiem text/event-stream Content-Type zgodnym z formatem SSE. W swojej podstawowej formie odpowiedź powinna zawierać wiersz data:, wiadomość oraz 2 znaki „\n” na końcu strumienia:

data: My message\n\n

Dane wielowierszowe

Jeśli wiadomość jest dłuższa, możesz ją podzielić na kilka wierszy data:. 2 lub więcej kolejnych wierszy zaczynających się od data: są traktowane jako pojedynczy element danych, co oznacza, że uruchamiane jest tylko 1 zdarzenie message.

Każdy wiersz powinien kończyć się pojedynczym znakiem „\n” (z wyjątkiem ostatniego, który powinien kończyć się 2 takimi znakami). Wynik przekazany do obsługi funkcji message to pojedynczy ciąg znaków połączony znakami nowego wiersza. Na przykład:

data: first line\n
data: second line\n\n</pre>

e.data pojawi się tekst „first line\nsecond line”. Następnie można użyć znaku e.data.split('\n').join(''), aby odtworzyć wiadomość bez znaków „\n”.

Wysyłanie danych w formacie JSON

Użycie wielu wierszy ułatwia wysyłanie danych JSON bez naruszania składni:

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

I możliwy kod po stronie klienta do obsługi tego strumienia:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

Powiązanie identyfikatora z wydarzeniem

Możesz wysłać unikalny identyfikator ze zdarzeniem strumienia, dodając wiersz rozpoczynający się od znaku id::

id: 12345\n
data: GOOG\n
data: 556\n\n

Ustawienie identyfikatora pozwala przeglądarce śledzić ostatnie wywołane zdarzenie, dzięki czemu w przypadku utraty połączenia z serwerem nowe żądanie będzie zawierać specjalny nagłówek HTTP (Last-Event-ID). Dzięki temu przeglądarka może określić, które zdarzenie należy wywołać. Zdarzenie message zawiera właściwość e.lastEventId.

Sterowanie czasem oczekiwania na ponowne połączenie

Po zamknięciu każdego połączenia przeglądarka próbuje nawiązać połączenie ze źródłem w ciągu około 3 sekund. Możesz zmienić ten czas oczekiwania, dodając wiersz zaczynający się od retry:, a następnie podając liczbę milisekund, przez którą ma nastąpić ponowne połączenie.

W tym przykładzie próba ponownego połączenia jest podejmowana po 10 sekundach:

retry: 10000\n
data: hello world\n\n

Określ nazwę zdarzenia

Pojedyncze źródło zdarzeń może generować różne typy zdarzeń, podając nazwę zdarzenia. Jeśli występuje wiersz zaczynający się od event:, a za nim podana jest niepowtarzalna nazwa zdarzenia, oznacza to, że zdarzenie jest z nią powiązane. Na kliencie można skonfigurować detektor zdarzeń, który będzie wykrywać to konkretne zdarzenie.

Na przykład ten fragment danych wyjściowych serwera wysyła 3 typy zdarzeń: ogólne zdarzenie „message”, zdarzenie „userlogon” i zdarzenie „update”:

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

Gdy detektory zdarzeń są skonfigurowane na kliencie:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

Przykłady serwerów

Oto podstawowa implementacja serwera w PHP:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

Oto podobne rozwiązanie w Node.js, które korzysta z obsługi Express:

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

Anulowanie strumienia zdarzeń

Zwykle przeglądarka automatycznie nawiązuje połączenie ze źródłem zdarzenia po jego zamknięciu, ale można to anulować na kliencie lub serwerze.

Aby anulować strumień z klienta, wywołaj:

source.close();

Aby anulować strumień z serwera, odpowiedz z wartością inną niż text/event-stream Content-Type lub z kodem stanu HTTP innym niż 200 OK (na przykład 404 Not Found).

Obie metody uniemożliwiają przeglądarce ponowne nawiązanie połączenia.

Kilka słów o bezpieczeństwie

Żądania generowane przez EventSource podlegają tym samym zasadom dotyczącym pochodzenia co inne interfejsy sieciowe, np. fetch. Jeśli chcesz, aby punkt końcowy SSE na serwerze był dostępny z różnych źródeł, dowiedz się, jak go włączyć za pomocą współdzielenia zasobów pomiędzy serwerami z różnych domen (CORS).