Steckdose zum Server (Linux-Magazin, August 2011)

HTML5 bringt Websockets, damit Clients über stehende Verbindungen mit Webservern schnattern können. Eine Webapplikation zeigt in Echtzeit im Browser, welche Seiten User von einem Webserver aufrufen.

Statt dem simplen Anfrage/Antwort-Spiel im traditionellen HTTP-Protokoll versetzten die im HTML5-Standard enthaltenen Websockets Standardbrowser in die Lage, länger über persistente Verbindungen birektional mit dem Webserver zu kommunizieren. Dazu nimmt JavaScript-Code auf einer heruntergeladenen Seite im Browser eine Websocket-Verbindung zur URL ws://server/pfad auf und definiert einen Callback, den der Code jedesmal sofort anspringt, falls der Server eine Nachricht über den Socket sendet. Die Browserapplikation reagiert demnach sofort auf Serversignale, ohne diesen in regelmäßigen Abständen pollen zu müssen.

Kann's der Browser?

Ob ein Browser Websockets unterstützt, lässt sich in JavaScript leicht feststellen. Nur falls das DOM-Element window.WebSocket existiert, sind Websockets implementiert und aktiviert. Die Webseite websocket.org bietet auf [2] eine kleine Applikation, die die Browserfertigkeiten mit grüner oder oranger Farbe anzeigt und mit einer kleinen Echo-Applikation zum Spielen anregt. Die Abbildungen 1 und 2 zeigen Firefox 4 erst mit deaktiviertem, dann mit aktiviertem Websocket-Modus.

Abbildung 1: websocket.org bestätigt, dass Firefox 4 ohne network.websocket.enabled nicht über Websockets kommunizieren kann.

Abbildung 2: Aktiviert der User die im Abschnitt "Sicherheitslücken" erklärte Konfiguration, nutzt Firefox 4 die Websocket-API nach dem alten Protokoll.

Derzeit unterstützen Firefox 4 und Google Chrome das Protokoll zumindest eingeschränkt (siehe Abschnitt "Sicherheitslücken"), für eine vollständige Implementierung nach dem neuesten Standard muss sogar Firefox 6 (Aurora) her.

Websockets im Test

Abbildung 3 zeigt eine Testapplikation, bei der der Browser in unregelmäßigen Abständen absteigende Zählerwerte vom Server empfängt. Letzterer zählt von 10 an abwärts, schickt den jeweiligen Zählerstand über einen Websocket zur dargestellten Browserseite und schläft einen per rand() gesteuerten zufälligen Sekundenbruchteil lang bis zum nächsten Schleifendurchgang. Bei Null angelangt, sendet der Server die Zeichenkette "BOOM!" und beendet die Websocket-Kommunikation.

Im Browser erscheinen die Servernachrichten verzögerungsfrei in Echtzeit, an die Applikation geliefert über einen effizienten Callback-Mechanismus, ohne dass der Client jedesmal um Nachschlag bitten müsste.

Abbildung 3: Das Websocket-Testskript zählt einen ruckelnden Countdown.

Zur Implementierung des Testservers greift Listing 1 auf das bereits im letzten Snapshot vorgestellte Framework Mojolicious::Lite zu, mit dem experimentierfreudige Programmierer in Minuten fertige Webapplikationen zusammenklopfen. Es unterstützt neben normalen Web-Protokollen auch Websockets und hält den Zustand jedes Websocket-Clients mittels Closures im Speicher.

Listing 1: cntdwn-random

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Mojolicious::Lite;
    04 use Mojo::IOLoop;
    05 
    06 my $listen = "http://localhost:8083";
    07 @ARGV = (qw(daemon --listen), $listen);
    08 
    09 my $loop = Mojo::IOLoop->singleton();
    10 
    11 ###########################################
    12 websocket "/myws" => sub {
    13 ###########################################
    14   my($self) = @_;
    15 
    16   my $timer_cb;
    17   my $counter = 10;
    18 
    19   $timer_cb = sub {
    20     $self->send_message( "$counter" );
    21     if( $counter-- > 0 ) {
    22         $loop->timer(rand(1), $timer_cb);
    23     } else {
    24         $self->send_message( "BOOM!" );
    25     }
    26   };
    27 
    28   $timer_cb->();
    29 };
    30 
    31 ###########################################
    32 get '/' => sub {
    33 ###########################################
    34   my ($self) = @_;
    35 
    36   ( my $ws_url = $listen ) =~ s/^http/ws/;
    37   $ws_url .= "/myws";
    38   $self->{stash}->{ws_url} = $ws_url;
    39 } => 'index';
    40 
    41 app->start();
    42 
    43 __DATA__
    44 @@ index.html.ep
    45 % layout 'default';
    46 Random counter:
    47 <font size=+2 id="counter"></font>
    48 
    49 @@ layouts/default.html.ep
    50 <!doctype html><html>
    51   <head><title>Random Countdown</title>
    52     <script type="text/javascript">
    53       var socket = new WebSocket( 
    54                      "<%== $ws_url %>" );
    55       socket.onmessage = function (msg) {
    56         document.getElementById( 
    57           "counter").innerHTML = msg.data;
    58       };
    59     </script>
    60   </head>
    61   <body> <%== content %> </body>
    62 </html>

Die Eventschleife Mojo::IOLoop bietet einer Applikation ein Timer-gesteuertes Hook-Framework, um in einem laufenden Mojolicious-Prozess hin und wieder die Kontrolle zu erlangen, kleine Aufgaben zu erledigen und den nächsten Update zu planen.

Statt eines Konstruktors new() nutzt Zeile 9 die Methode singleton(), die eine weitere Referenz auf eine Eventschleife liefert, falls vorher schon eine definiert wurde. Das ist wichtig, denn mehrere verschiedene Eventschleifen würden jeweils ihre Vorgänger auslöschen.

Perpetuum Mobile

Im Testskript definiert der Callback $timer_cb ab Zeile 19 eine Funktion, die den globalen Countdown-Wert mittels der Mojomethode send_message() über den Websocket zum Browser sendet und ihn anschließend um Eins herunterzählt. Nach getaner Arbeit ruft Zeile 22 die Methode timer() des Moduls Mojo::IOLoop auf, das die Anzahl der zu schlafenden Sekunden und eine Callback-Funktion entgegennimmt. rand(1) liefert einen Fließkommawert zwischen 0 und 1, also wacht die Eventschleife nach weniger als einer Sekunde wieder auf und springt wieder die gleiche in $timer_cb gespeicherte Callbackfuntion an. Um den Reigen anfangs in Schwung zu bringen, ruft Zeile 28 einmalig den Callback auf, der sich ab dann selbständig weiter perpetuiert.

Den Ansprungpunkt im Server bei eingehenden Websocket-Requests definiert das Kommando websocket in Zeile 12, im Gegensatz zu get in Zeile 32, die die Reaktion des Testservers auf GET-Anfragen zum Root-Pfad "/" des Webservers beantwortet. Der Websocket-Handler rechnet die gegebene "http://"-URL mittels eines regulären Ausdrucks in eine "ws://"-URL für Websocket-Requests um, fügt den Pfad /myws an und stopft den Gesamt-URL unter dem Schlüssel ws_url in den Stash des Template-Automaten. Zeile 39 verweist mit 'index' auf das weiter unten im __DATA__-Bereich definierte Template @@ index.html.ep.

Der zugehörige HTML-Code enthält etwas Text ("Random Counter") sowie ein font-Element mit der ID "counter". Zwar springen CSS-Puristen jetzt im Dreieck, aber schließlich geht es nur darum, irgendein HTML-Element mit einer bekannten ID zu definieren, damit der JavaScript-Code es später aus der DOM herausfieseln und seinen Inhalt auffrischen kann.

JavaScript an Raumschiff: Bitte melden

Das damit verknüpfte Layout 'default' ab Zeile 49 macht ein ordentliches HTML-Dokument aus dem oberen HTML-Schnipsel und fügt JavaScript-Code zur Websocket-Ansteuerung hinzu. Zeile 53 erzeugt ein neues Objekt der Klasse WebSocket, unter Verwendung der vorher erzeugten "ws://"-URL. Nimmt der Websocket erfolgreich Kontakt zum Server auf und absolviert den erforderlichen Handshake, erzeugt eine später vom Server an den Browser über den Websocket geschickte Nachricht einen Event namens socket.onmessage, dessen Callback-Funktion Zeile 55 setzt.

Abbildung 4: Nachdem der Client einen Websocket mit dem Server eröffnet hat, kann der Server asynchron Daten an den Client schicken.

Im Attribut data der übersandten Nachricht steht der Textstring, den send_message() auf der Serverseite am anderen Ende der Rohrpost eingetütet hat. Erwartungsgemäß steht dort also der aktuelle Zählerwert im Countdown, und Zeile 56 braucht nur das DOM-Element mit der ID "counter" zu suchen und dessen Attribut innerHTML auf den Zählerwert zu setzen, um letzteren zur Anzeige zu bringen. Geht der User mit dem Browser auf die in Zeile 6 gesetzte URL http://localhost:8032, fängt der Countdown an zu zuckeln.

Surfer-Kibitz

Als praktische Applikation sieht Listing 2 aktiven Usern einer Webseite über die Schulter und zeigt in einem Fenster in Echtzeit die Seiten an, die auch die Surfer zu Gesicht bekommen, zusammen mit dem URL und dem anzeigenden Host (Abbildung 6). Der Kontrolleur braucht nur den Mojolicious-Server in der URL-Zeile des Browsers anzugeben und schon frischt dieser in Echtzeit die Anzeige mit den gerade ausgelieferten Webseiten auf. Wie ist das möglich?

Abbildung 5: Ein Request im Logfile des Webservers.

Die Funktion tail() ab Zeile 69 in Listing 2 sieht einmal pro Sekunde nach, ob der sysread-Befehl auf ein im O_NONBLOCK-Modus geöffnetes access.log des Webservers neue Daten zutage fördert. Findet das Skript so GET-Requests wie in Abbildung 5, die auf eine HTML-Seite zeigen und nicht von der IP-Addresse des Skripts selbst stammen, verpackt es den angeforderten URL zusammen mit der in einen Hostnamen verwandelten IP-Addresse des Anfragenden in ein JSON-Konstrukt und schickt es in Zeile 46 zum Websocket des Browsers.

Abbildung 6: Im Schaufenster zeigt der Surfer-Kibitz an, welcher Surfer gerade welche Webseite ansteuert.

Der Callback socket.onmessage entpackt in Zeile 118 das JSON-Format, indem er es in runde Klammern einschließt und mit JavaScripts eval()-Funktion ausführt. Er sucht die HTML-Elemente mit den IDs "host", "url" und "pageview" im HTML-Code (Template ab Zeile 102) und pack die aus JSON extrahierten Daten in die entsprechenden Anzeigefelder.

Das hat zur Folge, dass die im Browser angezeigte HTML-Seite bei jeden eintreffenden Request für eine HTML-Seite auf dem Webserver den in ihr eingebetteten iframe (Zeile 108) mit der in der Logdatei gefundenen URL aktualisiert, also dem Kontrolleur die Webseite anzeigt, die auch der Surfer sieht. Gleichzeitig aktualisiert das Skript am oberen Seitenrand auch noch den angeforderten URL mitsamt dem aufgelösten Hostnamen des anfragenden Surfers.

Mit der blitzschnellen asynchronen, vom Server initiierten Aktualisierung lässt sich so in Echtzeit verfolgen, wer was auf dem Webserver ansieht. Damit das Skript bei prasselnden Anfragen nicht ins Schlingern gerät, beschränkt Zeile 51 die Untersuchung des Access-Logs auf einmal alle 5 Sekunden, und nimmt jeweils nur den ersten Treffer, der den Anforderungen in den Zeilen 33-35 genügt: Nur GET-Requests, um ungewollte Replays auf datenverändernde POSTs zu unterbinden, nur HTML-Seiten (falls jemand andere Endungen als .html? nutzt, ist dies anzupassen) und keine Requests, die von der IP des Kontroller selbst kommen. Der Browser frischt die gerade angezeigten URLs nur alle fünf Sekunden auf, egal wie schnell die Client-Requests ankommen.

Die in Zeile 31 aufgerufene Funktion parse_line_to_hash analysiert die ihr überreichte Accesslog-Zeile und wandelt sie in einen Hash mit den Schlüsseln "client" (Client-IP), "file" (angeforderter Dateipfad im URL) usw. um. revlookup() ab Zeile 91 transformiert eine IP über Reverse-DNS in einen Hostnamen, behält aber die ursprüngliche IP bei, falls dies fehlschlägt.

Listing 2: apache-peek

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use Mojolicious::Lite;
    004 use ApacheLog::Parser 
    005     qw(parse_line_to_hash);
    006 use Mojo::IOLoop;
    007 use POSIX;
    008 use Socket;
    009 use JSON qw(encode_json);
    010 
    011 my $listen = "http://website.com:8083";
    012 @ARGV = (qw(daemon --listen), $listen);
    013 
    014 my $base_url = "http://website.com";
    015 
    016 my $file = "access.log";
    017 sysopen my $fh, "$file", 
    018     O_NONBLOCK|O_RDONLY or die $!;
    019 
    020 my $loop = Mojo::IOLoop->singleton();
    021 
    022 ###########################################
    023 websocket "/myws" => sub {
    024 ###########################################
    025   my($self) = @_;
    026 
    027   my $timer_cb;
    028   $timer_cb = sub {
    029     for my $line ( @{ tail( $fh ) } ) {
    030       my %fields = 
    031         parse_line_to_hash( $line );
    032 
    033       if( $fields{ request } eq "GET" and
    034           $fields{ file } =~ /\.html?$/ and
    035             # skip our own requests
    036           $fields{ client } ne 
    037             $self->tx->remote_address
    038         ) {
    039         my $url = $base_url . 
    040                   $fields{ file };
    041         my $data = { 
    042           url  => $url,
    043           host => 
    044             revlookup( $fields{ client } ),
    045         };
    046         $self->send_message( 
    047             encode_json( $data ) );
    048         last;
    049       }
    050     }
    051     $loop->timer( 5, $timer_cb);
    052   };
    053   $timer_cb->();
    054 };
    055 
    056 ###########################################
    057 get '/' => sub {
    058 ###########################################
    059   my ($self) = @_;
    060 
    061   (my $ws_url = $listen) =~ s/http/ws/;
    062   $ws_url .= "/myws";
    063   $self->{stash}->{ws_url} = $ws_url;
    064 } => 'index';
    065 
    066 app->start();
    067 
    068 ###########################################
    069 sub tail {
    070 ###########################################
    071   my($fh) = @_;
    072 
    073   my($buf, $chunk, $result);
    074 
    075   while( $result = sysread $fh, 
    076                            $chunk, 1024 ) {
    077     $buf .= $chunk;
    078   }
    079 
    080   if( defined $result and defined $buf) {
    081     chomp $buf;
    082     my @lines = map { s/\s+$//g; $_; } 
    083                 split /\n/, $buf;
    084     return \@lines;
    085   }
    086 
    087   return [];
    088 }
    089 
    090 ###########################################
    091 sub revlookup {
    092 ###########################################
    093   my($ip) = @_;
    094 
    095   my $host = (gethostbyaddr( 
    096           inet_aton($ip), AF_INET ))[0];
    097   $host = $ip unless defined $host;
    098   return $host;
    099 }
    100 
    101 __DATA__
    102 @@ index.html.ep
    103 % layout 'default';
    104 
    105 Host: <em id="host"></em>
    106 URL: <em id="url"></em>
    107 
    108 <iframe width=100% height=800 src="" 
    109         id="pageview"></iframe>
    110 
    111 @@ layouts/default.html.ep
    112 <!doctype html><html>
    113   <head><title>Apache Peek</title>
    114     <script type="text/javascript">
    115       var socket = new WebSocket( 
    116                      "<%== $ws_url %>" );
    117       socket.onmessage = function (msg) {
    118         var data = eval( "(" + 
    119                           msg.data + ")" );
    120         document.getElementById( 
    121           "host").innerHTML = data.host;
    122         document.getElementById( 
    123           "url").innerHTML = data.url;
    124         document.getElementById( 
    125           "pageview").setAttribute( "src", 
    126                                 data.url );
    127       };
    128     </script>
    129   </head>
    130   <body> <%== content %> </body>
    131 </html>

Sicherheitslücken

Die Websockets-Implementierung in Firefox 4 und Google Chrome basiert auf der Draft-Version 76 des Protokolls, das Sicherheitsmängel aufweist. Zwar treten diese nur bei unverschlüsselter Kommunikation und fehlerhaft programmierten Proxies auf, doch im Endeffekt wäre der Enduser im realen Internet Attacken ausgesetzt.

Die aktuelle Version von Mojolicious auf dem CPAN (1.42) unterstützt deswegen nur die korrigierte Version des Protokolls, basierend auf der IETF-08-Spezifikation. Firefox 4 oder Google Chrome können mit letzterer allerdings nichts anfangen, doch Firefox 6 (Aurora) weiß damit umzugehen. Wer allerdings mit älteren Browsern testen möchte, der kann die alte Mojolicious-Version 1.16 vom CPAN laden, die damals nach dem Draft 76 des Websocket-Protokolls programmiert wurde. Für Produktionssysteme ist davon jedoch wegen der Sicherheitsmängel dringend abzuraten.

Abbildung 7: Ein neuer Firefox-Eintrag in about:config aktiviert die Websocket-API.

Firefox 4 schaltet seine Websockets wegen der veralteten Implementierung deshalb von Haus aus ab und nur wenn ein waghalsige Teufelskerl im Dialog about:config die Boolean-Variablen network.websocket.enabled und network.websocket.override-security-block auf true setzt, schaltet Firefox 4 das Feature frei (Abbildungen 7 und 8).

Abbildung 8: Wegen Sicherheitsbedenken muss der Anwender Websockets im Firefox 4 explizit aktivieren.

Installation

Die notwendigen CPAN-Module Mojolicious, ApacheLog::Parser und JSON lassen sich mit einer CPAN-Shell installieren. Das Modul ApacheLog::Parser bereitet einige Schwierigkeiten bei der Installation, da es auf Time::Piece beruht, dessen Testsuite fehlschlägt. Der Grund ist ein seit einem Jahr bestehender Bug im Zusammenspiel mit Test::Harness, der der Funktion des Moduls keinen Abbruch tut, aber seine per CPAN-Shell ausgeführten Tests scheitern lässt. Ein force install führt mit Gewalt zur erfolgreichen Installation.

Websockets stecken sicher noch in den Kinderschuhen und es wird wohl noch einige Zeit dauern, bis alle gängigen Browser auch die aktuelle Protokollversion fahren. Doch das Potential für Desktop-gleiche Browserapplikationen ist enorm, besonders im Chat- oder Videobereich lassen sich praktische Anwendungen denken.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2011/08/Perl

[2]

Websocket Testseite, http://websocket.org/echo.html

[3]

"Watch Your Processes Remotely with Mojolicious and a Smartphone", Jamie Popkin, Linux Journal, 2011/07, p58

[4]

"Webstecker", Stefan Neufeind, iX 06/2011, Seite 60

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.