Node.js: Eigener Websocket Server

15. Mrz. 2017 // Moritz Kanzler //

Es gibt eine Fortsetzung zu diesem Beitrag: Node.js: Skalierbarer Websocketserver mit Redis Pub/Sub.


Bestandteil eines meiner Projekte ist ein Chat, in dem einzelne Nutzer miteinander kommunizieren können sollen. Mein erster gewählter Ansatz war eine regelmäßige Abfrage an die REST API des Services um nach neuen Nachrichten zu suchen und diese dann dem Nutzer zu präsentieren.

Diese Strategie hat grundsätzlich funktioniert, allerdings entsteht hierbei ein große Last für Client und Server, da die regelmäßigen Abfragen auch stattfinden, wenn gar keine neuen Daten zur Verfügung stehen.

Auf der Suche nach einer Alternative bin ich auf Websockets gestoßen. Diese Spezifikation erlaubt es, eine beständige Verbindung zu Server aufzubauen. Gibt es dann neue Daten kann der Server direkt den angeschlossenen Client informieren. Ansonsten findet keinerlei Datenaustausch statt. Damit verringert sich der Aufwand im Gegensatz zur chronologischen Abfragestrategie enorm. Hinzu kommt noch, dass auch der Overhead der einzelnen Abfragen im  Websocket Protokoll deutlich geringer ist, als bei HTTP.

Eigener Chatserver über Websockets

Im Folgenden beschreibe ich den Aufbau eines eigenen Websocket Servers mit NodeJS.

Idee

Grundsätzlich sollte ein Chatserver auf Websocketbasis die Verbindungen von Clients annehmen und verwalten, sowie die Nachrichten eines Nutzer an einen anderen angemeldeten Nutzer ausliefern.

Um allerdings die Verbindungen der Clients auch entsprechenden Nutzern des Chatservices zuzuweisen, sollte beim Verbindungsaufbau zum Server der eigene Benutzername übermittelt werden und dann vom Server dauerhaft der entsprechenden Verbindung zugeordnet werden.

Auf der anderen Seite muss auch zu jeder zu übermittelnden Nachricht der Empfänger mit übertragen werden, damit der Server weiß an welche Verbindung die Nachricht verschickt werden soll.

Zusammenfassend soll der Server also:

  • bei jedem ersten Verbindungsaufbau den Nutzernamen des Clients übermittelt bekommen, diesen gegebenenfalls prüfen und
  • diesen dann in einer Art „Verbindungsliste“ speichern, um ein Mapping von Benutzernamen auf Verbindung durchführen zu können,
  • außerdem soll damit auch eine direkte Versendung von Nachrichten zwischen zwei Nutzern möglich sein

Das Mapping in der „Verbindungsliste“ erhöht zwar den Speicherbedarf des Servers, ermöglicht aber ein schnelleres Suchen des Empfängers. Der standartmäßige Ansatz für das verschicken von Nachrichten an einzelne Nutzer sieht nämlich ein durchlaufen der Verbindungsliste vor. Je nach Anzahl der Verbindungen kann hier eine Liste schneller sein.

Zusätzlich muss sich auf ein Nachrichtenprotokoll geeinigt werden, in welchen neben der Nachricht auch der Benutzername des Empfängers auftaucht.


Zusatzfunktion

Als Zusatzfunktion muss es in meiner Implementierung auch noch die Möglichkeit geben, die gesendeten Nachrichten in einer Datenbank für den späteren Abruf abzulegen. Dies ist für eine allgemeine Websocketeinbindung nicht nötig, wird hier aber mit angegeben.

Um die geschickten Nachrichten nachhaltig zu speichern, soll bei jeder ankommenden Nachricht der Websocketserver diese asynchron an die REST API des Dienstes weitergeben, welcher diese dann in der Datenbank ablegt.

Implementierung

Um den Websocket Chatserver umzusetzen nutze ich in diesem Szenario NodeJS aus verschiedenen Gründen:

  • Umfangreiche Bibliotheken um Websockets umzusetzen + zahlreiche Beispiele und Tutorials
  • Einfacher übersichtlicher Code
  • Gut skalierbar für Clusternutzung
  • Einfaches Parsing von JSON in Javascript

Als Bibliothek für die Websocket kommt ws zum Einsatz, welche die Standartimplementierung von Websockets in NodeJS ist.

Umsetzung

Einbindung der Abhängigkeiten über npm

NodeJS bietet mit seinem Packetmanager npm eine komfortable Möglichkeit alle Abhängigkeiten für ein Projekt anzulegen und zu verwalten. Um ein Projekt mit npm anzulegen wählt man zunächst das Zielverzeichnis und führt dann den Befehl    aus.

Nach einigen Angaben wird eine package.json erstellt. Von nun an kann man über npm weitere Pakete hinzufügen. So auch das Websocket Paket ws.

Anlegen eines einfachen Websocketservers

Zu Beginn der Initialisierung durch npm konnte man eine Datei als Einstiegspunkt wählen (Default ist index.js). In diesem Beispiel legen wir eine Datei mit dem Namen server.js an. Diese wird den Websocketserver darstellen.

Zunächst wird eine Konstante angelegt mit der das Websocketpaket ws angesprochen werden kann:

Danach wird eine neue Instanz eines Websocketservers aufgemacht:

Auf Verbindungen von Clients reagieren

Nachdem eine Instanz eines Websocketservers angelegt wurde, und diese auf Port 1234 lauscht, kann man sich mit dem Websocketserver grundsätzlich verbinden. Er besitzt aber noch keinerlei Funktionalität.

ws bietet unterschiedliche Events zu einem Websocketserver an, darunter die wichtigste   . Dieses Event wird immer dann aufgerufen, wenn eine neue Verbindung zum Websocketserver vollständig und erfolgreich aufgebaut ist, also nach einem Handshake. Gleichzeitig ist das auch der Einstiegspunkt für die weitere Kommunikation zwischen Server und Client, da bei Websockets die Verbindung bestehen bleibt.

Eine vollständige Liste der Events findet sich in der Dokumentation zu ws.

Um also auf die Neuverbindungen zu reagieren, lauschen wir auf das Event connection wie folgt:

Das Event connection liefert ein Objekt ws mit, welches von der Klasse Websocket ist und die aufgebaute Verbindung zwischen Client und Server symbolisiert. Auf dieses Websocket Objekt kann im Folgenden immer Zugegriffen werden, wenn eine Nachricht vom Server zu einem Client gesandt werden soll.

Umgekehrt nutzt NodeJS dieses Objekt auch dazu, um auf Verbindungen vom Client zum Server zu lauschen. Das machen wir uns im nächsten Abschnitt zu nutze.

Auf Aktionen des Clients reagieren

Nach dem die Verbindung zwischen Client und Server erfolgt ist, kann mit dem daraus resultierenden Websocket Objekt auf Aktionen des Clients reagiert werden. Auch hier gibt es unterschiedliche Events. Für das Verschicken von Nachrichten sind aber zunächst die Events    und    wichtig.

Message Event: Client schickt eine Nachricht

Schickt ein Client eine Nachricht an den Websocketserver wird das Event message des entsprechenden Websocket Objekts getriggert. Innerhalb der aufgebauten Verbindung lauschen wir also auf dieses Event und geben zunächst die geschickte Nachricht auf der Konsole aus:

Close Event: Client beendet die Verbindung zum Server

Für die weitere Implementierung des Aufgabenkatalogs ist außerdem wichtig darauf reagieren zu können, wenn ein Nutzer die Verbindung zu Websocketserver wieder trennt. Dazu nutzen wir das Event close:

Zwischenstand

Durch die bisherige Arbeit ist unserem Progamm Folgendes möglich:

  • Anbieten eines Websocket Services
  • Reaktion auf Neuverbindungen von Clients
  • Reaktion auf Nachrichten von Clients
  • Reaktion auf Verlust eines Clients

In den nächsten Schritten werden die zusätzlichen Funktionen von oben sowie die direkte  Nachrichtenübermittlung von einem Client zum anderen implementiert.

Nachrichten zwischen Clients verschicken

Um Nachrichten zwischen Clients auszutauschen müssen drei Sachverhalte bedacht werden:

  1. Es muss bekannt sein welcher Nutzer sich hinter welcher WebSocket Verbindung verbirgt.
  2. Alle Nutzer müssen in einer Verbindungsliste aufgeführt sein, mit welcher ermittelt werden kann, an welchen Websocket die Nachricht geschickt werden muss
  3. Da es kein allgemein gültiges Chatprotokoll gibt, muss ein weg gefunden werden nicht nur die Nachricht zu übermitteln, sondern auch die Information an welchen Nutzer die Nachricht gehen soll.
  4. Danach muss bei einer eintreffenden Nachricht diese auch an den entsprechenden Nutzer geschickt werden.

Schritt 1: Anmeldeinformationen beim Verbindungsaufbau

Um dem Server bekannt zu machen welcher User bzw. Username sich hinter welcher Verbindung steckt, sollten beim Verbindungsaufbau diese Informationen mitgegeben werden können. Möchte man zusätzlich, wie ich, einen Chatservice aufbauen welcher auf eine vorhandene Userbasis aufbaut müssen diese Anmeldeinformationen auch bei einer Datenbank oder einem vorhandenen Service gegen gecheckt werden.

Da Websockets im Grunde auf einem HTTP basierten Protokoll beruhen, kann man in die Anfrage auch einen eigenen Header setzen, welcher vom Server dann abgefragt werden kann. Zu bedenken ist aber, dass dies bei der initialen Verbindung geschehen muss, da ansonsten das Protokoll anders aussehen kann, weil Client und Server verbunden bleiben.

In meinem Fall reicht es mir über den bekannten   Header die Anmeldeinformationen wie folgt mitzugeben:

HeadernameValue
Authorizationbase64(Username:sha1(Passwort))

Nun muss der Server die mitgegebenen Anmeldeinformationen zunächst auslesen. Um die base64 Verschlüsselung lesen zu können wird hier zusätzlich das npm Paket base-64 genutzt. Dazu wechselt man in den root Ordner des Projektes und führt folgenden Befehl aus, um dieses einzubinden:

Danach bindet man die Pakete in der server.js direkt am Anfang der Datei ein:

Jetzt kann das eigentliche Auslesen des Headers stattfinden und das gegenchecken genutzt werden. Da hier die Implementierung verschieden ist benutze ich die Dummyfunktion    welche im Erfolgsfall true  und sonst false zurück gibt:

Wie zu erkennen ist, wird zunächst der Username und das Passwort aus dem Header gelesen und mit der eigenen Funktion am Server auf Gültigkeit abgefragt. Ist die Anmeldekennung günstig setzt der Server ein einfaches Flag   auf true. Ansonsten wird die Verbindung zwischen Client und Server vom Server aufgekündigt.

Schritt 2: Anlegen und Verwalten einer Verbindungsliste

An diesem Punkt der Entwicklung können wir feststellen, ob eine gültige Anmeldung am Chatserver gegen eine vorhandene Nutzerbasis besteht, oder falls das nicht nötig ist, zumindestens der Verbindung einen Nutzernamen zuweisen. Diese Zuweisung von Verbindung zu Nutzernamen muss aber noch im Programm hinterlegt werden. Dazu bauen wir eine Verbindungsliste auf, welches den Nutzernamen auf das zugehörige Websocket Objekt mappt. Genauso muss bei einem Verbindungsabbau (Event: close) der User wieder aus der Verbindungsliste gelöscht werden.

Zunächst legen wir zum Programmstart eine leere Verbindungsliste an und weisen bei einer erfolgreichen Verbindung das Websocket Objekt innerhalb der Liste dem Usernamen zu, bei einem Verbindungsabbau wird der User wieder aus der Liste gelöscht (siehe Markierungen):

Damit ist Schritt 2 schon erledigt.

Schritt 3: Festlegen eines Chatprotokolls

Um neben einer Nachricht auch zu übermitteln an welchen anderen Nutzer diese Nachricht gerichtet ist bzw. an wen der Server sie weiter versenden soll ist es nötig eine Art Protokoll zu definieren, an welchem sich alle Absender orientieren können. Es sollte folgende Informationen enthalten:

FeldBeschreibung
toUserBenutzername an den die Nachricht versendet werden soll
messageNachricht
date (Optional)Datum an dem die Nachricht verschickt wurde

Möchte man diese Informationen in einem String übertragen und sinnvoll voneinander trennen zu können bietet sich JSON an. Eine Beispielnachricht in JSON kodiert sollte also wie folgt aussehen:

Schritt 4: Versenden von Nachrichten zwischen Nutzern – Alles miteinander verbinden

An dem Punkt wo sowohl die Verbindungsliste als auch ein Nachrichtenprotokoll festgelegt ist, müssen nur noch die vorbereiteten Teile zusammengefügt werden und der Versand der Nachricht an den Empfänger initiiert werden. Dazu wird das Event message endgültig mit Leben gefüllt:

Die markierten Kommentare haben dabei die folgenden Funktionen:

  1. Versuch die erhaltenen Daten als JSON zu interpretieren. Je nach Ergebnis liegt entweder ein JSON Objekt oder der Wert false in der Variablen   .
  2. In Punkt 2 wird nochmals überprüft ob der User beim Verbindungsaufbau eine gültige Anmeldungkennung hatte und ob die erhaltene Nachricht als JSON interpretiert werden konnte. Danach wird der Empfänger und die Nachricht aus dem JSON Objekt ausgelesen. Daraufhin wird versucht das entsprechende Websocket Objekt zum Empfänger aus der Verbindungsliste zu finden.
  3. Punkt 3 beginnt mit einer Prüfung ob der gesuchte Empfänger wirklich in der Verbindungsliste war. Ist das der Fall wird mit dem erhaltenen Websocket Objekt über den Befehl   die gewünschte Nachricht an den entsprechenen Client verschickt.

Damit ist der Websocket unter dem angegebenen Protokoll vollständig nutzbar und fertiggestellt.

Starten des Servers

Nun möchte man den soeben abgeschlossenen Websocket Chat Server natürlich testen. Diesen kann man einfach mit    oder    starten. Die Konsolenausgaben erscheinen dann entsprechend im aktuellen Terminalfenster. Möchte man den Server anhalten wählt man   .

Test mit Clients über wscat2

Ein lauffähiger Websocketserver ist aber nur die eine Seite. Für einen Test muss man ebenfalls Clients simulieren können. Dafür eignet sich das Kommandozeilentool wscat2 hervorragend.

Die Installation erfolgt einfach über npm:

Damit ein Client sich mit laufenden Webserver über wscat verbinden kann, gibt man nun Folgendes in die Kommandozeile ein:

Nun sollte der Server sich schon mit einer erfolgreichen Verbindung in der Console melden. Über das definierte Chatprotokoll (also einer Eingabe über JSON) können so zwei über wscat verbundene Clients schon miteinander kommunizieren.

Unterstützung von Websockets

Websockets werden von allen aktuellen Browsern unterstützt, sowohl Mobil als auch auf den Desktop Versionen. Auch auf den mobilen Betriebssystem iOS und Android gibt es entsprechende Bibliotheken, welche eine Benutzung von Websocktes in der eigenen App ermöglichen.

Bei älteren Browsern ist die Unterstützung allerdings nicht unbedingt gegeben. Hier hilft die Nutzung der socket.io Bibliothek, welche auch eine abwärtskompatible Umsetzung über Long Polling ermöglicht. socket.io wird hier allerdings nicht weiter besprochen.


Weitere Kapitel