Web Server Implementierung


... [ Informatik und Master-Seminar SS2003 ] ... [ HWS Gesamtübersicht ] ... [ Zusammenfassung ] ...

Übersicht: Web Server Implementierung


Überblick


Der hier behandelte Prototyp Web Server wurde von Simon Marlow implentiert.
Man kann daraus zwei Resultate ziehen:
    1. Man kann mit minimalem Aufwand einen Server mit weniger als 1500 Zeilen Code implementieren.
    2. Diese einfache Implementierung hat eine relativ ansehnliche Performance zustande gebracht.
Mit einigen geringen Modifizierungen einiger Komponenten, konnte die Performance auf ein akzeptables Niveau verbessert werden, was nur bei höchst umfangreichen Web Servern nicht ausreichen würde.
Diese Server Implementierung wurde eine Woche lang als alternativer Server von der  haskell.org Seite getestet und hat zuverlässig 2000 hits innerhalb 3M memory footprint erfasst. Man plant den gegenwärtig laufenden Apache Web Server von haskell.org durch die Haskell Implementierung irgendwann in der Zukunft zu ersetzen.

Die Funktionsweise von diesem Web Server sieht wie folgt aus:

Main-Schleife

Hier nun die eigentliche main-Schleife (Endlosschleife):
acceptConnections :: Config -> Socket -> IO ()
acceptConnections conf sock = do { (handle, remote) <- accept sock ;
                                   forkIO (
                                       catchAllIO ( talk conf handle remote
                                                      'finally' hClose handle )
                                                  ( \e -> logError e )
                                           )
                                   acceptConnections conf sock

acceptConnections kriegt als Argumente eine Server Konfiguration conf und ein auf Verbindung wartendes Socket sock. Es wartet auf neue ankommende Requests über das Socket. Wenn ein Request ankommt, wird ein neuer Thread mit forkIO erzeugt, der diesen Request dann bearbeitet, und die Schleife wartet wieder auf weitere Verbindungen. Der neu erzeugte Thread kommuniziert anschliessend mit dem Client über den Aufruf von talk, das eine Funktion zur Kommunikation in HTTP ist. finally ermöglicht eine streng sequenzierte Ausführung von Aktionen, unabhängig von Exceptions.

finally :: IO a -> IO b -> IO a

finally funktioniert wie in Java: Zuerst wird das erste Argument dann das zweite Argument ausgeführt, auch wenn das erste Argument eine Exception auslöst, und gibt den Wert vom ersten Argument zurück (oder wirft die ausgelöste Exception wieder). Damit wird in der Schleife gewährleistet, dass das Socket ordnungsgemäß geschlossen wird auch wenn irgendwelche Fehler oder Bugs im Code auftreten.

catchAllIO :: IO a -> (Exception -> IO a) -> IO a

catchAllIO führt zuerst das erste Argument aus. Wenn dabei eine Exception ausgelöst wird, führt es das zweite Argument (den Exception Handler) aus, andernfalls liefert es des Ergebnis zurück. In der obigen Schleife wird catchAllIO zum Auffangen von allen Fehlern benutzt, die dann in die Error Log Datei geschrieben werden.


Request Bearbeitung

Die Bearbeitung eines Requests besteht aus folgenden Schritten:
  1. Request aus dem Socket lesen.
  2. Request parsen.
  3. Response erstellen.
  4. Response an Client senden.
  5. falls Keep Alive Verbindung, wieder zum Lesen aus dem Socket zurückkehren (zurück zum Schritt 1).

Das Lesen aus dem Socket erfolgt durch getRequest:

getRequest :: Handle -> IO [String]

getRequest gibt eine Liste von String als return zurück.
Beispiel für einen Request:

GET /index.html HTTP/1.1
Host: www.haskell.org
Date: Wed May 31 11:08:40 GMT 2000

Das Kommando des Requests ist in diesem Fall GETindex.html ist der Name des angeforderten Objektes und 1.1 die Version des benutzten HTTP Protokolls. In den weiteren Zeilen (genannt headers), werden weitere Informationen gegeben, die meistens optional sind. Wenn der Server einen Header nicht versteht, soll er dieses ignorieren.

Danach kommt das Parsen des Requests in eine Request Struktur:

data Request = Request { reqCmd     :: RequestCmd,
                         reqURI     :: ReqURI,
                         reqHTTPVer :: HTTPVersion,
                         reqHeaders :: [RequestHeader]  }
parseRequest :: Config -> [String] -> Either Response Request

Das Parsen kann hierbei zu einer Response führen, die einen Mißerfolg darstellt und wahrscheinlich eine "Bad Request" Response, die auch spezifischere Informationen enthalten kann, ist.
Wenn das Parsen erfolgreich war, wird eine Response generiert:

data Response = Response { respCode     :: Int,
                           respHeaders  :: [String],
                           respCoding   :: [TransferCoding],
                           respBody     :: ResponseBody,
                           respSendBody :: Bool  }
data ResponseBody = NoBody
                  | FileBody Integer{-size-} FilePath
                  | HereItIs String
genResponse :: Config -> Request -> IO Response

genResponse überprüft den Request auf Validität (Korrektheit) und erzeugt eine passende Response. Bei einem GET Request erhält man eine Response mit einem FileBody. Ein "invalid request" (ungültiger Request) resultiert in einer Fehlermeldung, die aus einer automatisch generierten HTML besteht, die den Fehler in dem HereItIs Body näher beschreibt. Falls eine Response aus einer ganzen Datei besteht, wird die Datei nicht als String in respBody enthalten sein, sondern nur den Pfad der Datei in respBody enthalten. Damit kann die Datei auf eine effizientere Art übertragen werden, wie durch Konvertierung zum String und zur Datei zurück.

Anschließend, muss nur noch die Response zum Client gesendet werden:

sendResponse :: Config -> Handle -> Response -> IO ()

Zusammengefasst, sieht die resultierende talk Funktion in etwa folgendermaßen aus:

talk :: Config -> Handle -> HostAddress -> IO ()
talk conf handle haddr = do req <- getRequest handle
                          case parseRequest r of
                             Left resp -> do sendResponse conf handle resp
                                             return ()
                             Right req -> do resp <- genResponse conf req
                                             sendResponse conf handle resp
                                             logAccess req resp haddr
                                             if (isKeepAlive req)
                                               then talk conf handle haddr
                                               else return ()

Hierbei fehlt nur noch der Code zum Error-Logging und Timeout, was in den folgenden Abschnitten behandelt wird. Und schließlich, resultiert logAccess in einem Eintrag in die Log-Datei, was auch nachfolgend behandelt wird.


Timeout-Mechanismus

Timeout ist bei einem Server notwendig, wenn z.B. eine Verbindung zum Client abgebrochen ist oder der Client eine außergewöhnlich lange Antwortzeit hat.
Mit dem Timeout können Verbindungen zu solchen Clients abgebrochen werden und die entsprechenden Ressourcen wieder freigegeben werden.
Hier eine generische Implementierung von einem timeout:

timeout :: Int       -- timeout in seconds
        -> IO a      -- action to run
        -> IO b      -- action to run on timeout
        -> IO a

(timeout t a b) lässt zuerst a  laufen,  bis es entweder sich beendet oder t Sekunden vergangen sind. Wenn sich a innerhalb t Sekunden selbst beendet, wird das Ergebnis von a zurück gegeben, andernfalls wird a terminiert und b ausgeführt. Falls a eine Exception wirft, wird diese durch timeout weitergeleitet. Aufgrund keiner anderen Seiteneffekten, kann timeout an beliebigen Stellen im Code verwendet werden.
Aufgrund asynchroner Excpetions, kann jedoch Aktion a jederzeit terminiert werden, weshalb es erforderlich ist es exception safe zu machen, d.h. alle variablen Datentypen dürfen in keinem inkonsistenten Zustand gelassen werden oder irgendwelche Ressourcen ausgelassen werden. In diesem Zusammenhang, ist es sogar notwendig den geasamten Code exception safe zu machen, da stack overflow bzw. heap overflow als asynchrone Exceptions ausgelöst werden. Um Code exception safe zu machen, gibt es in Haskell folgende zwei Funktionen:

block   :: IO a -> IO a
unblock :: IO a -> IO a

(block a) führt a aus, wobei asynchrone Exceptions abgeblockt werden. Erst durch den Aufruf (unblock a) kann der Thread wieder von anderen Threads terminiert werden. Auch diese beiden Funktionen können an beliebigen Stellen im Code verwendet werde
Beispiel:

block ( do a <- takeMVar m
           (unblock (...))
             'catchAllIO'
                (\e -> do putMVar m a; throw e)
           putMVar m a   )

In diesem Beispiel soll gezeigt werden, dass ein Lock in Form von MVar m  auch dann sicher freigesetzt werden kann, wenn eine Exception geworfen wurde.
Eine andere Methode einen Code exception safe zu machen, ist die Verwendung von der Kombination finally und bracket. Damit kann man eine locking Sequenz einfacher schreiben:

bracket   :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket (takeMVar m) (putMVar m) (...)          -- vereinfachte Schreibweise einer locking Sequenz


Log und Error Log

Ein Web Server erstellt eine Log-Datei, in der alle erhaltenen Requests inklusive einiger Informationen zu den versendeten Responses aufgelistet werden.
Dabei kann ein Log-Eintrag folgende Daten enthalten:

Das Format eines Log-Eintrags, d.h. welche Datenfelder enthalten sind, kann konfiguriert werden und kann auch andere Datenfelder zu Request und Response enthalten.Aufgrund einiger standardisierten Log-Eintrag Formaten von verbreiteten Servern und Programmen, die Log-Dateien auswerten und dazu Reporte erstellen, wird in dem Haskell Web Server ein kompatibles Format der Log-Datei erstellt:

logAccess :: Request
          -> Response
          -> HostAddress
          -> TimeDiff
          -> IO ()

Ein Thread das ein Request bearbeitet erzeugt einen Log-Eintrag, indem es logAccess mit den Argumenten Request, Response, Client Adresse und der Dauer der Bearbeitung (Zeit zwischen Erhalt des Requests und Beendigung der Response) aufruft.
Das Schreiben des Eintrags in die Log-Datei wird jedoch von einem separatem Thread durchgeführt. Die Log-Einträge werden dabei über einen globalen Channel von einem Thread zum Log-Thread übergeben. logAccess fügt den Log-Eintrag in den Channel ein und der Log-Thread holt sich (und entfernt) den Log-Eintrag aus dem Channel und schreibt es in die Log-Datei.
Ein separater Log-Thread hat den Vorteil, dass das System geringer belastet wird, weil die Request-Threads sich gleich nach Abgabe des Eintrags in den Channel beenden können und somit nicht weiter mit Schreiben in die Log-Datei belastet werden. Zusätzlich, kann der Log-Thread mehrere Einträge zusammenfassen und als einen einzelnen Block in die Log-Datei schreiben.
Der Log-Thread kann darüberhinaus sich selbst wieder neu starten (restart), wenn eine Exception ausgelöst wird. Bei einem Restart versucht der Thread die Log-Datei wieder neu zu öffnen (re-open) um dann mit dem Schreiben des nächsten Log-Eintrags fortzufahren.
Dieser Restart-Mechanismus wird ausserdem von dem main-Thread (Endlosschleife) genutzt (mißbraucht), wenn ein Request zum auslesen der Log-Datei empfangen wurde.

Das Error-Logging funktioniert nach dem gleichen Prinzip, d.h. es gibt auch hier einen separaten Error-Log-Thread der in die Error-Log-Datei die Einträge schreibt und sich selbst Restarten kann, falls er eine Exception empfängt (wobei auch der Restart mit Exception in die Datei reingeschrieben wird). Und auch hier werden die Error-Log-Einträge über einen globalen Channel übergeben.
Die Error-Log-Einträge werden von den Exception Handlern erzeugt und in den Channel eingefügt, wenn bei einem Request-Thread eine Exception ausgelöst wurde.


Globale Variablen

Wie schon erwähnt, wird die Kommunikation zwischen den Threads über einen globalen Channel bewerkstelligt.
Der globale Channel ist in Haskell als eine Instanz von global mutable object realisiert.
Wie man nun eine globale Variable implementiert, kann man anhand der folgenden Implementierung einer globalen MVar sehen:
global_mvar :: MVar String
global_mvar = unsafePerformIO newEmptyMVar
Obwohl die globalen Variablen sehr praktisch sind, sollte man in Umgang mit ihnen folgendes bedenken:
Alternativ zu globalen Variablen, könnte man "Implizite Parameter" benutzen. Obwohl sie einfacher sind, sind sie aber weniger effizient. Die Entwickler haben sich aber mit dieser Alternative bis jetzt nicht näher befasst.


... [ Informatik und Master-Seminar SS2003 ] ... [ HWS Gesamtübersicht ] ... [ Web Server Implementierung ] ... [ Zusammenfassung ] ...