Aus Linux-Magazin 05/2025

Websocket-Server in Go kontaktiert Browser

© Vadim Fedotov / 123RF.com

Mit dem Websocket-Protokoll lädt der Browser eine Seite sofort nach, falls sich ihr Inhalt auf dem Server ändert. Mike Schilli baut dafür einen speziellen Dienst in Go.

Wer hat sich nicht schon einmal gewundert, wie es funktioniert, dass ein im Webbrowser laufender Chat wie Whatsapp oder Slack schlagartig auf neue Eingaben des Gegenübers reagiert, ja teilweise bereits typing … anzeigt, sobald der Gesprächspartner zu tippen beginnt (Abbildung 1)? Dazu muss der Browser die gerade dargestellte Seite zumindest teilweise nachladen. Aber wer macht ihn darauf aufmerksam?

Abbildung 1: Slack im Webbrowser nutzt Websockets zur Kommunikation in Echtzeit.

Abbildung 1: Slack im Webbrowser nutzt Websockets zur Kommunikation in Echtzeit.

Im simpelsten Fall könnte der Browser einfach periodisch nachfragen, aber das triebe den Netzverkehr unnötig in die Höhe, denn meist hat sich ja gar nichts geändert. Außerdem entstünde ein periodisches Flackern einer weitgehend statischen Seite, was recht unprofessionell aussähe. Der Profi dreht den Spieß um und weckt den Browser auf, falls sich der Ursprung der angezeigten Datei geändert hat.

Dazu muss der Browser nicht mehr wie im HTTP-Protokoll eine Anfrage schicken, die der Server beantwortet bevor er die Verbindung wieder schließt. Vielmehr baut der Browser eine persistente Verbindung über das Websocket-Protokoll zu einem speziellen Server auf. Sobald sie steht, können sowohl Server als auch Client Nachrichten losschicken, die die Gegenseite sofort über den offenen Kanal ausliest. Beide Parteien lauschen also aktiv an ihrem Ende und reagieren, sobald neue Informationen ankommen.

Ursprünge

Das Websocket-Protokoll [1] erblickte schon 2011 mit der RFC 6455 das Licht der IT-Welt, alle modernen Browser beherrschen es heutzutage. Damit sich Webseiten dynamisch anfühlen und wie Whatsapp anscheinend verzögerungsfrei und ohne irritierendes Nachladen neu hinzugekommene Informationen anzeigen, verbindet sich ein im HTML-Code der Seite verstecktes Javascript-Snippet mit dem Websocket-Server und wartet in einer Event-Schleife, bis sich etwas rührt. Dann bringt es in Zusammenarbeit mit dem DOM (Document Object Model) des Browsers die dargestellte Seite auf Vordermann.

Zur Illustration des Verfahrens schwebt mir eine Applikation vor, die mich in einer lokalen Datei Text tippen lässt, ihn bei abgespeicherten Änderungen formatiert und im Browser anzeigt. So schreibe ich übrigens meine Artikel für die Snapshot-Kolumne: Ich tippe im Editor Vim in einem Textformat, das ein Konverter in HTML für die Browserdarstellung umwandelt. Sobald ich den Text in Vim speichere und Make aufrufe, schaltet die HTML-Darstellung im Browser mit dem neuen Tool ohne Verzögerung auf den aktualisierten Text um (Abbildung 2). Perfekt!

Abbildung 2: Ein Artikel der Kolumne Snapshot während der Entstehung.

Abbildung 2: Ein Artikel der Kolumne Snapshot während der Entstehung.

Abbildung 3 zeigt, wie die Komponenten Browser, Webserver und Websocket-Server miteinander kommunizieren. Als Einstieg holt der Browser die erste Version der Webseite mittels des HTTP(S)-Protokolls vom Webserver. Im Code der Seite versteckt sich ein Javascript-Snippet, das sofort zu laufen beginnt und eine permanente Websocket-Verbindung mit dem Websocket-Server aufbaut. Der darf auf einem anderen Host laufen als der Webserver, muss aber nicht.

Abbildung 3: Der Browser bleibt über Javascript und das Websocket-Protokoll permanent mit dem Server verbunden.

Abbildung 3: Der Browser bleibt über Javascript und das Websocket-Protokoll permanent mit dem Server verbunden.

In beide Richtungen

Der Javascript-Client und der Websocket-Server bauen nun eine persistente Verbindung auf. Sobald sie steht, können sowohl der Client als auch der Server über das Websocket-Protokoll Nachrichten an ihr Gegenüber abschicken. Beide lauschen typischerweise in einer Event-Schleife auf neue Nachrichten und arbeiten sie nach dem Eintreffen verzögerungsfrei ab.

Im vorliegenden Fall bekommt der Websocket-Server Änderungen im lokalen Dateisystem mit. Rührt sich etwas, schickt er jedes Mal eine Nachricht durch den persistenten Websocket-Kanal an den Client. Der fordert den Browser mit dem »reload()«-Befehl aus der Javascript-Engine dazu auf, die dargestellte Seite erneut vom Webserver nachzuladen.

Alarm bei Änderung

Wie weiß der Server seinerseits, ob sich eine Datei geändert hat? Dazu bedient er sich wie schon im Snapshot der letzten Ausgabe [2] per Git-Watcher der Inotify-Funktion des Linux-Kernels. Sie kann einzelne Dateien oder ganze Verzeichnisse auf modifizierte Einträge hin überwachen.

Dazu exportiert Listing 1 die Funktion »watchDirs()«, die als ersten Parameter ein Array von Pfaden erwartet. Als zweiten Parameter nimmt sie eine Callback-Funktion entgegen, die sie asynchron aufruft, sobald die Inotify-Schnittstelle des Kernels in einem der markierten Verzeichnisse veränderte Dateien meldet.

Als Helferlein zieht Listing 1 in Zeile 3 das Paket fsnotify von Github heran. Zeile 11 setzt es mit »Add()« auf alle Pfade an, die der Benutzer beim Aufruf angegeben hat. Rührt sich etwas, benachrichtigt das Paket den Aufrufer mit einem Event auf dem Channel »Events«. Das schließt allerdings auch Lesevorgänge mit ein, die den Server in diesem Fall nicht interessieren sollten.

Diese für die Applikation nicht relevanten Lesevorgänge filtert die Bedingung in Zeile 20 heraus. Nur für neu erzeugte Dateien und Modifizierungen bereits existierender Einträge ruft Zeile 21 den Callback des Aufrufers auf. Zeile 13 startet dazu eine nebenläufige Goroutine, die auch dann weiterläuft, wenn »watchDirs()« schon zum Hauptprogramm zurückgekehrt ist.

Listing 1

inotify.go

package main
import (
  "github.com/fsnotify/fsnotify"
)
func watchDirs(paths []string, cb func(string)) error {
  watcher, err := fsnotify.NewWatcher()
  if err != nil {
    return err
  }
  for _, path := range paths {
    watcher.Add(path)
  }
  go func() {
    for {
      select {
      case event, ok := <-watcher.Events:
        if !ok {
          return
        }
        if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
          cb(event.Name)
        }
      case <-watcher.Errors:
        return
      }
    }
  }()
  return nil
}

Multitaskender Fernmelder

Stellt der Server eine Änderung im Dateisystem fest, schickt er über die Websockets-Leitung eine Nachricht an den Browser. Listing 2 zeigt die serverseitige Implementierung. Sie kommt objektorientiert daher: Der Konstruktor »NewWSServer()« ab Zeile 13 gibt einen Pointer auf eine Struktur vom Typ »WSServer« (ab Zeile 8) zurück. Sie enthält einen Logger aus dem Paket zap aus dem Hause Uber sowie eine Hashmap »Clients« für alle bestehenden Websocket-Verbindungen.

Ein typischer Websocket-Server im Internet kann nämlich nicht nur einen Client bedienen, sondern sehr viele, und zwar quasi gleichzeitig. Dazu hält er für jeden Client eine Verbindung offen und multiplext zwischen ankommenden Nachrichten. Bei Serverantworten sollen im vorliegenden Fall gleich alle angeschlossenen Clients die Nachricht mit dem Pfad einer geänderten Datei erhalten. Es dürfen sich mehrere Browser-Tabs mit dem Websocket-Server verbinden und ihre Darstellung simultan auffrischen.

Listing 2

websocket.go

package main
import (
  "net/http"
  "sync"
  "github.com/gorilla/websocket"
  "go.uber.org/zap"
)
type WSServer struct {
  Log        *zap.Logger
  Clients    map[*websocket.Conn]bool
  ClientsMux sync.Mutex
}
func NewWSServer() *WSServer {
  ws := WSServer{
    Clients: map[*websocket.Conn]bool{},
  }
  return &ws
}
func (ws *WSServer) Handler() http.HandlerFunc {
  upgrader := websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool { return true },
  }
  return func(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
      ws.Log.Error("Websocket", zap.Error(err))
      return
    }
    ws.ClientsMux.Lock()
    ws.Clients[conn] = true
    ws.ClientsMux.Unlock()
    for {
      if _, _, err := conn.ReadMessage(); err != nil {
        break
      }
    }
    ws.ClientsMux.Lock()
    delete(ws.Clients, conn)
    ws.ClientsMux.Unlock()
    conn.Close()
  }
}
func (ws *WSServer) Notify(path string) {
  ws.Log.Debug("Notify", zap.String("path", path))
  ws.ClientsMux.Lock()
  defer ws.ClientsMux.Unlock()
  for conn := range ws.Clients {
    msg := map[string]string{"path": path}
    if err := conn.WriteJSON(msg); err != nil {
      conn.Close()
      delete(ws.Clients, conn)
    }
  }
 }

Ungeprüft durchgelassen

Was passiert, wenn sich ein Client zum ersten Mal mit dem Websocket-Server verbindet, bestimmt die Funktion »Handler()« ab Zeile 19 in Listing 2. Das Websocket-Protokoll nutzt als Transportmechanismus das HTTP-Protokoll, fungiert also quasi als HTTP-Upgrade. Entsprechend erzeugt Zeile 24 mit »Upgrade()« eine neue Websocket-Verbindung »conn« und fügt sie als Gedächtnisstütze in die Hashmap »Clients« ein. Die lebt in den Instanzdaten des Objekts und hilft später dabei, Nachrichten an alle angeschlossenen Clients zu verteilen.

Der Parameter »CheckOrigin« in Zeile 21 legt fest, ob der »Upgrader« den Origin-Header eingehender Clientanfragen prüft. Die Applikation legt keinen Wert auf Restriktionen und hebelt den Mechanismus mit »return true« aus.

Immer der Reihe nach

Da der Server später bei mehreren Clients deren Anfragen in Goroutinen quasi gleichzeitig bearbeitet, muss der Code Zugriffe auf mehrzellige Datenstrukturen wie Arrays durch Mutex-Semaphoren schützen. Funktionen wie das Erzeugen neuer Einträge in einer Hashmap, deren Löschung oder das Durchlaufen aller Einträge sind in Go nicht von Haus aus für nebenläufige Zugriffe ausgelegt. Vor dem Schreiben oder Lesen derartiger Einträge blockiert aus diesem Grund immer ein Mutex-Konstrukt aus Gos sync-Paket mit »Lock()« parallele Zugriffe, bevor »Unlock()« die Blockade wieder auflöst. Unterbliebe dies, käme es früher oder später zu Datenkorruption.

Die dritte Funktion aus Listing 2, »Notify()« ab Zeile 43, meldet den Pfad einer modifizierten Datei, indem sie den String an alle angeschlossenen Websocket-Clients schickt. Hierzu iteriert sie über alle Einträge der Hashmap und sendet jeweils eine JSON-Nachricht mit dem Schlüssel »path« und dem Dateipfad als String-Wert. Die Funktion »WriteJSON()« erledigt die Formatierung, indem sie die ihr übergebene Hashmap in JSONs Schlüssel-Wert-Paare umwandelt.

Die »defer«-Anweisung in Zeile 46 stellt dabei sicher, dass ein gesetztes »Lock()« nach Abschluss der aktuellen Funktion wieder mit »Unlock()« zurückgesetzt wird. Das ist praktisch, denn besonders bei komplizierterer If-Else-Logik wird das Rücksetzen sonst in manchen Fällen gern vergessen.

Abbildung 4: Das Javascript-Snippet der Webseite kontaktiert den Server via Websocket-Protokoll und frischt den Inhalt auf Kommando auf.

Abbildung 4: Das Javascript-Snippet der Webseite kontaktiert den Server via Websocket-Protokoll und frischt den Inhalt auf Kommando auf.

Empfänger Javascript

Die Clientseite der Websocket-Verbindung zeigt Abbildung 4. Steht dieses Javascript-Snippet im Kopf einer HTML-Datei, verbindet sich der Browser kurz nach dem Laden der Seite mittels »new WebSocket« mit dem Websocket-Server. Ankommende Servernachrichten fängt der Event-Handler »ws.onmessage« ab. Da Nachrichten im JSON-Format ankommen, muss »JSON.parse()« den gesendeten Dateipfad erst entpacken. Dann entscheidet der Code, was zu tun ist, und handelt basierend auf der Endung der modifizierten Datei.

Halber oder voller Reload

Die Funktion »reload()« der Javascript-Engine im Browser entspricht dem Vorgang, den der Benutzer auslöst, wenn er auf den Reload-Button in der Taskleiste drückt. Das lädt die gerade dargestellte Seite neu, indem der Client den Server noch einmal danach fragt.

Befinden sich nun eingebettete Fotos im HTML der Seite, wird es kompliziert. Der Renderer zeichnet zwar deren Umrandung neu, holt aber nicht das Foto als Datei erneut vom Server. Das geschieht nur, falls der User während des Klicks zusätzlich die Umschalttaste gedrückt hält. Entsprechend akzeptiert die »reload()«-Funktion der Javascript-Engine optional einen booleschen Wert als Parameter. Ist der auf »true« gesetzt, erfolgt der große Reload mitsamt allen Fotos.

Stellt der Watcher auf der Serverseite nun fest, dass sich der HTML-Inhalt einer überwachten Datei geändert hat, braucht der Browser später nur den einfachen Reload anzuleiern. Ändert sich aber eine überwachte Fotodatei, sollte er den großen Zapfenstreich anstoßen und »reload(true)« ausführen.

Dazu prüft der Javascript-Code in Abbildung 4 die Endung der Datei, die der Server als modifiziert meldet. Lautet sie ».jpg« oder ».png«, kommt »reload(true)« zum Einsatz. In allen anderen Fällen holt ein leichtgewichtiges »reload()« nur die eigentliche Seite neu und lässt die referenzierten Fotos aus.

Gesprächig oder schweigsam

Das Hauptprogramm aus Listing 3 vereint nun alle bislang besprochenen Komponenten, startet den Webserver auf Port 8080 und bedient anfragende Clients. Dabei betreibt das abschließende »ListenAndServer()« in Zeile 33 sowohl den eigentlichen HTTP-Server, der die Seite »test.html« ausliefert, als auch den Websocket-Server, der die Änderungsmeldungen bringt.

Möglich macht dies der Aufruf von »HandleFunc()« in Zeile 22, der unter dem Pfad »/ws« den Handler aus Listing 2 anwirft. Dabei gibt »Handler()« eine Funktion zurück, die »HandleFunc()« als Handler im Webserver einpflanzt. In Go sind Funktionen ja bekanntlich Datentypen erster Klasse und lassen sich nach Belieben herumreichen.

Die Log-Meldungen des Pakets zap mit der Funktion »Debug()« sollen nur erscheinen, falls die Applikation mit der Option »–debug« aufgerufen wurde. Dazu fängt Gos Standardpaket flag das Flag auf der Kommandozeile ab, und »NewDevelopment()« initialisiert das zap-Framework auf den gesprächigen Modus. Ansonsten sorgt »NewProduction()« dafür, dass im Code verstreute »Debug()«-Aufrufe stumm bleiben.

Die vom Server auf Änderungen überwachten Dateien oder Verzeichnisse kommen gleichfalls über die Kommandozeile herein, und zwar als zusätzliche Argumente in »flag.Args()«. Unterbleibt das beim Aufruf, setzt Zeile 12 das zu überwachende Verzeichnis auf ».«, also den aktuellen Ordner.

Listing 3

live.go

package main
import (
  "flag"
  "net/http"
  "go.uber.org/zap"
)
func main() {
  debug := flag.Bool("debug", false, "be verbose")
  flag.Parse()
  dirs := flag.Args()
  if len(dirs) == 0 {
      dirs = []string{"."}
  }
  var log *zap.Logger
  if *debug {
    log, _ = zap.NewDevelopment()
  } else {
    log, _ = zap.NewProduction()
  }
  wsserver := NewWSServer()
  wsserver.Log = log
  http.HandleFunc("/ws", wsserver.Handler())
  http.Handle("/", http.FileServer(http.Dir(".")))
  go func() {
    watchDirs(dirs,
      func(path string) {
        wsserver.Notify(path)
      })
  }()
  addr := "localhost:8080"
  log.Debug("Watching", zap.Strings("dirs", dirs))
  log.Debug("Serving on http://" + addr)
  err := http.ListenAndServe(addr, nil)
  if err != nil {
    log.Error("Server start", zap.Error(err))
  }
}

Das Hauptprogramm startet zunächst in Zeile 24 eine nebenläufige Goroutine, die mit »watchDirs()« aus Listing 1 Änderungen in den eingestellten Verzeichnissen feststellt und bei Alarmen den als zweiten Parameter übergebenen Callback aufruft. Der wiederum nutzt »Notify()« aus Listing 2, um alle angeschlossenen Browser aufzuwecken.

Der übliche Dreisatz aus Listing 4 holt alle genutzten Go-Pakete von Github ab und kompiliert die ganze Chose zum Binary »live«. Mit der Option »–debug« gestartet, gibt es zu Testzwecken auf der Standardausgabe Meldungen darüber aus, was gerade so abgeht. Ohne »–debug« erledigt »live« einfach stumm seine Arbeit. Das ist der bevorzugte Modus, falls die Applikation mit »live &« im Hintergrund der Shell gestartet wurde: Sporadische Ausgaben würden nur den Betrieb im Vordergrund stören. Ein auf http://localhost:8080 eingestellter Browser (wichtig: kein http://https:) zeigt anschließend das gehostete Verzeichnis mit den überwachten Dateien an. Auf »test.html« eingenordet, setzt der Browser den Websocket-Reigen in Gang.

Listing 4

build.sh

$ go mod init live
$ go mod tidy
$ go build live.go inotify.go websocket.go
$ ./live --debug

Explosionen verhindern

Das Websocket-Protokoll geht davon aus, dass die Verbindung vom Client zum Server jederzeit bestehen bleibt. Wer den Websocket-Server während der Entwicklung neu startet, wird sich vielleicht wundern, dass der Browser die aktuell angezeigte Seite nicht mehr auffrischt, wenn die neue Serverinstanz eine Änderung herausschickt. Der Grund für die Untätigkeit ist freilich, dass die Javascript-Engine des Browsers nun die Verbindung zur alten Instanz des Servers verloren hat. Nur ein manueller Reload der Seite nimmt mit der neuen Instanz Kontakt auf und hält ihn dann wieder.

Damit der Websocket-Server im Produktionsbetrieb nicht irgendwann explodiert, gilt es, noch Grundregeln für das Abräumen nicht mehr benötigter Verbindungen aufzustellen. Nach welcher Zeit gelten sie als inaktiv? Wie viele davon soll der Server maximal gleichzeitig offenhalten, bevor er sich als überlastet betrachtet und die Tür für Neuankömmlinge schließt oder die Dauersitzer hinauswirft? Wie immer liegt der Teufel im Detail, und es lohnt sich, alle Szenarios abzuklären, bevor man eine solche Applikation live aufs Web loslässt. (uba)

Infos

  1. Websocket-Protocol: https://en.wikipedia.org/wiki/WebSocket
  2. Snapshot: Mike Schilli, “Bewegungsmelder”, LM 04/2025, S. 82, https://www.lm-online.de/51439
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben