WebTech | WebSockets

Server-Push und bidirektionale Kommunikation mit WebSockets
Kommentare

Benutzer wollen über neue Ereignisse, wie beispielsweise das Eintreffen einer Nachricht, benachrichtigt werden oder sich online über ihre Webseite austauschen. Wenn aktualisierte Daten den Benutzern sofort angezeigt werden sollen und nicht erst beim nächsten Seitenaufruf, dann muss der Server die Kommunikation anstoßen. Das Web hat diesen Fall aber nicht vorgesehen – die Kommunikation wird immer vom Client initiiert. Was also tun?

In den vergangenen Jahren haben sich für die bidirektionale Kommunikation zwischen Server und Client (teils recht abenteuerliche) Verfahren unter dem Überbegriff Comet etabliert, die den Eindruck einer servergesteuerten Kommunikation erwecken: Forever Frame, Continuous Polling, HTTP-Streaming, XHR-Streaming, Long Polling, Implementierung in Flash, XHR-Multipart, -Long-Polling etc. Jede dieser Technologien hat Vor- und Nachteile. Moderne Browser bieten mit WebSockets endlich eine standardisierte Methode an.

Mit WebSockets können Browser und Server Nachrichten austauschen, ohne sich an den Anfrage-Antwort-Zyklus halten zu müssen; zudem handelt es sich um eine persistente Verbindung, ein ständiger Verbindungsauf- und -abbau entfällt. Damit kann der Server jederzeit Nachrichten an den Browser senden. Diese lösen im Browser Events aus, worauf mit JavaScript reagiert werden kann. Umgekehrt kann der Browser beliebig Nachrichten über die bestehende WebSocket-Verbindung an den Server senden. WebSockets sind damit echten TCP/IP-Verbindungen sehr ähnlich.

Die WebSocket-Spezifikation ist mittlerweile über drei Jahre alt und hat einige Überarbeitungen erfahren. Insbesondere Sicherheitsbedenken haben Änderungen im Protokoll bedingt. Inzwischen ist WebSocket eine Candidate Recommendation des W3C und ein Proposed Standard RFC. WebSocket wird aktuell von Firefox, Chrome, Opera und Safari unterstützt, Internet Explorer wird WebSocket ab Version 10 unterstützen. Safari implementiert derzeit leider nur eine ältere, unsichere Version des Protokolls.

Handshake

Das WebSocket-Protokoll nützt eine bestehende (oder zu diesem Zweck geöffnete) HTTP-Verbindung. Aus diesem Grund besteht das Protokoll aus zwei Phasen, wie in Abbildung 1 zu sehen: dem Handshake zu Beginn, der den Wechsel vom HTTP-Protokoll zum WebSocket-Protokoll aushandelt (Listing 1), und der WebSocket-Datenverbindung, sobald das Protokoll gewechselt worden ist.

Abb. 1: Übersicht über den Ablauf des WebSocket-Protokolls

Abb. 1: Übersicht über den Ablauf des WebSocket-Protokolls

Der Protokollwechsel zu Beginn wird vom Client eingeleitet, indem er über die HTTP-Header Upgrade und Connection den Umstieg auf das WebSocket-Protokoll einleitet. Listing 1 führt die wichtigsten HTTP-Header des Handshake auf. Der Client sendet zusätzlich zu den beiden erwähnten Headern den Origin-Header sowie eine Zufallszahl (Sec-WebSocket-Key) mit. Über den Origin-Header kann serverseitig der Zugriff kontrolliert werden. Der Server setzt bei erfolgreicher Antwort seinerseits die Upgrade- und Connection-HTTP-Header, bestätigt die Protokollversion und gibt mit Sec-WebSocket-Accept eine auf der zuvor übermittelten Zufallszahl basierende Antwort. Dieses einfache Challenge-Response-Protokoll stellt sicher, dass beide Seiten den Wechsel wollen und keine alten (gecachten) Antworten verwendet werden.

Listing 1: WebSocket Handshake

 > GET /chat HTTP/1.1 > Host: php.xmp.site:8080 > Connection: keep-alive, Upgrade > Upgrade: websocket > Origin: http://php.xmp.site > Sec-WebSocket-Version: 13 > Sec-WebSocket-Key: ePVtWzV9D2WyNI69E9T1fg==  < HTTP/1.1 101 Switching Protocols < Server: PHPWebsocket 1.0 < Date: Sat, 15 Dec 2012 18:42:23 +0100 < Connection: Upgrade < Upgrade: websocket < Sec-WebSocket-Accept: EiSvfWgJglChefWha6s3sik/iqs= < Sec-WebSocket-Version: 13

Datenübertragung mit Frames

Nach erfolgreichem Handshake wechselt die Verbindung von HTTP auf das WebSocket-Datenprotokoll. Letzteres besteht aus Datenframes, die Art und Länge der übertragenen Daten spezifizieren. Aus Sicherheitsgründen muss vom Client eine Maskierung der Nutzdaten vorgenommen werden. Abbildung 2 zeigt den Aufbau eines Frames.

Abb. 2: Aufbau eines Daten-Frames des WebSocket-Protokolls [2]

Abb. 2: Aufbau eines Daten-Frames des WebSocket-Protokolls [2]

Die Struktur erlaubt das Senden verschiedener Nachrichten (mithilfe des Opcodes), die auch fragmentiert werden können. Die einzelnen Felder haben dabei folgende Bedeutungen:

  • Das FIN-Bit an erster Stelle gibt an, ob es sich um eine fragmentierte Nachricht handelt. Beim letzten Frame einer Nachricht ist FIN == 1, sonst 0. Ist eine Nachricht nicht fragmentiert (Standardfall), dann ist gleich beim ersten Frame FIN == 1.
  • Das Opcode-Feld gibt an, um welche Art von Frame es sich handelt. Es gibt Daten- und Kontrollframes.
  • Das MASK-Bit gibt an, ob die Nutzdaten mit dem angegebenen Maskierungsschlüssel XOR-verknüpft werden sollen. Die Maskierung der Nutzdaten von Client zu Host ist aus Sicherheitsgründen verpflichtend, in die umgekehrte Richtung ist sie optional.
  • Die Länge der Nutzdaten wird in den nächsten 7 Bit bestimmt. Sind die Nutzdaten kleiner als 126 Byte, werden nur diese 7 Bit benützt. Sind die Daten 127 bis 65535 Byte lang, wird der Wert auf 126 gesetzt, und die zwei folgenden Byte enthalten die Länge der Daten als 16-Bit-Wert. Sind die Daten noch länger, wird der Wert auf 127 gesetzt, und die folgenden acht Byte enthalten die Länge als 64-Bit-Wert.
  • Falls die Nutzdaten maskiert sind, folgt der 32 Bit lange Maskierungsschlüssel.
  • Darauf folgen die eigentlichen Nutzdaten. Es können auch Frames ohne Nutzdaten gesendet werden (in diesem Fall ist das Längenfeld gleich null).

Arten von Frames

Frames werden anhand des Opcode-Feldes in Datenframes und Kontrollframes unterschieden. Als Datenframes sind derzeit Textdaten (in UTF-8, Opcode == 1) und Binärdaten (Opcode == 2) definiert. Wird eine Nachricht fragmentiert, enthält der erste Frame den Opcode der Nachricht und ein gelöschtes FIN-Bit. Die nachfolgenden Frames haben den Opcode 0 und ein gelöschtes FIN-Bit, beim letzten Frame ist das FIN-Bit gesetzt.

Zusätzlich gibt es einen Close-Frame (Opcode == 8), um das Ende einer Verbindung anzuzeigen. Im Frame kann optional eine Begründung für das Schließen angegeben werden [2]. Um zu überprüfen, ob die Gegenstelle noch aktiv ist, sind Ping- (Opcode == 9) und Pong-Frames (Opcode == 10) definiert. Die Nutzdaten der Pong-Antwort sollen eine Kopie der Daten des Ping-Frames sein. Es ist auch möglich einen Pong-Frame ohne Aufforderung zu senden.

JavaScript-API

Die Ansteuerung einer WebSocket im Browser ist ähnlich wie die eines XMLHttpRequests: Ein WebSocket-Objekt wird instanziiert, und sobald die Verbindung geöffnet ist, können mit send() Daten übertragen werden. In Folge kann mittels eines EventListeners auf empfangene Daten reagiert werden. Eine gesonderte open()-Methode gibt es nicht, die WebSocket wird bereits durch das Instanziieren geöffnet und der Handshake eingeleitet. Tabelle 1 gibt einen Überblick über die Methoden und Eigenschaften des WebSocket-Objekts. Die EventListener können entweder über die angeführten Eigenschaften (onopen, onerror, onclose, onmessage) beim Objekt gesetzt werden oder über die Methode addEventListener() (Events open, error, close, message).

Methode, Attribut

Beschreibung

new (URL, [ subprotokolle ])

Konstruktor, der neben dem URL (Schema: ws://) optional einen Subprotokollparameter (String oder Array) übernimmt.

DOMString url

Geöffneter WebSocket-URL (nur zum Auslesen)

DOMString extensions

Enthält Informationen zu zukünftigen Erweiterungen des WebSocket-Protokolls, die vom Server ausgewählt worden sind.

DOMString protocol

Vom Server ausgewähltes Subprotokoll oder der leere String.

Netzwerk-Events und -Zustände

unsigned short readyState

Die WebSocket kann sich in verschiedenen Zuständen befinden:

·         CONNECTING (0): Der Verbindungsaufbau (Handshake) läuft gerade

·         OPEN (1): Die WebSocket ist geöffnet und bereit für Daten

·         CLOSING (2): Die Verbindung wird gerade geschlossen

·         CLOSED (3): Die Verbindung ist geschlossen

EventListener onopen

Funktion, die aufgerufen wird, wenn die WebSocket geöffnet und bereit für Daten ist.

EventListener onerror

Funktion, die bei einem Fehler aufgerufen wird.

EventListener onclose

Funktion, die aufgerufen wird, wenn die Verbindung geschlossen ist.

void close([ code, [ begründung ] ])

Funktion zum clientseitigen Schließen der Verbindung. Für Codes siehe [2].

Senden und Empfangen von Nachrichten

EventListener onmessage

Funktion, die beim Empfang einer neuen Nachricht aufgerufen wird.

DOMString binaryType

Gibt an, wie der Browser empfangene Binärdaten behandeln soll. Mögliche Werte sind blob (Standardwert) und arraybuffer.

void send(data)

Sendet Daten an den Server. Es können Textdaten oder Binärdaten (Typ ArrayBuffer, Blob) gesendet werden. Der Browser wählt den für den Datentyp passenden WebSocket-Frame aus.

unsigned long bufferedAmount

Menge der mit send() abgeschickten Daten, die noch nicht an den Server übertragen wurden (nützlich bei beispielsweise einer großen Datenmenge und langsamer Datenverbindung).

Tabelle 1: Methoden und Eigenschaften des WebSocket-Objekts

Webserver

WebSockets könnten zwar auch über Apache (oder andere Webserver) angesteuert werden; allerdings werden so unnötig viele Ressourcen verbraucht. Da die WebSocket-Verbindung geöffnet bleibt, wird ein Serverprozess so lange vollständig belegt, bis die Verbindung wieder geschlossen wird. Pro WebSocket-Verbindung werden daher einige MB Arbeitsspeicher belegt, eventuell auch weitere Ressourcen (zum Beispiel Datenbankverbindungen). Aus diesem Grund werden eigene, ereignisgesteuerte Server, bei denen ein Prozess viele geöffnete Verbindungen parallel halten kann, verwendet. Zwei bekannte, rein in PHP geschriebene Server sind php-websocket und Ratchet. Die folgenden Beispiele verwenden Ratchet, da der Server einfach zu installieren ist, eine aktive Community hat und auch die älteren WebSocket-Protokollversionen HyBi und Hixie-76 unterstützt (für Zusammenarbeit mit Safari).

Installation

Ratchet verwendet Composer als Dependency Manager. Um Composer verwenden zu können, laden Sie sich den Installer von https://getcomposer.org/installer herunter und führen ihn mit php installer aus. Der Installer überprüft die PHP-Konfiguration und erzeugt die composer.phar-Datei. Legen Sie eine Datei composer.json wie in Listing 2 angegeben an und installieren Sie Ratchet, indem Sie im selben Verzeichnis php composer.phar install aufrufen. Ratchet benötigt die Curl-PHP-Erweiterung und optional die libevent-PECL-Erweiterung (für bessere Performance).

Listing 2: „composer.json“ für Ratchet-Installation

 {     "require": {         "cboden/Ratchet": "0.2.*"     } }

Schere, Stein, Papier: die Serverseite

Ratchet bietet mit dem MessageComponentInterface eine einfache, an das WebSocket-API angelehnte Schnittstelle an. Als Beispiel soll das Spiel Schere, Stein, Papier dienen, das in Listing 3 zu sehen ist. Es enthält alle Elemente, die auch für Chat-Anwendungen oder andere interaktive Webapplikationen benötigt werden.

Neue Nachrichten bzw. Verbindungen lösen einen Aufruf der zugehörigen Methode (onOpen, onMessage, onClose, onError) der Klasse SchereSteinPapier aus. Bei einer neuen Verbindung wird in onOpen() der Client in $clients gespeichert und eine login-Nachricht an alle Clients gesendet. Bricht der Client die Verbindung ab, wird in onClose() der Client wieder aus dem $clients-Array entfernt. Das Array hält so immer eine Liste aller aktiven Verbindungen bereit.

Die broadcast()-Methode sendet eine Nachricht an alle aktiven Verbindungen. Da das Beispiel keine Benutzer oder Sessions verwendet, wird der Sender über einen spl_object_hash() der Verbindung identifiziert. ConnectionInterface::send() übernimmt dabei die passende Kodierung der Nachricht entsprechend des WebSocket-Protokolls.

Listing 3: „SchereSteinPapier.php“

 clients = new SplObjectStorage();     }      public function onOpen(ConnectionInterface $conn) {         $this->clients->attach($conn, 0);         $this->broadcast($conn, 'login', true);     }      public function onMessage(ConnectionInterface $from, $msg) { ... }      public function onClose(ConnectionInterface $conn) {         $this->clients->detach($conn);         $this->broadcast($conn, 'logout', true);     }      public function onError(ConnectionInterface $conn, Exception $e) {         echo "Fehler: {$e->getMessage()}n";         $conn->close();     }      private function broadcast(ConnectionInterface $from, $type, $msg) {         $id = spl_object_hash($from);         $msg = json_encode([ 'id' => $id, $type => $msg ]);         foreach ($this->clients as $client) {             $client->send($msg);         }     } } ?>

Die Spielelogik selbst befindet sich in der onMessage()-Methode, zu sehen in Listing 4. Ist bisher keine Nachricht eingegangen, wird der aktuelle Spielzug in den Variablen $last_c und $last_m zwischengespeichert. Kommt ein zweiter Spielzug hinzu, wird über play() der Gewinner ermittelt, und die neuen Spielstände werden mithilfe von broadcast() an alle aktiven Clients übertragen. Eine feste Zuordnung, wer mit wem spielt, findet nicht statt, sondern ergibt sich aus der zeitlichen Abfolge der Spielzüge.

Listing 4: Spielelogik und „onMessage()“-Methode


 
  Normal
  0
  21
  
  
  false
  false
  false
  
   
   
   
   
   
  
  MicrosoftInternetExplorer4
 
class SchereSteinPapier implements MessageComponentInterface { ...     public function onMessage(ConnectionInterface $from, $msg) {         if (empty($this->last_c)) {             $this->last_c = $from;             $this->last_m = $msg;         }         else {             $score = $this->play($this->last_m, $msg);             $this->clients[$this->last_c] += $score;             $this->clients[$from] -= $score;             $this->broadcast($from, 'turn', ['score' => $this->clients[$from],                                              'play' => $msg]);             $this->broadcast($this->last_c, 'turn',                                     ['score' => $this->clients[$this->last_c],                                      'play' => $this->last_m]);             unset($this->last_c);         }     }      private function play($p1, $p2) {         if ($p1 == $p2)             return 0;         switch ($p1) {             case 'Schere': return ($p2 == 'Papier') ? 1 : -1;             case 'Papier': return ($p2 == 'Schere') ? -1 : 1;             case 'Stein':  return ($p2 == 'Papier') ? -1 : 1;         }     } }

Die Basis des Ratchet-Servers ist die Klasse IoServer, die die asynchrone Kommunikation mit den Clients steuert. Listing 5 zeigt den Aufruf zum Start des Servers. Die Applikationsklasse SchereSteinPapier wird von WsServer, der WebSocket-Schicht von Ratchet, umschlossen. IoServer gibt Verbindungen einen WsServer weiter, der die Protokolldetails behandelt, sodass beispielsweise die verwendete Protokollversion für die Applikationsschicht transparent bleibt.

Der Server wird von der Kommandozeile aus mit php server.php gestartet und läuft dann ohne Unterbrechung. Dank des in PHP 5.3 eingeführten Garbage Collectors sollte das Script auch bei längeren Laufzeiten nicht unmäßig viel Speicher belegen. Im Produktivbetrieb sollte der Server mit einem Monitoring Tool wie Monit überwacht werden, das den Server auch automatisch neu starten kann.

Listing 5: „server.php“ – Start eines Ratchet-Servers


 
  Normal
  0
  21
  
  
  false
  false
  false
  
   
   
   
   
   
  
  MicrosoftInternetExplorer4
 
run(); ?>

Clientseite

Listing 6 zeigt das HTML der zugehörigen Schere-Stein-Papier-Beispielanwendung. Das Spiel wird über drei Links gesteuert. Die zugehörigen EventListener werden über die Funktion addCommandListeners() gesetzt. Die WebSocket selbst wird in der Funktion openWebSocket() in Listing 7 geöffnet, in der auch ein EventListener für neu empfangene Nachrichten (message-Event) eingetragen wird. Als URL muss bei WebSockets das ws://-Schema verwendet werden.

Listing 6: Client-HTML und WebSocket-Ansteuerung


 
  Normal
  0
  21
  
  
  false
  false
  false
  
   
   
   
   
   
  
  MicrosoftInternetExplorer4
 
      var wsckt = openWebSocket(); window.onload = addCommandListeners;  function addCommandListeners() {     var a = document.getElementsByTagName('a');     for (var i=0; i < a.length; i++) {         a[i].addEventListener('click', makeNewMove, false);     } }  function openWebSocket() { ... } function makeNewMove(e) { ... } function receivedMessage(e) { ... }  function getOrCreateParagraph(id) {     var p = document.getElementById(id);     if (!p) {          p = document.createElement('p');         p.id = id;         document.body.appendChild(p);         p.appendChild(document.createTextNode(""));     }     return p; }

Schere Stein Papier

Listing 7: Öffnen der WebSocket-Verbindung

function openWebSocket() {     var wsckt = new WebSocket('ws://php.xmp.site:8000');     wsckt.onmessage = receivedMessage;     return wsckt; }

Klickt der Benutzer auf einen Link, wird in der Methode makeNewMove() (Listing 8) der Text des Links ausgelesen und über WebSocket::send() an den Server gesendet. Da keine strukturierten Daten übertragen werden, wird im Beispiel auf eine JSON-Kodierung der Nachricht verzichtet.

Listing 8: Neue Nachricht an Server senden


 
  Normal
  0
  21
  
  
  false
  false
  false
  
   
   
   
   
   
  
  MicrosoftInternetExplorer4
 
/* click-EventListener */ function makeNewMove(e) {     e.preventDefault();     var msg = this.firstChild.nodeValue;     wsckt.send(msg); }

Normal
0
21

false
false
false

MicrosoftInternetExplorer4

Nachrichten vom Server hingegen werden als JSON übertragen. Die receivedMessage()-Funktion in Listing 9 erhält ein MessageEvent mit den Nachrichtendaten im data-Attribut. Mit JSON.parse() wird als erstes die Nachricht in ein Objekt dekodiert. Anschließend werden die drei Nachrichtentypen (login, logout, turn) ausgewertet und das Ergebnis in Absätzen am Bildschirm angezeigt. Abbildung 3 zeigt den Spielstand nach einigen Runden.

Listing 9: Servernachricht auswerten


 
  Normal
  0
  21
  
  
  false
  false
  false
  
   
   
   
   
   
  
  MicrosoftInternetExplorer4
 
/* WebSocket-message-EventListener */ function receivedMessage(e) {     var msg = JSON.parse(e.data);     var p = getOrCreateParagraph(msg.id);     if (msg.login) {         p.firstChild.nodeValue = msg.id + ": Neu eingeloggt.";     }     else if (msg.logout) {         p.parentNode.removeChild(p);     }     else if (msg.turn) {         p.firstChild.nodeValue = msg.id + ": " + msg.turn.score + " Punkte";         p.appendChild(document.createTextNode(" | " + msg.turn.play));     } }
Abb. 3: Stand des Spiels nach sechs Runden und vier Spielern

Abb. 3: Stand des Spiels nach sechs Runden und vier Spielern

Anwendungsprotokoll

In Ihren eigenen Anwendungen kann das Empfangen von Nachrichten für verschiedenste Aktionen genützt werden: Aktualisieren von Inhalten, Seitenwechsel, Verständigung des Benutzers etc. Welche Nachrichten Sie verwenden und wie Ihre Anwendung darauf reagiert, können Sie frei definieren und damit genau Ihren Anforderungen anpassen. Eine Antwort auf gesendete Daten – eine Bestätigung – ist im WebSocket-Protokoll nicht vorgesehen. Sollte Ihre Anwendung eine Antwort benötigen, müssen Sie eine gesonderte Nachricht vom Server an den Client senden.

Wenn Sie Ihr eigenes Anwendungsprotokoll entwerfen, sollten Sie sicherstellen, dass Sie die unterschiedlichen Arten der Nachrichten erkennen und Antworten eindeutig der entsprechenden Anfrage zuordnen können. Es bietet sich daher an, die Nachrichten als JSON zu kodieren (oder als XML). Damit haben Sie, wie bei der Methode receivedMessage() in Listing 9 zu sehen, einfachen Zugriff auf die einzelnen Werte. Sie sollten auch die Nachrichten mit eigenen Feldern für Nachrichtentypen versehen. Um Antworten zuordnen zu können, sollten Sie eine eindeutige Kennung (die identisch mit der Kennung der Anfrage ist) mitsenden. Sie können nämlich nicht davon ausgehen, dass Benutzer nicht mehrere Nachrichten auslösen, bevor der Browser eine Antwort erhält.

Polyfill für Alternativen

Um auch ältere Browser zu unterstützen, können Sie auf WebSocket Polyfills zurückgreifen, die mithilfe verschiedener Methoden (beispielsweise mit Flash) WebSockets simulieren. web-socket-js ist zum Beispiel eine einfach zu verwendende Flash-Lösung. Ratchet bietet dafür auch einen passenden Flash-Policy-Server an. Eine Liste weiterer Alternativen finden Sie hier. Manchmal ist aber auch eine serverseitige Adaptierung notwendig.

Fazit

Nachrichten aktiv von der Serverseite zum Browser zu pushen macht viele Funktionen in Webanwendungen erst möglich. Egal ob Benachrichtigung von neu eingetroffenen E-Mails, ereignisgesteuerte Aktualisierung von angezeigten Informationen oder Echtzeitaustausch von Daten mit anderen Benutzern: Der Server stößt die Kommunikation an. Mit dem WebSocket-Protokoll steht Webentwicklern endlich eine voll funktionsfähige bidirektionale Verbindung zur Verfügung. Das Protokoll nützt eine bestehende HTTP-Verbindung, statt selbst eine neue Verbindung aufzubauen.

WebSocket wird in absehbarer Zeit alle anderen Varianten des Server-Push ablösen, da es eine unkomplizierte TCP/IP-ähnliche Verbindung zwischen Browser und Server herstellt. Mit JavaScript kann mit Events auf neue Nachrichten asynchron reagiert werden, womit auch komplexe Webanwendungen umgesetzt werden können.

Die Technologie bedingt aber ein Umdenken im Programmieren. Datenverbindungen bleiben langfristig offen und belegen damit bei herkömmlichem Vorgehen einen Systemprozess und dessen Speicherplatz. Skalierbare Lösungen werden daher auf einer ereignisgesteuerten Architektur, wie beispielsweise Ratchet, basieren.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -