Wer hat sich nicht schon einmal gewundert, wie es sein kann, dass ein im Web-Browser laufender Chat wie zum Beispiel Whatsapp oder Slack schlagartig auf neue Eingaben des Gegenübers reagiert, ja teilweise schon "typing ..." anzeigt, falls 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 Web-Browser nutzt ebenfalls 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 soweit statischen Seite, was ebenfalls unprofessionell aussähe. Der Profi dreht den Spieß um und weckt den Browser auf, falls sich der Ursprung der angezeigten Datei geändert hat.
Hierzu muss der Browser nicht mehr wie im HTTP-Protokoll eine Anfrage schicken, die der Server beantwortet und dann die Verbindung schließt. Vielmehr baut der Browser eine persistente Verbindung über das Websocket-Protokoll zu einem speziellen Websocket-Server auf. Steht diese einmal, können sowohl Server als auch Client Nachrichten losschicken, die die Gegenseite sofort über den offenen Kanal ausliest. Beide Parteien lauschen also aktiv an ihren Enden und reagieren, sobald neue Informationen ankommen.
Das Websocket-Protokoll wurde schon 2011 mit der RFC 6455 aus der Taufe gehoben und alle modernen Browser beherrschen es heutzutage. Damit sich Webseiten dynamisch anfühlen und wie Whatsapp scheinbar verzögerungsfrei und ohne irritierendes Nachladen ankommende Informationen anzeigen, verbindet sich ein im HTML-Code der Seite verstecktes JavaScript-Snippet mit dem Websocket-Server und wartet in einer Eventschleife, 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 in dieser Ausgabe eine Applikation vor, die mich in einer lokalen Datei Text tippen lässt, denselben bei abgespeicherten Änderungen formatiert und im Browser anzeigt. So schreibe ich übrigens meine Artikel im Programmier-Snapshot. Ich tippe im Editor vim
in einem Textformat, das ein Konverter in HTML für die Browserdarstellung umwandelt. Sobald ich in vim
das Speicherkommando tippe und "make" aufrufe, schnackelt die HTML-Darstellung im Browser mit dem neuen Tool unmittelbar auf den aktualisierten Text um (Abbildung 3). Perfekt!
![]() |
Abbildung 2: Der Browser bleibt mittels JavaScript und dem Websocket-Protokoll permanent mit dem Server verbunden. |
![]() |
Abbildung 3: Ein Artikel der Reihe Programmier-Snapshot während der Entstehung |
Abbildung 2 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 befindet sich ein JavaScript-Snippet, dass sofort zu laufen beginnt und eine permanente Websocket-Verbindung mit dem Websocket-Server aufbaut. Letzterer kann auf einem anderen Host laufen als der Webserver, muss aber nicht.
Der JavaScript-Client und der Websocket-Server bauen nun eine persistente Verbindung auf, und sobald diese steht, erlaubt es das Websocket-Protokoll sowohl dem Client als auch dem Server, Nachrichten an ihr Gegenüber abzuschicken. Beide lauschen typischerweise in einer Event-Schleife auf neue Nachrichten und arbeiten sie verzögerungsfrei nach dem Eintreffen ab.
Im vorliegenden Fall bekommt der Websocket-Server Änderungen im lokalen Dateisystem mit. Rührt sich etwas, schickt der Websocket-Server jedes Mal eine Nachricht durch den persistenten Websocket-Kanal an den Client, der den Browser mit dem reload()
-Befehl aus dem JavaScript-Engine dazu anhält, die dargestellte Seite erneut vom Webserver nachzuladen.
Und wie weiß der Server seinerseits, ob sich eine Datei geändert hat? Hierzu bedient er sich wie schon in der letzten Ausgabe mit dem GitWatcher der inotify-Funktion des Linux-Kernels. Letztere kann einzelne Dateien oder ganze Verzeichnisse auf modifizierte Einträge hin überwachen.
01 package main 02 import ( 03 "github.com/fsnotify/fsnotify" 04 ) 05 func watchDirs(paths []string, cb func(string)) error { 06 watcher, err := fsnotify.NewWatcher() 07 if err != nil { 08 return err 09 } 10 for _, path := range paths { 11 watcher.Add(path) 12 } 13 go func() { 14 for { 15 select { 16 case event, ok := <-watcher.Events: 17 if !ok { 18 return 19 } 20 if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { 21 cb(event.Name) 22 } 23 case <-watcher.Errors: 24 return 25 } 26 } 27 }() 28 return nil 29 }
Hierzu 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 und Zeile 11 setzt es mit Add()
auf alle Pfade an, die der User beim Aufruf angegeben hat. Rührt sich etwas, benachrichtigt das Paket den Aufrufer mit einem Event auf dem Channel Events
. Dies schließt allerdings auch Lesevorgänge mit ein, die den Server in diesem Fall nicht interessieren sollen.
Für die Applikation nicht relevante Lesevorgänge filtert die if
-Bedingung in Zeile 21 heraus. Nur für neu erzeugte Dateien und Modifizierungen bereits existierender Einträge ruft Zeile 22 den Callback des Aufrufers auf. Zeile 13 startet hierzu eine nebenläufige Goroutine, die auch dann weiter läuft, wenn watchDirs()
schon zum Hauptprogramm zurückgekehrt ist.
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 und 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 auf dem 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 Server-Antworten sollen im vorliegenden Fall gleich alle angeschlossenen Clients die Nachricht mit dem Pfad einer geänderten Datei erhalten, so können sich mehrere Browser-Tabs mit dem Websocket-Server verbinden und ihre Darstellung simulatan auffrischen.
01 package main 02 import ( 03 "net/http" 04 "sync" 05 "github.com/gorilla/websocket" 06 "go.uber.org/zap" 07 ) 08 type WSServer struct { 09 Log *zap.Logger 10 Clients map[*websocket.Conn]bool 11 ClientsMux sync.Mutex 12 } 13 func NewWSServer() *WSServer { 14 ws := WSServer{ 15 Clients: map[*websocket.Conn]bool{}, 16 } 17 return &ws 18 } 19 func (ws *WSServer) Handler() http.HandlerFunc { 20 upgrader := websocket.Upgrader{ 21 CheckOrigin: func(r *http.Request) bool { return true }, 22 } 23 return func(w http.ResponseWriter, r *http.Request) { 24 conn, err := upgrader.Upgrade(w, r, nil) 25 if err != nil { 26 ws.Log.Error("Websocket", zap.Error(err)) 27 return 28 } 29 ws.ClientsMux.Lock() 30 ws.Clients[conn] = true 31 ws.ClientsMux.Unlock() 32 for { 33 if _, _, err := conn.ReadMessage(); err != nil { 34 break 35 } 36 } 37 ws.ClientsMux.Lock() 38 delete(ws.Clients, conn) 39 ws.ClientsMux.Unlock() 40 conn.Close() 41 } 42 } 43 func (ws *WSServer) Notify(path string) { 44 ws.Log.Debug("Notify", zap.String("path", path)) 45 ws.ClientsMux.Lock() 46 defer ws.ClientsMux.Unlock() 47 for conn := range ws.Clients { 48 msg := map[string]string{"path": path} 49 if err := conn.WriteJSON(msg); err != nil { 50 conn.Close() 51 delete(ws.Clients, conn) 52 } 53 } 54 }
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, ist also ein "Upgrade" desselben. 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 in den Instanzdaten des Objekts lebt und später dabei hilft, Nachrichten an alle angeschlossenen Clients zu verteilen.
Der Parameter CheckOrigin
in Zeile 21 legt fest, ob der Upgrader
den Origin-Header eingehender Client-Anfragen prüft. Die Applikation legt keinen Wert auf Restriktionen und hebelt den Mechanismus mit return true
aus.
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, das Löschen derselben oder das Durchlaufen aller Einträge sind in Go nicht von Haus aus für nebenläufe 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 es die ihr übergebene Hashmap in JSONs Key/Value-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 gern in manchen Fällen vergessen.
![]() |
Abbildung 4: Das JavaScript-Snippet der Webseite kontaktiert den Server über das Websockets-Protokoll und frischt den Inhalt auf Kommando auf. |
Die Client-Seite der Websocket-Verbindung zeigt Abbildung 4. Steht dieses JavaScript-Snippet am Kopf einer HTML-Datei, wird sich der Browser kurz nach dem Laden der Seite mittels new WebSocket
mit dem WebSocket-Server verbinden. Ankommende Server-Nachrichten fängt der Event-Handler ws.onmessage
ab. Da Nachrichten im Json-Format ankommen, muss JSON.parse()
den geschickten Dateipfad erst entpacken. Dann muss der Code entscheiden, was zu tun ist, und tut dies basierend auf der Endung der modifizierten Datei.
Die Funktion reload()
des JavaScript-Engines im Browser enspricht dem Vorgang, den der User auslöst, wenn er auf den Reload-Button in der Taskleiste drückt. Dies lädt die gerade dargestellte Seite neu, in dem sie den Server noch einmal danach fragt. Befinden sich nun eingebettete Fotos im HTML der Seite, wird's kompliziert. Der Renderer wird zwar deren Umrandung neu zeichnen, aber nicht das Foto als Datei erneut vom Server holen. Dies geschieht nur, falls der User während des Klicks zusätzlich die Shift-Taste gedrückt hält.
Entsprechend nimmt die reload()
-Funktion des JavaScript-Engines einen optionalen boolschen Wert als Parameter, und wenn der auf true
gesetzt ist, 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 abspielen und reload(true)
ausführen.
Hierzu prüft der JavaScript-Code in Abbildung 4 die Endung der Datei, die der Server als modifiziert meldet. Ist sie .jpg
oder .png
, kommt reload(true)
zum Einsatz. In allen anderen Fällen ist es der "leichte" reload()
, der nur die eigentliche Seite neu holt und nicht die referenzierten Fotos.
Das Hauptprogramm in 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 können nach Belieben herumgereicht werden.
Die Log-Meldungen des zap
-Pakets mit der Funktion Debug()
sollen nur erscheinen, falls die Applikation mit der Option --debug
aufgerufen wurde. Hierzu fängt Gos Standardpaket flag
das Flag auf der Kommandozeile ab und NewDevelopment()
initialisiert das zap
-Framework auf den geprächigen Modus. Andernfalls sorgt NewProduction()
dafür, dass im Code verstreute Debug()
-Aufrufe stumm bleiben.
Die vom Server auf Änderungen überwachten Dateien oder Verzeichnisse kommen ebenfalls über die Kommandozeile herein, und zwar als zusätzliche Argumente in flag.Args()
. Unterbleibt dies beim Aufruf, setzt Zeile 12 das zu überwachende Verzeichnis auf ".", also das gegenwärtige.
01 package main 02 import ( 03 "flag" 04 "net/http" 05 "go.uber.org/zap" 06 ) 07 func main() { 08 debug := flag.Bool("debug", false, "be verbose") 09 flag.Parse() 10 dirs := flag.Args() 11 if len(dirs) == 0 { 12 dirs = []string{"."} 13 } 14 var log *zap.Logger 15 if *debug { 16 log, _ = zap.NewDevelopment() 17 } else { 18 log, _ = zap.NewProduction() 19 } 20 wsserver := NewWSServer() 21 wsserver.Log = log 22 http.HandleFunc("/ws", wsserver.Handler()) 23 http.Handle("/", http.FileServer(http.Dir("."))) 24 go func() { 25 watchDirs(dirs, 26 func(path string) { 27 wsserver.Notify(path) 28 }) 29 }() 30 addr := "localhost:8080" 31 log.Debug("Watching", zap.Strings("dirs", dirs)) 32 log.Debug("Serving on http://" + addr) 33 err := http.ListenAndServe(addr, nil) 34 if err != nil { 35 log.Error("Server start", zap.Error(err)) 36 } 37 }
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 übergebenenen Callback aufruft. Dieser 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 zu einem Binary live
. Mit der Option --debug
gestartet gibt es zu Testzwecken auf der Standardausgabe Meldungen darüber aus, was gerade so abgeht. Ohne --debug
tut live
einfach stumm seine Arbeit. Das ist der bevorzugte Modus, falls die Applikation mit live &
im Hintergrund der Shell gestartet wurde, denn sporadische Ausgaben würden nur den Betrieb im Vordergrund stören. Ein auf http://localhost:8080
eingestellter Browser (wichtig, kein https
!) zeigt anschließend das gehostetet Verzeichnis mit den überwachten Dateien an, und auf <test.html> eingenordet setzt der Browser den Websocket-Reigen in Gang.
1 $ go mod init live 2 $ go mod tidy 3 $ go build live.go inotify.go websocket.go 4 $ ./live --debug
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 die aktuell angezeigte Seite nicht mehr aufgefrischt wird, falls die neue Serverinstanz eine Änderung herausschickt. Der Grund für die Untätigkeit ist freilich, dass der JavaScript-Engine des Browsers nun die Verbindung zur alten Instanz des Servers verloren hat, und nur ein manueller Reload der Seite wird mit der neuen Instanz Kontakt aufnehmen und dann auch wieder halten.
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 offen halten, bevor er sich als überlastet sieht und die Tür für Neuankömmlinge schließt oder die Dauersitzer rauswirft? Wie immer liegt der Teufel im Detail und es lohnt sich, alle Szenarios abzuklären bevor so eine Applikation live aufs Web geht.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/04/snapshot/
Michael Schilli, "Titel": Linux-Magazin 12/16, S.104, <U>http://www.linux-magazin.de/Ausgaben/2016/12/Perl-Snapshot<U>
Websocket-Protocol", https://en.wikipedia.org/wiki/WebSocket
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc