Wissen: Einführung in die Netzwerkprogrammierung mit der Socket-API


Aufbau von Client-Server-Anwendungen

Dieser Abschnitt stellt die Anwendung der Socket-API anhand eines einfachen Beispiels vor.

Wir werden einen Echoserver und den zugehörigen Client erstellen und verschiedene grundlegende Architekturen betrachten. Außerdem sollen einige Hilfsfunktionen eingeführt werden, die für die Datenübertragung notwendig sind. Um die Programmtexte etwas kompakter zu gestalten, wurden sogenannte Wrapper-Funktionen eingeführt, die man an den führenden Unterstrichen erkennt. Die kapseln Überprüfungen und führen im Fehlerfall eine Textausgabe und die Beendigung des Prozesses durch.

Der blockierende Echoserver

Unser erstes Beispiel stellt einen TCP-Server dar, der von einem Client Zeichenketten entgegennimmt und diese unverändert zurücksendet. Es handelt sich somit um einen Echoserver in der denkbar einfachsten Form.

1               int main (int argc, char** argv) {

2               int fdListen;

3               int fdClient;

4               int len;

5               int addrLen = 0;

6               struct sockaddr_in saddr_listen;

7               struct sockaddr_in saddr_client;

8               char buffer[MAXLINE];

9

10            memset (&saddr_listen, 0, sizeof (struct sockaddr_in));

11            memset (&saddr_client, 0, sizeof (struct sockaddr_in));

12            saddr_listen.sin_family = AF_INET;

13            saddr_listen.sin_addr.s_addr = htonl (INADDR_ANY);

14            saddr_listen.sin_port = htons (PORT);

15

16

17

18            fdListen = __socket (AF_INET, SOCK_STREAM, 0);

19

20            __bind (fdListen, (struct sockaddr *) &saddr_listen, sizeof  (saddr_listen));

21            __listen (fdListen, WAITQUEUE);

22

23            printf („listening on port %i\n“, PORT);

24

25            for (;;) {

26                 fdClient = __accept (fdListen, (struct sockaddr *) &saddr_client,   &addrLen);

27

28                 printf („>“);

29                 len = read (fdClient, buffer, MAXLINE);

30                 buffer[len] = 0;

31                 printf („%i bytes read from %i: %s\n“, len, fdClient, buffer);

32

33                              //Der Prozess schläft für 3 Sekunden, stellvertretend für sonstige

34                              //Serverlast.

35                 sleep (3);

 

An diesem Beispiel erkennt man die notwendigen Schritte, den Serverprozeß dem System bekannt zu machen. Es wird eine Adressstruktur deklariert und mit Werten belegt, ein Socketdeskriptor wird initialisiert und anschließend an eine Adresse und eine Warteschlange gebunden. Auffallend sind hier die Funktionen htonl und htons. Um ihre Bedeutung zu verstehen, muß man wissen, daß bei den verschiedenen Rechnerarchitekturen keine einheitliche Anordnung von Datenworten im Speicher existiert. Ein Datenwort, das aus mehr als einem Byte besteht, kann in zwei Reihenfolgen im Speicher abgelegt werden, indem man entweder mit dem niederwertigsten (least significant byte – LSB) oder dem höchstwertigen (most significant byte – MSB) beginnt. Man spricht hier von Little Endian und Big Endian-Architekturen. TCP erwartet, daß Datenworte mit dem höchstwertigen Byte voran gesendet werden, unabhängig von der Architektur des jeweiligen Hosts. Für diese Konvertierung zwischen Host- und Netzwerk-Byteordnung existieren die Funktionen htonl (host-to-network-long), htons (host-to-network-short), ntohl (network-to-host-long) und ntohs (network-to-host-short), die jeweils Datenwörter aus zwei oder vier Bytes konvertieren.

Mit dem Aufruf von listen wird der Socket zu einem sogenannten listening socket, der keinen aktiven Verbindungsaufbau durchführt. Nachdem mit diesem Schitt die Vorbereitungen abgeschlossen sind, tritt der Prozeß in eine Endlosschleife ein, die aus den vier Abschnitten

  1. Bestätigen eines Verbindungsaufbau-Wunsches
  2. Lesen der Eingabe des Clients
  3. Senden einer Antwort an den Client
  4. Schließen der Clientverbindung

besteht. Durch den Aufruf von accept erhält der Serverprozeß einen Socketdeskriptor zur Kommunikation mit dem Client. Wie bereits erwähnt handelt es sich hierbei nicht um den horchenden Socket. Über diesen mit fdClient bezeichneten Socketdeskriptor läuft die Kommunikation mit dem Client ab. Der zweite Deskriptor, fdListen dient nur Entgegennahme der Verbindungsanfragen und bleibt bis zum Ende der Applikation bestehen. (Da der Server in einer Endlosschleife läuft, wird dieser Socket in der Tat nicht geschlossen.)

1               int main (int argc, char** argv) {

2               int fdClient;

3               int len;

4               int addrLen = 0;

5               struct sockaddr_in saddr_client;

6               char buffer[MAXLINE];

7

8               memset (&saddr_client, 0, sizeof (struct sockaddr_in));

9               saddr_client.sin_family = AF_INET;

10            ient_aton („127.0.0.1“, &(saddr_client.sin_addr));

11            saddr_client.sin_port = htons (PORT);

12

13

14            fdClient = __socket (AF_INET, SOCK_STREAM, 0);

15

16            __connect (fdClient, (struct sockaddr *) &saddr_client,

17                                                sizeof (struct sockaddr));

18

19            printf („>“);

20

21            if (fgets (MAXLINE, buffer, stdin)) {

22                 write (fdClient, buffer, len);

23                 len = read (fdClient, buffer, MAXLINE);

24                 buffer[len] = 0;

25                 printf („%i bytes read from %i: %s\n“, len, fdClient, buffer);

26            }

27

28            close (fdClient);

29            return 0;

30            }

Hier ist der zugehörige Client zu sehen. Um eine TCP-Verbindung zu etablieren, muß er lediglich connect aufrufen, wodurch der 3-Wege-Handshake initiiert wird. Im Fall eines Fehlers kehrt die Funktion mit einem Rückgabewert -1 zurück. Eine Fehlernummer ist in der globalen Variablen errno abgelegt. Unsere Wrapper-Funktion reagiert auf Fehler mit der Beendigung des Prozesses.

Nach dem Verbindungsaufbau beginnt die Kommunikation mit dem Server, der zu diesem Zeitpunkt aus dem Aufruf von accept zurückkehrt. Nun läuft das eigentliche Protokoll zwischen Server und Client ab. Das bedeutet, es müssen Regeln vereinbart worden sein, nach denen die Kommunikation zeitlich und semantisch ablaufen soll. In diesem einfachen Fall besteht das Protokoll aus der Vereinbarung, daß der Client zunächst in einem einzigen Schreibvogang eine unformatierte Zeichenkette an den Server überträgt und anschließend auf eine Antwort des Servers wartet. Dieses Protokoll stellt nun keine großen Anforderungen an die Implementierung der beteiligten Prozesse. Mächtigere Protokolle zeichnen sich im wesentlichen dadurch aus, daß die Eingaben in irgendeiner Weise strukturiert sind. Zum anderen kann das Protokoll verschiedene Zustände definieren, die nur unter bestimmten Eingaben gewechselt werden. TCP selbst ist ebenfalls ein solcher endlicher Automat.

Nichtblockierende Echoserver

Unabhängig vom implementierten Protokoll hat dieser Server jedoch einen weiteren entscheidenden Mangel. Dieses Problem kommt besonders dann zum tragen, wenn die Bearbeitung einer Clientanfrage mehr Zeit beansprucht, als das bloße Ausgeben einer Zeichenkette. So könnte die Rückgabe des Servers das Ergebnis einer Datenbankabfrage oder einer komplizierten Berechnung sein. Fatal wäre auch ein Fehler, der Serverprozeß daran hindert, die Endlosschleife komplett zu durchlaufen. In allen drei Fällen ist das Resultat das gleiche. Clients die versuchen, eine Verbindung zum Server aufzubauen, werden hingehalten. Es existiert zwar genau für solche Fälle die mittels listen eingerichtete Warteschlange – diese ist jedoch auch nicht unbegrenzt und so wäre es nur eine Frage der Zeit, bis der erste Client abgewiesen wird. Abgesehen davon ist es natürlich auch nicht besonders performant, alle Anfragen der Reihe nach zu beantworten. Um das zu umgehen, muß man einen weg finden, mehrere Clients gleichzeitig zu bedienen. Man spricht dann von einem nichtblockierenden Server im Gegensatz zum blockierenden Server, den wir oben gesehen haben.

Das Prinzip eines nebenläufigen Servers ist immer ähnlich. Der Hauptprozeß nimmt Anfragen entgegen und beauftragt daraufhin einen anderen Prozess mit der Bearbeitung dieser Anfrage.

Als mögliche Verbesserung bietet sich die Verwendung von Threads an Stelle der Prozesse an. Außerdem können Prozesse oder Threads im Vorhinein erzeugt werden, so daß beim Entgegennehmen einer Clientanfrage diese Zeit gespart werden kann. In diesem Fall ist dann von einem Preforked-, bzw. Prethreaded-Server die Rede. Da sich das grundlegende Prinzip allerdings immer ähnelt, werden wir uns hier auf die Verwendung nebenläufiger Prozesse und Threads beschränken.

Es gibt auch Möglichkeiten, nichtblockierende Server mit einem einzelnen Prozeß zu schreiben. Eine davon ist das IO-Multiplexing, das auf der Funktion select basiert. Der inetd-Deamon stellt beispielsweise einige Dienste zur Verfügung, die er nicht an andere Prozesse deligiert, sondern selbst ausführt. Da hier nur kurze Bearbeitungszeiten anfallen, kann auf die zusätzliche Komplexität, die ein nichtblockierender Server mit sich bringt, verzichtet werden. So erfordern daytime-Server, die lediglich die lokale Systemzeit ausgeben, beispielsweise nicht den Einsatz paralleler Prozesse. Trotzdem birgt das IO-Multiplexing alleine einen Teil der Nachteile der blockierenden Server. Obwohl mehrere Eingänge zur gleichen Zeit überwacht werden, kann der Server durch eine fehlerhaft oder mutwillig ungültige Anfrage außer Gefecht gesetzt werden. Etwa, wenn das Protokoll des Echoservers vorschriebe, daß eine bestimmte Mindestzeichenzahl eingehalten werden müsste. Ein Client könnte dann durch eine unvollständige Übertragung den Server dauerhaft oder zumindest bis zum Ablauf eines Timeouts auf Socketebene blockieren.

Die Verwendung von Threads stellt daher den eleganteren Kompromiss zwischen Performanz, Sicherheit und Strukturierung dar, weshalb die übrigen Techniken nur am Rande erwähnt werden.

Parallele Prozesse

Unsere erste Version eines nichtblockierenden Servers verwendet parallele Prozesse, die mit dem Systemruf fork erzeugt werden. Fork erzeugt eine Kopie das aufrufenden Prozesses, die sich nur in der Prozeßnummer vom erzeugenden Prozess unterscheidet. Dies ist auch ein Grund, warum fork eine so kostspielige Operation ist. Die Kosten für das Kopieren werden bei den meisten Implementierungen durch das sogenannte Copy-on-Write-Verfahren vermindert. Hier verwenden beide Prozesse ein gemeinsames Datensegment, bis einer der beiden schreibend darauf zugreift. Erst dann wird das komplette Segment dupliziert. Der eigentliche Nachteil von fork ist jedoch, daß Prozeßwechsel an sich sehr teuer sind. Bei jedem Wechsel muß der Dispatcher des Systems nicht nur den Prozesskontrollblock kopieren und Register sichern. Häufig müssen auch ganze Speicherseiten von der Festplatte in den Speicher geladen werden.

Für den Programmierer bedeuten parallele Prozesse, daß mit voneinander getrennten Adressbereichen gearbeitet werden muß. Zwar stellt das System geeignete Mittel zur Interprozesskommunikation Verfügung, aber ein Mehraufwand ist es dennoch. Wie wir bei den weiteren Beispielen noch sehen werden, eignen sich parallele Prozesse dazu, Aufgaben zu deligieren, etwa indem ein Prozeß einen Sohnprozeß erzeugt und anschließend mit Hilfe des Systemrufs exec

ein neues Programm in das Textsegment des Sohnes lädt. Auf die Weise kann man sich ein bereits vorhandene Programme zu Nutzen machen.

Bei unserem Server kommt fork nun folgendermaßen zum Einsatz. Wurde eine neue Verbindung aufgebaut, erzeugt der Serverprozeß einen Sohn, der die Kommunikation mit dem Client abwickelt. Der Server kehrt sofort zurück und kann weitere Anfragen entgegennehmen.

1               int main (int argc, char** argv) {

2               int fdListen;

3               int fdClient;

4               int len;

5               int addrLen = 0;

6               struct sockaddr_in saddr_listen;

7               struct sockaddr_in saddr_client;

8               char buffer[MAXLINE];

9

10            pid_t parent_pid, child_pid, fork_pid, wait_pid;

11            int child_stat;

12

13            fdListen = __socket (AF_INET, SOCK_STREAM, 0);

14

15            memset (&saddr_listen, 0, sizeof (struct sockaddr_in));

16            memset (&saddr_client, 0, sizeof (struct sockaddr_in));

17            saddr_listen.sin_family = AF_INET;

18            saddr_listen.sin_addr.s_addr = htonl (INADDR_ANY);

19            saddr_listen.sin_port = htons (PORT);

20

21            __bind (fdListen, (struct sockaddr *) &saddr_listen, sizeof (saddr_listen));

22            __listen (fdListen, WAITQUEUE);

23

24            if (signal (SIG_CHLD, sig_handler) == SIG_ERR) {

25                 fprintf (stderr, „error establishing sig_handler\n“);

26                 exit (1);

27            }

28

29            printf („listening on port %i\n“, PORT);

30            for (;;) {

31                 fdClient = __accept (fdListen, (struct sockaddr *) &saddr_client, &addrLen);

32                 fork_pid = fork ();

33

34                 switch (fork_pid) {

35                              case -1:

36                                   fprintf (stderr, „error %i during fork\n“, errno);

37                                   exit (1);

38                                   break;

39                              case 0:

40                                   close (fdListen);

41                                   len = read (fdClient, buffer, MAXLINE);

42                                   buffer[len] = 0;

43                                   printf („>%i bytes read from %i: %s\n“, len, fdClient, buffer);

44                                   sleep (3);

45                                   write (fdClient, buffer, len);

46                                   close (fdClient);

47                                   exit (0);

48                                   break;

49                              default:

50                                   close (fdClient);

51                                   break;

52                 }

53            }

54            return 0;

55            }

56

57            void sig_handler (int signum) {

58               if (signal (SIGCHLD, sig_handler) == SIG_ERR) {

59                              fprintf (stderr, „error re-establishing sig_handler\n“);

60                              exit (1);

61               }

62               while (waitpid ((pid_t)-1, NULL, WNOHANG) > 0) {}

63            }

Die entscheidende Änderung gegenüber der ersten Version befindet sich in der Endlosschleife. Nach dem Aufruf von accept wird ein neuer Prozeß erzeugt. Der folgende Switch-Block mag auf den ersten Blick etwas seltsam erscheinen. Fork ist allerdings die einzige Funktion, die mit zwei Rückgabewerten zurückkehrt. Der gerade erzeugte Sohnprozeß läuft ab der Rückkehr von fork weiter. Er erzhält 0 als Rückgabewert. Im Vaterprozeß hat fork_pid die Prozeßnummer des neu erzeugten Sohnes. Er läuft daher im default-Zweig des Switch-Blocks weiter. Im Fall eines Fehlers kehrt fork mit -1 zurück.

Dem Vaterprozeß bleibt nichts weiter zu tun, als den nicht benötigten Socketdeskriptor fdClient zu schließen und erneut im Aufruf von accept zu blockieren, bis ein neuer Client verbinden will. Dem Leser, der noch keine Erfahrung im Umgang mit Prozessen hat, mag die Funktion signal zunächst etwas unverständlich sein. Dazu muß man wissen, daß ein UNIX-Prozess nach seiner Beendigung nicht komplett aus dem Speicher verschwindet. Es werden zwar sämtliche reservierten Ressourcen freigegeben, aber ein Eintrag in der Prozeßtabelle verbleibt trotzdem. Man spricht dann von einem Zombie-Prozeß. Gleichzeitig mit Beendigung des Prozesses passiert folgendes: der Vaterprozeß, sofern noch vorhanden, wird über das Ende des Sohnes informiert. Diese Benachrichtigung erfolgt über den sogenannten Signalmechanismus. Mit Hilfe der Funktion signal kann eine Callbackfunktion angemeldet werden, die beim eintreffen eines solchen Signals aufgerufen wird. Weitere Beispiele für Signale sind SIGTERM, das beim Drücken von STRG-X an das Terminal gesendet wird, oder SIGSEGV, das den Prozeß über eine Speicherschutzverletzung informiert. In unserem Fall, beim Beenden eines Sohnprozesses tritt das Signal SIGCHLD auf. Zu diesem Zeitpunkt ist der Sohnprozeß bereits beendet. Der Vater hat jedoch nun die Möglichkeit, Informationen über das Ableben des Sohnes einzuholen. Dies geschieht über den Aufruf der Funktion waitpid, die gleichzeitig auch den verbliebenen Eintrag aus der Prozeßtabelle entfernt. An dieser Stelle sind wir lediglich an diesem kosmetischen Effekt, die Prozeßtablle sauber zu halten, interessiert und werten daher keine weiteren Rückgaben aus.

Threads

Wie bereits erwähnt, sind nebenläufige Prozesse nicht immer das non-plus-ultra, was Parallelverarbeitung angeht. Eine Alternative, die hier ebenfalls vorgestellt werden soll, sind Threads, die in der Literatur auch als Lightweight-Processes bezeichnet werden. Der hearusragende Unterschied zu den Prozessen liegt im gemeinsamen Adressraum der Threads. Man kann sagen, daß ein einzelner Prozeß aus mehreren Threads gebildet wird, die jeweils einen privaten Stack und Befehlszähler besitzen. Allerdings resultieren daraus einige potentielle Fehlerquellen. So können globale Variablen von allen Threads gelesen, aber auch geändert werden und müssen daher vor dem Zugriff synchronisiert werden. Auch geöffnete Dateien sind global sichtbar. Darauf muß beim Zugriff auf Dateizeiger geachtet werden. Von der jeweiligen Implementierung ist abhängig, wie sich die Funktionen fork und exec verhalten. Posix definiert hier, daß durch fork eine neuer Prozeß mit einer Kopie des aufrufenden Thread erzeugt wird. Der Aufruf von exec überlagert das Textsegment des gesamten Prozesses und hat dadurch das Ende aller Threads zur Folge. Diese Feinheiten müssen besonders unter Linux bedacht werden, da hier auf zwei grundlegend verschiedene Implementierungen zugegriffen werden kann. Traditionelle LinuxThreads, die intern durch Aufrufe von fork realisiert werden, sowie die oben genannten PosixThreads. Bei den hier vorgestellten Beispielen wird aufgrund der Plattformunabhängigkeit ausschließlich auf PosixThreads zurückgegriffen.

1               int main (int argc, char** argv) {

2               int fdListen;

3               int *fdClient;

4               int addrLen = 0;

5               struct sockaddr_in saddr_listen;

6               struct sockaddr_in saddr_client;

7

8               pthread_t thr_id[1];

9               pthread_attr_t attr;

10

11            __pthread_init (&attr);

12

13            fdListen = __socket (AF_INET, SOCK_STREAM, 0);

14

15            memset (&saddr_listen, 0, sizeof (struct sockaddr_in));

16            memset (&saddr_client, 0, sizeof (struct sockaddr_in));

17            saddr_listen.sin_family = AF_INET;

18            saddr_listen.sin_addr.s_addr = htonl (INADDR_ANY);

19            saddr_listen.sin_port = htons (PORT);

20

21            __bind (fdListen, (struct sockaddr *) &saddr_listen, sizeof                                                                     (saddr_listen));

22            __listen (fdListen, WAITQUEUE);

23

24            printf („listening on port %i\n“, PORT);

25            for (;;) {

26                              fdClient = (int *) malloc (sizeof (int));

27                 *fdClient = __accept (fdListen, (struct sockaddr *) &saddr_client,                                &addrLen);

28                 pthread_create (&thr_id[0], &attr, THREADFUNC_TP serve_client, (void                                                  *)fdClient);

29            }

30

31            pthread_attr_destroy (&attr);

32            return 0;

33            }

 

34            void serve_client (int* fdClient) {

35               int len = 0;

36               char buffer[MAXLINE];

37               len = read (*fdClient, buffer, MAXLINE);

38               buffer[len] = 0;

39               printf („>%i bytes read by %i from %i: %s\n“, len, pthread_self (),                                   *fdClient, buffer);

40               write (*fdClient, buffer, len);

41               close (*fdClient);

42               free (fdClient);

43            }

An Stelle von fork ist hier der Aufruf von pthread_create getreten. Seine Aufgabe ist, einen neuen Thread zu erzeugen, der die angegebene Funktion (server_client) mit dem übergebenen Parameter (fdClient) ausführt. Der zu übergebende Parameter ist vom Typ void *. Werden mehrere benötigt, muß eine Struktur definiert und übergeben werden. Der neue Thread beginnt sofort seine Ausführung.

Der Grund für die Verwengung eines dynamisch allokierten int-Wertes ist die bekannte Tatsache, daß für Threads untereinander kein Speicherschutz existiert. Würde man eine einzige int-Variable verwenden, deren Adresse jedesmal übergeben würde, würde diese bei jedem Aufruf von accept überschrieben werden. Das hätte zur Folge, daß jeder zu diesem Zeitpunkt laufende Thread diese überschriebene Variable lesen würde. Durch das wiederholte Allokieren von Speicher erhält jeder Thread eine „private“ Variable, deren Adresse nur ihm bekannt ist. Ein gemeinsamer Zugriff auf einen einzigen Speicherbereich ist in diesem Fall nicht sinnvoll, da das Programm hierdurch gezwungen wäre sequentiell abzulaufen.

Dieses Kapitel konnte natürlich höchstens einen kleinen Überblick über die Programmierung von Serveranwendungen geben. Die Kommunikation über UDP beispielsweise wurde nur am Rande erwähnt, um dem begrenzten Platz Rechnung zu tragen. Fortgeschrittene Techniken wie Socketoptionen, Rawsockets oder nichtblockierende IO eröffnen dem Entwickler hier zusätzliche Möglichkeiten, die allerdigns problemlos ein eigenes Buch füllen würden. Auch wurden Begriffe der UNIX-Systemprogrammierung wie Prozesse, Interprozesskommunikation und Threads ebenfalls nur, soweit es für das Verständnis der Beispiele erforderlich war, erklärt. Einen tieferen Einblick in die Thematik gewährt hier [literatur]

Vielmehr wurde versucht, die Grundlage für den nächsten Abschnitt zu schaffen, in dem das Zusammenspiel der vorgestellten Techniken an einem größeren Beispielprojekt betrachtet wird.

Beispielprojekt „Webserver“

Nachdem wir uns nun mit der Socket-API bekannt gemacht haben, ist es nun an der Zeit, das neu erworbene Wissen in die Praxis umzusetzen. Auf den folgenden Seiten soll gezeigt werden, wie ein komplexeres Protokoll implementiert werden kann. Aufgrund seines relativ einfachen Aufbaus bietet sich hier das Hypert Transfer Protocol an. Es dient dazu, Ressourcen in einem TCP-Netz verbindungslos verfügbar und verwaltbar zu machen. Damit bildet es das Fundament, auf dem sich das World Wide Web abspielt. HTTP ermöglicht es vor allem, Clients in Form von Webbrowsern, Dateien unterschiedlichsten Typs anzufordern. Aber nicht nur das: das Protokoll definiert verschiedene Befehle, mit denen Daten in beide Richtungen ausgetauscht und aktualisiert werden können und ermöglicht damit, in Verbindung mit der bereits erwähnten CGI-Schnittstelle, Inhalte dynamisch – sozusagen auf Anfrage – generieren zu lassen. Zusätzlich wird durch HTTP bereist eine rudimentäre Nutzerverwaltung ermöglicht. Dennoch soll gleich vorweggenommen werden, daß hier natürlich kein zweiter Apache-Server entstehen kann. Tatsächlich wird das Protokoll an manchen Stellen nur exemplarisch umgesetzt. Allerdings entsteht dabei dennoch eine Anwendung, die in der Lage ist, Webseiten, auch mit Unterstützung von CGI-Skripten, über das Web sichtbar zu machen.

Bevor nun mit der eigentlichen Entwicklung begonnen werden kann, muß zunächst der Funktionsumfang festgelegt werden. Die Anforderungen an unseren Server besagen, daß er sich gemäß der Vorgaben von HTTP verhalten soll. Der erste Schritt muß also die Ermittlung dieser Vorgaben sein. Dazu ziehen wir die übliche Informationsquelle für Internetprotokolle, die RFC-Dokumente zu Rate. In unsere Fall handelt es sich hierbei um das Dokument RFC 2616, das unter http://www.w3.org/Protocols/rfc2616/rfc2616.html öffentlich zugänglich ist.

Dieses etwa 200-seitige Dokument verrät uns folgendes über die Arbeitsweise von HTTP:

Für die Kommunikation zwischen Client und Server werden Nachrichten und deren Formate definiert

Es werden Fehlerfälle und entsprechende Verhaltensweisen bestimmt.

Neben der Syntax werden die jeweiligen Reaktionen des Servers auf Nachrichten festgelegt.

Es gibt keine internen Zustände, die der Server einnehmen kann.

Die Bedeutung des Punktes 1 für uns ist, daß wir nun wissen, welche „Sprache“ ein Webserver verstehen muß. Wir können den Server also nun in die Lage versetzen, zu entscheiden, ob eine korrekte Anfrage eingegangen ist. Darüberhinaus legt Punkt 2 fest, wie wir auf ungültige Anfragen reagieren sollen. Punkt 3 kann an dieser Stelle nicht vollständig realisiert werden. Um die Arbeitsweise zu demonstrieren, wird jedoch eine der Nachrichten implementiert. Der vierte Punkt stellt für uns zunächst eine Erleichterung dar. Der Server kennt nur momentan eingehende Anfragen und hat keine Kenntnis über vorangegangene Abläufe. Wie man einen Webserver dennoch mit einer Art Gedächtnis ausstatten kann, zeigt ein späterer Abschnitt über CGI.

Die Kommunikation zwischen Server und Client

Ein Blick in den HTTP-Standard vermittelt ein sehr detailliertes Bild über die zulässigen Nachrichtenformate und Zeichensätze. Zunächst stellt man fest, daß alle Nachrichten aus einem Header mit Steuerdaten und einem Body für die eigentlichen Nutzdaten bestehend aufgebaut sind. Die übertragenen Nutzdaten bezeichnet der Standard als Entität. Darüberhinaus läßt sich die Menge aller legalen Nachrichten in zwei Gruppen untergrliedern: Anfragen, die der Client an den Server richtet und Antworten, die der Server darauf entgegnet.

Anfragen

Mit einer Anfrage teilt ein Client dem Server mit, an welcher Ressource er interessiert ist und welche Aktion er mit ihr ausführen will. Eine Anfrage beschreibt also nicht mehr und nicht weniger als einen einzelnen Zugriff durch den Client. Folgende Aktionen werden durch HTTP definiert:

CONNECTAufbau einer Verbindung zu einem Proxy, der im Stande ist, einen Tunnel aufzubauen
DELETELöschen der angegebenen Ressource
GETÜbertragung einer Ressource zum Client
HEADEntspricht GET mit dem Unterschied, daß der Server keine Entität an die Nachricht anhängen darf. Kann verwendet werden, um Informationen über die betreffende Ressource zu gewinnen.
POSTAnzängen der enthaltenen Entität an die betreffende Ressource. Mögliche Verwendungszwecke wären das Einfügen einer Nachricht an ein Bulletin Board oder die Übertragung von Daten, bzw. Parametern zu einer Datenbank oder einem CGI-Skript.
PUTAblegen der angehängten Entität unter dem angegebenen Namen auf dem Server.
TRACEVeranlaßt den letzten Empfänger einer Anfrage, diese als Entität seiner Antwort an den Client zurückzusenden. Da die Anfragen des Clients über verschiedene Netzknoten wie Proxies oder andere Webserver geleitet werden können, erhöht sich die Wahrscheinlichkeit für Übertragungsfehler mit der Anzahl der zwischen Client und Server liegenden Rechner. Eine TRACE-Anfrage darf niemals zwischengespeichert werden und keine Entität beinhalten.
OPTIONSInformationen über Kommunikationsoptionen und Fähigkeiten des Servers

 

socket-server-bild-2
Hier ist der schematische Aufbau von HTTP-Anfragen und -Antworten zu sehen.

Die Request-Line zu Beginn des Anfragenheaders hat setzt sich neben der gewünschten Aktion aus dem URL (universal resource locator), mit dem die gewünschte Ressource bezeichnet wird, sowie der erwarteten HTTP-Version des Servers zusammen.

Neben der Request-Line kann eine Anfrage noch viele weitere Eigenschaften haben, die im Header zeilenweise – sprich: durch CRLF getrennt – aufgelistet sind. Zu diesen Headerfeldern kann etwa die bei einem GET-Request, wie ihn ein Webbrowser durchführt, die Angabe der durch den Client akzeptierten MIME-Typen gehören. MIME ist die Abkürzung für Multipurpose Internet Mail Extensions. Dieser Standard legt Bezeichner für im Internet gebräuchliche Medientypen fest. Diese Bezeichner setzen sich aus zwei Teilen zusammen, die jeweils einen Content-Typ und einen Subtyp beschreiben.

Die wichtigsten Mime-Typen sind:

Content-TypSubtypBeschreibung
textplain

richtext

html

Unformatiert

RTF-Text mit Attributen

HTML-formatierter Text

audiobasic8-Bit ISDN encoded
imagejpeg

gif

Jpg-Format

Compuserver GIF

videompegMPEG-Format
applicationpostscript

pdf

x-perl

x-httod-php

Postskript-Datei

Adobe PDF-Datei

Perlquelltext

PHP-Quelltext

Ein anderes Beispiel für Felder im Anfragenheader sind Anforderungen an die letzte Änderung der gewünschten Ressource oder ein Bezeichner des Clients. So teilen etwa Webbrowser über das Headerfeld „User-Agent“ ihre Produktbezeichnung und Versionsnummer mit, die dann wiederum durch CGI-Skripte ausgewertet werden kann. Auf diese Weise können HTML-Seiten auf bestimmte Browsermodelle zugeschnitten werden.

 

Ein GET-Request könnte das folgende Aussehen haben:

GET /docs/document.pdf HTTP/1.1

Host: webserver.org:80

Accept: text/*, image/*, application/pdf

Accept-Encoding: x-gzip, x-deflate, gzip, deflate, identity

Accept-Charset: iso-8859-15, utf-8;q=0.5, *;q=0.5

Accept-Language: de, DE, en

User-Agent: Mozilla/5.0 (compatible; Konqueror/3.1)

Pragma: no-cache

Cache-control: no-cache

Antworten

Der Aufbau einer Antwort gleicht dem der Anfrage. Lediglich die erste Zeile, die sogenannte Status-Line hat eine andere Struktur. Sie stellt den Rückgabewert der Clientanfrage dar.

Neben einer numerischen Mitteilung sind in der Status-Line eine Textmeldung, sowie die unterstützte HTTP-Version enthalten. Konnte die Anfrage ausgeführt werden, wird eine Antwort, ähnlich der obigen Anfrage, zum Client übertragen:

HTTP/1.1 200 OK

Date: Mon, 9-Feb-2004 20:33:52 GMT

Server: Apache/1.3

MIME-Version: 1.0

Content-Type: application/pdf

Last-Modified: Sun. 8-Feb-2004 01:47:51 GMT

Content-Length: 165001

Der Client -etwa ein Webbrowser – empfängt nun diese Nachricht. Anhand des Mimetyps entscheidet er, ob er in der Lage ist, den Inhalt zu interpretieren. Im Falle von PDF-Dokumenten ist es wohl in den meisten Fällen so, daß die Browser selbst dieses Format nicht verstehen. Ist jedoch ein sogenanntes Plugin, also eine externe Erweiterung installiert, wird die empfangene Datei an dieses Übergeben. Aus diesem Grund ist die Angabe dieses Headerfeldes unverzichtbar. Neben dem Mimetyp ist ebenfalls das Feld Content-Length wichtig. Beide Kommunikationspartner haben keine andere Möglichkeit, das Ende einer Übertragung zu erkennen, da beim Nachrichtenkörper kein Endekennzeichen verwendet wird.

Leider ist jedoch nicht jede Clientanfrage von Erfolg gekrönt. Für Fehler, die der Anwendungsschicht zuzuordnen sind, definiert HTTP eine Reihe von Werten, die dem Client Aufschluß über den Ausgang einer Anfrage geben.

1xxInformation
2xxErfolgreich
3xxAuswahlmöglichkeiten für den Client
4xxClientfehler
5xxServerfehler

So teilt etwa der bekannte Fehlercode 404 dem Cilent mit, daß die angeforderte Ressource auf dem Server nicht verfügbar ist. Wie bereits erwähnt, setzt sich die Statusmeldung des Servers neben einer der obigen Fehlernummern aus einer textlichen Meldung zusammen. Unser Webserver muß natürlich in ähnlicher Weise auf Fehlersituationen reagieren.

Aufbau des Webservers

Aus den im letzten Abschnitt skizzierten Abläufen zwischen Client und Server leiten wir nun eine mögliche Architektur für einen Webserver her. Das Protokoll kann an dieser Stelle natürlich nicht vollständig abgebildet werden. Vielmehr zielt dieser Abschnitt darauf ab, ein Gefühl zu vermitteln, wie komplexere Protokolle mit den einfachen Mitteln der Socket-API unter Verwendung einiger Unix-Systembefehlen systematisch umgesetzt werden können.

Den gesamten Informationsfluß im Server kann man in der Hauptsache in drei Schritte unterteilen, die für jeden Clientzugriff unternommen werden müssen.

  1. Analyse der Anfrage
  2. Ausführung des Befehls
  3. Senden der Antwort

Gemäß dem Protokoll müssen diese Schritte für jede angeforderte Datei durchgeführt werden. Besteht eine Website aus einer HTML-Datei mit zwei Bildern, werden drei Anfragen an den Webserver gesendet. Schon jetzt ist zu erkennen, daß dieser Server wesentlich länger mit einer einzelnen Anfrage beschäftigt sein wird, als der Echoserver aus dem letzten Abschnitt.

Das bedeutet für uns, daß ein blockierender Server von vorne herein nicht in Frage kommen kann.

Und auch bei der Entscheidung für eine der nichtblockierenden Architekturen ist die Wahl schnell getroffen. Erfahrungsgemäß muß ein Webserver eine hohe Anzahl an Zugriffen gleichzeitig verarbeiten und insbesondere die Reaktionszeit spielt eine entscheidende Rolle für die Nutzerakzeptanz. Hier scheint die Verwendung von einem Thread pro Anfrage am besten den Anforderungen zu genügen.

Im weiteren Verlauf dieses Abschnitts werden die wesentlichen Funktionen des Webservers, sowie einige Hilfsfunktionen, wenn angebracht, vorgestellt. Ausgehend von der main-Funktion werden wir Stück für Stück den Weg vom Empfang einer Anfrage bis zur Übertragung der Antwort verfolgen.

 

1               int main (int argc, char** argv) {

2               int port;

3               char *strFileName;

4               struct sockaddr_in saddr_listen;

5               pthread_t thr_id[1];

6               pthread_attr_t attr;

7

8               if (parse_cmd_args (argc, argv, &port, &strFileName) != OK) { exit (1); }

9               if (strFileName) { import_config (strFileName, TRUE); }

10

11            __init_address (&saddr_listen, INADDR_ANY, port);

12            __pthread_init (&attr);

13

14            fdListen = __socket (AF_INET, SOCK_STREAM, 0);

15            __bind (fdListen, (struct sockaddr *) &saddr_listen, sizeof  (saddr_listen));

16            __listen (fdListen, WAITQUEUE);

17

18            printf („%slistening on port %i\n“, PROMPT, port);

19

20            buildMimeTypeList (&mime);

21            printf („%sstandard-mimehandler established\n“, PROMPT);

22

23            for(;;) {

24                 remote = (REMOTEADDR_TP *) malloc (sizeof (REMOTEADDR_TP));

25                 memset (remote, 0, sizeof (REMOTEADDR_TP));

26                 remote->fd = __accept (fdListen, (struct sockaddr *) &(remote->saddr),   &(remote->len));

27                 printf („%saccepted client %s\n“, PROMPT, inet_ntoa (remote- >saddr.sin_addr.s_addr));

28                 if (pthread_create (&thr_id[0], &attr, THREADFUNC_TP serve_client,  (void*)remote)) {

29                              printf („error %i happened on creating thread\n“, errno);

30                 }

31            }

32

33            pthread_attr_destroy (&attr);

34            close (fdListen);

35            return 0;

36            }

Als erster fällt hier, im Gegensatz zu den obigen Beispielen, die Berücksichtigung von Kommandozeilenargumenten auf. Die Funktion parse_cmd_args wertet diese Argumente aus und ermittelt daraus zum einen die Nummer des Ports, an dem der Server horchen soll und zum anderen den Namen einer Konfigurationsdatei, die optional angegeben werden kann. Ist dies der Fall, wird im nächsten Schritt mit einem Aufruf von import_config die entsprechende Datei, sofern vorhanden, eingelesen. Hier wurde eine Umsetzung in Form von Name=Wert-Paaren gewählt, die zeilenweise in der Datei getrennt vorliegen und in Umgebungsvariablen gespeichert werden. Natürlich sind auch andere Möglichkeiten denkbar. In der nächsten Zeile werden – wie in den vorangehenden Beispielen auch – Adressstrukturen und Threadattribute initialisiert. Im Anschluß erfolgt die Einrichtung des horchenden Sockets und der Prozeß meldet sich auf der Kommandozeile. Bevor der Server in die Endlosschleife eintritt, wird noch die Funktion buildMimeTypeList aufgerufen. Sie ist ebenfalls eine Neuerung gegenüber dem Echoserver.

Wie bereits erwähnt, muß ein Webserver in der Lage sein, Anfragen nach den unterschiedlichsten Dateiformaten bedienen zu können. Unter Umständen bestehen diese Anfragen nicht nur aus der Übertragung einer Datei – vielmehr müssen möglicherweise bestimmte Operationen auf diesen Dateien ausgeführt werden. So kann ein Client etwa die Ausführung mancher Dateien veranlassen. Idealerweise sollte der Webserver also die Fähigkeit haben, auf jeden Dateityp in einer eigenen Weise zu reagieren. Diese Anforderung erfüllt nun eine MIME_TYPE genannte Struktur.

typedef struct mime_type {

char *type;

char *suffixes;

int (*action) (struct mime_type *, HTTP_REQUEST *, HTTP_RESPONSE *);

struct mime_type *next;

} MIME_TYPE;

Sie stellt ein Element einer einfach verketteten Liste dar, das den folgenden Aufbau hat:

Zunächst ist ein Typbezeichner integriert, der beschreibt, um welchen MIME-Typ es sich handelt. Die zweite Komponente ist eine Zeichenkette, die alle zu diesem MIME-Typ gehörigen Suffixe durch Leerzeichen getrennt beinhaltet. So kann überprüft werden, von welchem Typ eine angeforderte oder übertragene Datei ist. Um einen Hauch von Objektorientierung mit hinein zu bringen, enthält die Struktur gleich einen Zeiger auf die dem MIME-Typ zugewiesene Funktion. Diese soll neben dem eigenen Typ Zeiger auf die Clientanfrage, sowie die zu beschreibene Antwortstruktur des Servers erhalten. Der letzte Zeiger dient lediglich zum Verketten der einzelnen Listenknoten.

Die Funktion buildMimeTypeList hat also die Aufgabe, eine Liste mit Standardhandlern – etwa für HTML- oder Perl-Dateien – aufzubauen. Denkbar wäre an dieser Stelle eine Erweiterung dahingehend, daß zusätzliche Handler beim Start des Servers hinzugelinkt werden können.

Was man beim Betrachten der Endlosschleife noch vermissen könnte, ist eine Möglichkeit, den Webserver zu beenden. Im Gegensatz zu den Echoservern werden hier umfangreichere Ressourcen reserviert werden müssen, so daß man dem Server Gelegenheit geben muß, diese Reservierungen wieder freizugeben. Wir integriert man nun eine Schnittstelle, über die ein Administrator Steuerbefehle absetzen kann? Die Socketverbindung wäre zwar ein sehr komfortabler Weg, könnte allerdings zum Angriffspunkt unbefugter Zugriffe werden. Es muß also ein zusätzlicher Eingang geschaffen werden, etwa über die Kommandozeile. Hier steht man alledings vor dem Problem, daß der Server nun zwei Stellen hat, an denen er blockiert. Sowohl der Lesezugriff auf den Socket als auch der auf das Terminal sind von Haus aus blockierend ausgelegt. Die Möglichkeit, beide nichtblockierend kontinuiertlich abzufragen disqualifiziert sich von vorne Herein, da hier eine enorme Prozessorlast erzeugt würde. Benötigt wird also die Möglichkeit, an zwei Stellen gleichzeitig auf ein Ereignis zu warten. Dies wird durch die Funktion select ermöglicht, die den folgenden Kopf hat:

Int select (int maxfd, fd_set *read_set, fd_set *write_set, fd_set *except_set, const struct timeval *timeout);
maxfdMaximale Länge der drei folgenden Felder
*read_setFeld von Deskriptoren, bei denen auf Lesefreigabe gewartet wird
*write_setFeld von Deskriptoren, bei denen auf Schreibfreigabe gewartet wird
*except_setFeld von Deskriptoren, bei denen auf die Ankunft einer Ausnahme gewartet wird.
*timeoutMaximale Wartezeit
Return>0die Anzahl der bereiten Deskriptoren
0Timeout
-1Fehler

Der Funktion wird eine Menge von Deskriptoren und eine Zeitspanne übergeben. Wird in den Komponenten von *timeout 0 angegeben, kehrt die Funktion sofort zurück. Ansonsten wird für die angegebene Zeit, oder bei Übergabe von NULL unbegrenzt gewartet. Die Verwaltung der Parameter vom Typ fd_set geschieht durch die vier Funktionen:

void FD_ZERO (fd_set *fdset);

void FD_SET (int fd, fd_set **fdset);

void FD_CLR (int fd, fd_set **fdset);

int FD_ISSET (int fd, fd_set **fdset);

Der Typ fd_set ist kein Feld im herkömmelichen Sinne, vielmehr werden die einzelnen Deskriptoren in Form von Bits abgebildet. Daher ist ein Zugriff nur durch die vier Hilfsfunktionen zu empfehlen. Mit ihrer Hilfe können die Parameter für den Aufruf von select vorbereitet und anschließend die Verfügbarkeit der Deskriptoren getestet werden. Der Grund, warum mit maxfd die maximale Anzahl trotzdem mit übergeben werden muß, ist die Performanz. Die theoretische Obergrenze liegt bei 1024 Deskriptoren, die andernfalls bei jedem Aufruf dreimal überprüft werden müssten.

In unserem konkreten Fall lassen wir durch select zwei Deskriptoren auf ihre Lesebereitschaft überprüfen. Dazu muß der Code der Endlosschleife wie folgt abgeändert werden:

1               int main (int argc, char **argv) {

2               /*…*/

3               int maxfd;

4               fd_set rset;

5               char buffer[STRLEN];

6

7               FD_ZERO (&rset);

8               for(;;) {

9                    /*…*/

10                 FD_SET (fdListen, &rset);

11                 FD_SET (STDIN_FILENO, &rset);

12                 maxfd = max (fdListen, STDIN_FILENO) + 1;

13                 printf („%s“, PROMPT);

14                 if (select (maxfd, &rset, NULL, NULL, NULL) < 0) {

15                              fprintf (stderr, „error %i on performing select!\n“, errno);

16                              exit (1);

17                 }

18                 if (FD_ISSET (fdListen, &rset)) {

19                              remote->fd = __accept (fdListen,

(struct sockaddr *) &(remote->saddr), &(remote->len));

20

21                              if (pthread_create (&thr_id[0], &attr, THREADFUNC_TP serve_client,  (void *)remote)) {

22                                   printf („error %i happened on creating thread\n“, errno);

23                              }

24                 }

25                 if (FD_ISSET (STDIN_FILENO, &rset)) {

26                              fgets (buffer, STRLEN, stdin);

27                              if (!strcasecmp (buffer, „quit“)) {

28                                                break;

29                              }

30                 }

31            }

32            pthread_attr_destroy (&attr);

33            close (fdListen);

34            return 0;

35            }

Nun haben wir erreicht, daß der Serverprozeß auf zwei Ereignisse gleichzeitig wartet (Zeile 14). Es spielt nun keine Rolle mehr, ob auf dem Socket oder auf der Kommandozeile Daten anliegen, die gelesen werden können. Der Prozeß wird in beiden Fällen geweckt und überprüft anschließend in den Zeilen 18 bis 24 und 25-30, wo er lesen kann, ohne zu blockieren. Dadurch wird der Server in die Lage versetzt, sowohl auf eingehende Verbindungswünsche aus dem Netzwerk als auch auf lokale Eingaben von einem Administrator unmittelbar reagieren zu können, was mit zwei hintereinanderliegenden blockierenden Funktionen nicht möglich ist. Die Interpretation der Kommandozeilen-Eingaben kann natürlich beliebig komplex ausfallen. Prinzipiell könnte man an dieser Stelle eine Art Shell etablieren, die umfangreichere Einstellmöglichkeiten oder Statusabfragen zur Laufzeit ermöglicht. So könnte man etwa einen Messungen über die Serverauslastung durchführen, zu denen jeder Thread Beiträge liefert. Über ein lokales Terminal wäre dann zu beliebiger Zeit eine Ausgabe möglich.

Nachdem nun die Vorarbeit getan ist, tritt der Server in die bekannte Endlosschleife ein und wartet auf   Verbindungsanfragen. Wie vereinbart, wird für jede eingehende Verbindung ein eigener Thread kreiert, der mit einer privaten Adressstruktur vom Typ REMOTEADDR_TP parametriert wird. Der interessante Teil spielt sich also in der Threadfunktion serve_client ab.

1               void serve_client (REMOTEADDR_TP *lremote) {

2                  int len, i = 0;

3                  int result = 0;

4                  char buffer[MAXLINE];

5                  HTTP_REQUEST *request;

6                  HTTP_RESPONSE *response;

7

8                  initialize_structs (&request, &response);

9                  request->remote = lremote;

10               getHostName (request->remote, &(request->remote->name));

11

12               result = parse_request (request);

13               prepare_response (request, response);

14               send_response (lremote->fd, response, request);

15

16               dispose_request (request);

17               dispose_response (response);

18               close (lremote->fd);

19               free (lremote);

20            }

Der Thread beginnt seine Arbeit mit der Initialisierung der beiden Strukturen HTTP_REQUEST und HTTP_RESPONSE. Diese stellen für alle durch den Standard geforderten Headerfelder Komponenten in Form von Zeichenketten bereit.

typedef struct {

char *accept;

char *accept_charset;

char *accept_encoding;

char *accept_language;

/* … */

char *user_agent;

char *method;

char *rawcookie;

char *user;

int status;

URL *url;

HTTP_GENERAL *gen;

HTTP_ENTITY *ent;

HTTP_COOKIE *cookie;

REMOTEADDR_TP *remote;

} HTTP_REQUEST;

 

typedef struct {

char *accept_ranges;

char *age;

char *date;

/* … */

char *status;

char *www_authenticate;

HTTP_GENERAL *gen;

HTTP_ENTITY *ent;

HTTP_COOKIE *cookie;

} HTTP_RESPONSE;

Nach Abschluß dieser Initialisierung nimmt der Thread Kontakt zum Client auf, genauer: er nimmt die vom Client übertragene Anfrage entgegen und analysiert sie. Dies geschieht in der Funktion parse_request, die wir uns gleich auszugsweise ansehen:

1               int parse_request (HTTP_REQUEST *request) {

2                  char buffer[BUFLEN];

3                  char *header = NULL, *ptr = NULL, *ptr2 = NULL, *ptr3 = NULL;

4                  int len = 0;

5                  int i = 0;

6                  int method = 0;

7                  HTTP_COOKIE *tmp;

8

9                  while ( (len = readLine (request->remote->fd, buffer, BUFLEN)) != -1) {

10                              if (!strncmp (buffer, „\r\n“, 2)) break;

11

12                              buffer[strlen(buffer)-2] = ‚\0‘;

13                              if (split(buffer, strlen (buffer), “ „, &header, &ptr) < 0) {

14                                 request->status = HTTP_BAD_REQUEST;

15                                 return HTTP_BAD_REQUEST;

16                              } else if (!strcasecmp (header, „CONNECT“) ||

17                                                   !strcasecmp (header, „DELETE“) ||

18                                                   !strcasecmp (header, „GET“) ||

19                                                   !strcasecmp (header, „HEAD“) ||

20                                                   !strcasecmp (header, „POST“) ||

21                                                   !strcasecmp (header, „PUT“) ||

22                                                   !strcasecmp (header, „OPTIONS“) ||

23                                                   !strcasecmp (header, „TRACE“)) {

24                                 if (split (ptr, strlen (ptr), “ „, &ptr2, &ptr3) < 0) {

25                                                request->status = HTTP_BAD_REQUEST;

26                                                return HTTP_BAD_REQUEST;

27                                 }

28

29                                 if (parse_url (ptr2, strlen(ptr2), &(request->url)) != URL_OK) {

30                                                request->status = HTTP_BAD_REQUEST;

31                                                return HTTP_BAD_REQUEST+3000;

32                                 }

33

34                                 set_string (&(request->method), header);

35                                 set_string (&(request->gen->version), ptr3);

36                                 method++;

37                              } else if (!strcasecmp (header, „Accept:“)) {

38                                 set_string (&(request->accept), ptr);

39                              } else if (!strcasecmp (header, „Accept-Charset:“)) {

40                                 set_string (&(request->accept_charset), ptr);

41                              } else if (!strcasecmp (header, „Accept-Encoding:“)) {

42                                 set_string (&(request->accept_encoding), ptr);

43                              } else if (!strcasecmp (header, „Accept-Language:“)) {

44                                 set_string (&(request->accept_language), ptr);

45                              } else if (!strcasecmp (header, „Authorization:“)) {

46                                 set_string (&(request->authorization), ptr);

47                              } else if (!strcasecmp (header, „Expect:“)) {

48                                 set_string (&(request->expect), ptr);

49                              } else if (!strcasecmp (header, „From:“)) {

50                                 set_string (&(request->from), ptr);

51                              }

52

53                              /* … */

54               }             /* while */

55

56               if (request->ent->content_length) {

57                              if (request->ent->content = (char *) malloc (sizeof (char) * atoi  (request->ent->content_length))) {

58                                 if (read (request->remote->fd, request->ent->content,

59                                                atoi (request->ent->content_length)) < 0) {

60                                                free (request->ent->content);

61                                                free (request->ent->content_length);

62                                                request->ent->content = NULL;

63                                                request->ent->content_length = NULL;

64                                 }

65                              } else {

66                                 request->ent->content_length = NULL;

67                              }

68               }

69

70               if (method == 1) {

71                              request->status = HTTP_OK;

72                              return HTTP_OK;

73               } else {

74                              request->status = HTTP_BAD_REQUEST;

75                              return HTTP_BAD_REQUEST;

76               }

77            }

Die Funktion liest fortlaufend zeilenweise von dem mit dem Client verbundenen Socket (Zeile 9). Die Schleife wird erst unterbrochen, wenn eine leere Zeile erkannt wird (Zeile 10). Bevor die eigentliche Analyse der Clientanfrage beginnt, werden die die Zeichenkette terminierenden beiden letzten Zeichen (\r\n) entfern (Zeile 12). Jetzt ist der empfangene String bereit, untersucht zu werden. Jede im Sinne von HTTP legale Zeile einer Anfrage besteht zumindest aus zwei durch ein Leerzeichen getrennten Worten. Der Inhalt des Empfangspuffers wird also an der Position dieses Leerzeichens getrennt und in zwei neue Zeichenketten aufgespalten. Das übernimmt die Funktion split, die als Rückgabe zwei neu allokierte Zeigerlisten vom Typ char liefert. Ist kein Leerzeichen enthalten, kann der weitere Empfang der Anfrage abgebrochen und eine passende Fehlermeldung an den Client übertragen werden (Zeile 15). Im Fall einer korrekten Zeile wird nun der Inhalt des ersten Wortes mit einer Reihe erwarteter Werte verglichen. (Zeilen 16-51). Bei jedem Treffer wird die zweite Hälfte des Empfangspuffers in die entsprechende Komponente einer HTTP_REQUEST-Struktur kopiert.

Dieser Vorgang setzt sich so lange fort, bis schließlich eine leere Zeile eingeht. Damit ist das Ende des Headers erreicht. Falls der Client Daten an den Server übermitteln will, muß er im Header über das Feld Content-Length die zu übertragende Menge mitgeteilt haben. In diesem Fall wird ein weiterer Empfangsvorgang gestartet (Zeilen 56-67). Tritt kein Fehler auf, finden sich die übertragenen Nutzdaten in der Komponente ent->content der HTTP_REQUEST-Struktur wieder. Bevor die Funktion terminiert findet noch eine Überprüfung statt, ob tatsächlich nur eine einzige Requestline eingegangen ist und es wird HTTP_OK oder HTTP_BAD_REQUEST zurückgegeben.

Wir befinden uns nun in Zeile 13 von serve_client und die eben mit Werten gefüllte Struktur request wird an die nächste Funktion, prepare_response, übergeben. Hier wird eine HTTP_RESPONSE-Struktur für die Übertragung an den Client vorbereitet. Nachdem der Client uns mitgeteilt hat, was sein Anliegen ist, kann nun die Umsetzung beginnen.

1               int prepare_response (HTTP_REQUEST *request, HTTP_RESPONSE *response) {

2                  char tmp[255];

3                  int typeResult = 0;

4                  int result = 0;

5                  MIME_TYPE *type;

6

7                  set_string (&(response->gen->version), request->gen->version);

8

9                  if (strcmp (response->gen->version, SERVERPROTOCOL)>0) {

10                              request->status = HTTP_VERSION_NOT_SUPPORTED;

11               }

12

13               buildDate (&response->gen->date);

14               set_string (&(response->server), SERVERNAME);

15

16               if (request->status < 400) {

17                              if (!strcmp (request->method, „CONNECT“)) {

18                                 result = handle_connect (request, response);

19                              } else if (!strcmp (request->method, „DELETE“)) {

20                                 result = handle_delete (request, response);

21                              } else if (!strcmp (request->method, „GET“)) {

22                               result = handle_get (request, response);

23                              } else if (!strcmp (request->method, „HEAD“)) {

24                                 result = handle_head (request, response);

25                              } else if (!strcmp (request->method, „OPTIONS“)) {

26                                 result = handle_options (request, response);

27                              } else if (!strcmp (request->method, „POST“)) {

28                                 result = handle_post (request, response);

29                              } else if (!strcmp (request->method, „PUT“)) {

30                                 result = handle_put (request, response);

31                              } else if (!strcmp (request->method, „TRACE“)) {

32                                 result = handle_trace (request, response);

33                              }

34

35                              switch (result) {

36                                 case NOTFOUND:

37                                                request->status = HTTP_NOT_FOUND; break;

38                                 case ERROR:

39                                 request->status = HTTP_INTERNAL_SERVER_ERROR; break;

40                                 case OK: break;

41                                 default:

42                                                request->status = HTTP_BAD_REQUEST; break;

43                              }

44               }

45

46               if (request->status >= 400) {

47                              snprintf (tmp, 4, „%i“, request->status);

48                              set_string (&(response->status), tmp);

49                              if (getErrorPage (request->status, &(response->ent->content),  &(response->ent->content_length)) != OK) {

50                                 sprintf (tmp, „ciritical error %i on creating error page\n“,  errno);

51                                 print_error (errno, tmp, strlen (tmp));

52                                 return ERROR;

53                              } else {

54                                 set_string (&(response->ent->content_type), „text/html“);

55                                 return OK;

56                              }

57               }

58

59               return OK;

60            }

 

Während in parse_request Syntax und Struktur der Anfrage auf Korrektheit geprüft wurden, findet hier ein semantischer Test statt, sowie der Ausführung der vom Client beantragten Methode statt. Man erkennt die Trennung zwischen allgemeinen Headerfeldern der Antwort (Zeilen 7-14) und der auf die Anfrage zugeschnittenen Entität, die von speziellen Methodenhandlern generiert wird (Zeilen 17-33). Tritt während der Verarbeitung ein Fehler auf, wird wieder ein Fehlercode vermerkt und die Übertragung einer entsprechenden Fehlerseite veranlaßt (Zeilen 46-56).

 

Als Beispiel für die Behandlung einer HTTP-Methode soll hier GET dienen.

1      int handle_get (HTTP_REQUEST *request, HTTP_RESPONSE *response) {

2         char tmp[STRLEN];

3

4         snprintf (tmp, 4, „%i“, request->status);

5         set_string (&(response->status), tmp);

6

7         if ( (typeResult = getContentType (request->url->file, &type)) == OK) {

8             result = type->action (type, request, response);

9             return OK;

10        } else if (typeResult == NOTFOUND) {

11            result = getRawFile (type, request, response);

12            return NOTFOUND;

13        } else {

14            request->status = HTTP_BAD_REQUEST;

15            return ERROR;

16        }

17     }

Zuerst wird der Status der vorgangegangenen Analyse der Anfrage in die Antwort integriert (Zeilen 4 und 5). Die eigentliche Arbeit der Funktion beginnt aber mit der Feststellung des MIME-Typs der angeforderten Ressource. Dies wird von der Funktion getContentType durchgeführt (Zeile 7), die die Liste der MIME-Typen durchläuft und jeden Eintrag mit der Dateiendung der vom Client gewünschten Datei vergleicht. Da diese Funktion ebenfalls sehr kurz ist, können wir sie uns an dieser Stelle ansehen:

1      int getContentType (char *file, MIME_TYPE **type) {

2         char *suffix;

3         int ret = NOTFOUND;

4         MIME_TYPE *ptr;

5

6         *type = NULL;

7         if (suffix = strchr (file, ‚.‘)) {

8             pthread_mutex_lock (&mtMimeTypes);

9             ptr = mime;

10            for (;ptr; ptr=ptr->next) {

11               if (isMimeSuffix (ptr, suffix+1) == OK) {

12                    *type = (MIME_TYPE *) malloc (sizeof (MIME_TYPE));

13                    memcpy (*type, ptr, sizeof (MIME_TYPE));

14                    ret = OK;

15                    break;

16               }

17            }

18            pthread_mutex_unlock (&mtMimeTypes);

19        }

20        return ret;

21     }

Diese Funktion stellt insofern eine Besonderheit dar, als daß hier zum ersten Mal ein Zugriff auf eine globale Ressource, sozusagen über Threadgrenzen hinweg, geschieht. Gemeint ist die globale Liste der MIME-Handler. Da Threads quasi parallel (auf einem Mehrprozessorsystem sogar echt parallel) ablaufen, kann der Fall eintreten, daß zwei zur selben Zeit auf die gleiche Adresse im Speicher zugreifen. In einer solchen Situation ist die Konsistenz der betreffenden Speicherstelle nicht mehr gewahrt. Zwar ist dies bei einem gleichzeitigen Lesezugriff durch mehrere Threads kein Problem, sobald jedoch ein schreibender Zugriff erfolgt, ist das Resultat nicht mehr definiert. Man spricht hier von einem kritischen Bereich. Um dies zu verhindern, stellt praktisch jede Thread-Implementierung dem Programmierer Sperr- oder Synchronisationsmechanismen zur Verfügung. Im Falle der Posixthreads handelt es sich hierbei um sogenannte Mutexe, was die Abkürzung für Mutual Exclusion, also gegenseitiger Ausschluß ist.

Für den Umgang mit Mutexen gibt es zahlreiche Funktionen, von denen pthread_mutex_lock und pthread_mutex_unlock zweifelsohne die wichtigsten sind. Die Wirkung ist die folgende: durch die Funktionen werden folgende Operationen atomar auf dem Mutex ausgeführt. Zunächst wird der Zustand, der entweder „gesperrt“ oder „nicht gesperrt“ sein kann, getestet. Ist der Mutex noch nicht gesperrt, wird im zweiten Schritt diese Sperre gesetzt und der Thread kann ungehindert fortfahren. Hat jedoch ein anderer Thread den Mutex bereits gesperrt, wird der aufrufende Thread blockiert. In diesem Zustand verweilt er so lange, bis der Thread im kritischen Bereich diesen durch Aufruf von pthread_mutex_unlock wieder verlassen hat. Wichtig bei der Festlegung von kritischen Bereichen sind die folgenden Überlegungen. Zunächst darf immer nur ein einziger Thread zur selben Zeit im kritischen Bereich sein. Dann muß sichergestellt sein, daß dieser nach endlicher Zeit auch wieder verlassen wird. Es darf also nicht passieren, daß der Thread in eine Endlosschleife gerät oder beendet wird, bevor der Mutex wieder freigegeben wird. Die dritte Forderung besagt, daß sichergestellt sein muß, daß jeder Thread nach endlicher Wartezeit in den kritischen Bereich eintreten darf. Bei der Arbeit mit Threads, bzw. Prozessen, für die die obigen Aussagen ebenfalls gelten, ist immer Vorsicht geboten. Nicht immer lassen sich die Konstellationen der einzelnen Sperren so einfach überblicken und schnell kann der Fall eintreten, daß mehrere Sperren gleichzeitig gehalten und nicht mehr freigegeben werden können. Man spricht dann von einem Deadlock. Die Schwierigkeit an solchen Problemen ist, daß sie häufig sehr zufällig auftreten und daher nur schwer aufzuspüren sind. Insbesondere die Freiheit von diesen Fehlern läßt sich für ein Programm nur sehr schwierig oder gar nicht nachweisen.

Da wir bei der Funktion getContentType nur einen lesenden Zugriff auf die Liste der MIME-Typen durchführen, ist die Frage gerechtfertigt, ob ein Schutz überhaupt notwendig ist. Solange garantiert keine Änderung an der Liste vorgenommen wird, kann man die Frage sogar verneinen. Im Hinblick auf zukünftige Weiterentwicklungen des gesamten Programms, sollte man sich auf solche Überlegungen jedoch nicht einlassen. Vorstellbar wäre, daß in einer späteren Version die Liste der Mimehandler auch zur Laufzeit erweitert werden kann. Dann kann eine solch leichtsinnige Annahme unter Umständen schwerwiegende Konsequenzen haben – nicht zuletzt, da beim Auftreten eines solchen Fehlers nicht unmittelbar ersichtlich sein muß, an welcher Stelle man eine Änderung vornehmen sollte.

Was die getContentType nun im einzelnen tut, ist rasch erklärt: jeder Mimehandler wird zusammen mit einer Liste der Endungen der zugehörigen Dateien angespeichert. Für jeden Handler muß also der übergebene Dateiname mit dieser Liste verglichen werden. Das geschieht in der Funktion isMimeSuffix. Bei einem Treffer wird der entsprechende Handler kopiert und die Funktion kehrt mit OK zurück. Andernfalls ist das Resultat NOTFOUND.

Nach der erfolgreichen Rückkehr von getContentType wird nun der übergebene Mimehandler aufgerufen. In den meisten Fällen beschränkt sich die notwendige Aktion auf ein Anhängen einer angeforderten Datei an die HTTP_RESPONSE-Struktur. Es gibt aber auch MIME-Typen, die die Interpretation einer Datei mit anschließender Übermittlung des Ergebnisses verlangen. Aus Sicht von handle_get spielt das jedoch keine Rolle. Mit dem Aufruf des passenden Mimehandlers wird der Inhalt der Serverantwort generiert und anschließend an die aufrufende Funktion, prepare_response, zurückgegeben.

Bevor prepare_response nun verlassen und die Serverantwort an eine Sendefunktion übergeben werden kann, findet ein weiteres Mal eine Überprüfung eines Statuswertes statt (Zeile 46). Trat während der Verarbeitung der Anfrage ein Fehler auf, kann dies nun in Form einer entsprechenden Fehlerseite dem Client mitgeteilt werden. Für den Fall, daß auch dies fehlschlägt, bleibt dem Server nichts, als eine Fehlermeldung lokal auszugeben. Offensichtlich ist liegt ein schwerwiegender Fehler vor, oder es sind keine Fehlerseiten aufzufinden. In beiden Fällen muß der Administrator des Servers eingreifen.

Natürlich könnten Fehlerseiten auch problemlos durch das Programm zur Laufzeit generiert werden. Der einfachere Weg ist jedoch das Laden einer fertigen Datei, die noch dazu unabhängig vom Programmquelltext verändert werden kann.

Nach der Rückkehr aus prepare_response, finden wir uns in Zeile 14 von server_client und damit beim Aufruf von send_response wieder.

1      int send_response (int sock, HTTP_RESPONSE *resp, HTTP_REQUEST *req) {

2         HTTP_COOKIE *tmp;

3         char crlf[] = „\r\n“;

4         char buffer[STRLEN];

5         char buffer2[STRLEN];

6

7         if (resp->gen->version) {

8             send (sock, „Version:“, strlen („Version:“), 0);

9             send (sock, req->gen->version, strlen (req->gen->version), 0);

10        }

11

12        send (sock, “ Message:“, strlen (“ Message:“), 0);

13        send (sock, resp->status, strlen (resp->status), 0);

14        switch (req->status) {

15            case HTTP_OK: send (sock, „OK“, strlen („OK“), 0);

16               break;

17            case HTTP_NO_CONTENT: send (sock, „No Content“, strlen („No Content“), 0);

18               break;

19            case HTTP_BAD_REQUEST: send (sock, „Bad Request“, strlen („Bad Request“), 0);

20               break;

21            /*…*/

22        }

23

24        send (sock, crlf, strlen (crlf), 0);

25

26        if (resp->server) {

27            send (sock, „Server:“, strlen („Server:“), 0);

28            send (sock, resp->server, strlen (resp->server), 0);

29            send (sock, crlf, strlen (crlf), 0);

30        }

31

32        if (resp->gen->date) {

33            send (sock, „Date:“, strlen („Date:“), 0);

34            send (sock, resp->gen->date, strlen (resp->gen->date), 0);

35            send (sock, crlf, strlen (crlf), 0);

36        }

37

38        if (resp->ent->content_type) {

39            send (sock, „Content-type:“, strlen („Content-type:“), 0);

40            send (sock, resp->ent->content_type, strlen (resp->ent->content_type), 0);

41            send (sock, crlf, strlen (crlf), 0);

42        }

43

44        /*…*/

45        send (sock, crlf, strlen (crlf), 0);

46        if (resp->ent->content) {

47            send (sock, resp->ent->content, strlen (resp->ent->content), 0);

48            send (sock, crlf, strlen (crlf), 0);

49        }

50

51        return OK;

52     }

Hier findet die Übertragung der Komponenten der HTTP_RESPONSE-Struktur per Socket an den Client statt. Man kann erkennen, wie die Statusline zusammengesetzt wird. (Zeilen 7-22) Durch \r\n wird der Header vom Rest der Nachricht getrennt (Zeile 45). Nach der Rückkehr zu server_client werden noch lokal allokierte Strukturen freigegeben (Zeilen 16-19) und der Socketdeskriptor geschlossen. Die Verbindung zum Client ist beendet und der Thread terminiert.

Das Common Gateway Interface

In der Hauptsache sind Webserver auf das Einlesen und Zustellen von Dateien zugeschnitten. Dennoch gibt es Anwendungen, bei denen diese einfachen Mechnismen nicht mehr ausreichend sind. HTML-Seiten sind – für sich betrachtet – statisch und eine Anpassung, etwa an bestimmte Benutzergruppen, ist nur mit großem Aufwand zu realisieren. Man stelle sich vor, eine Seite solle unter anderem das aktuelle Tagesdatum enthalten oder gar den Inhalt einer Datenbanktabelle darstellen. Natürlich kann man solche Aufgaben mit Hilfe konventioneller Programme lösen. Aber spätestens wenn eine echte Interaktion zwischen Client und Server verlangt ist, wird eine solche Lösung unbefriedigend. Erweitert man den Ansatz programmierter Seiten dahingehend, daß die dynamische Erstellung der Seite unmittelbar auf Anfrage geschehen kann, kommt man einer eleganten Lösung schon sehr nahe. Berücksichtigt man noch die Möglichkeit, die Seitenerstellung durch die Clientanfrage zu parametrieren, hat man die Grundlage für interaktive Anwendungen geschaffen, in denen HTML-Seiten lediglich die Schnittstelle zum Benutzer bilden.

Ein solcher Mechanismus ist in den bekannten Webservern unter dem Namen Common Gateway Interface, kurz: CGI umgesetzt. CGI hat den Hintergrund, HTML-Seiten durch Programme auf Anfrage generieren zu lassen und beschreibt sowohl die Umgebung dieser Programme als auch die Übergabe von Parametern.

Um die Abläufe zu verdeutlichen, sehen wir uns zunächst den Weg einer Clientanfrage an. Um dem Client und insbesondere dem Anwender die Möglichkeit zur Paramterübergabe zu geben, definiert der HTML-Standard Formularfelder, die neben einem Inhalt über einen Namen verfügen. Dem Anwender stellt sich dies wie folgt dar:

 

socket-server-bild-3
CGI-Formular

Betrachten wir nun den Quelltext der Seite:

1      <html>

2      <head>

3      <title>CGI-Formular</title>

4      </head>

5      <body>

6      <form action=“http://webserver.org/cgi-bin/skript.pl“ method=“GET“>

7      <input type=“text“ name=“Feld1“ value=“Inhalt1“>

8      <input type=“text“ name=“Feld2“ value=“Inhalt2“>

9      <input type=“submit“ name=“Abschnicken“>

10     <input type=“reset“ name=“Inhalt l&ouml;schen“>

11     </form>

12     </body>

13     </html>

Die Zeilen 6 bis 11 definieren ein HTML-Formular, daß sich aus zwei Textfeldern zusammensetzt. Jedes dieser Felder hat einen Bezeichner, ähnlich einem Variablennamen und optional einen vordefinierten Inhalt, der vom Benutzer geändert werden kann. Der Typ interessiert uns vorerst nicht weiter, da er nur die äußere Form des Formularelements beeinflußt. HTML definiert auch mehrzeilige Textfelder, sowie Listen, Check- und Radiobuttons – die Übergabe der Inhalte erfolgt jedoch immer nach dem gleichen Prinzip. Eine Ausnahme bilden die beiden unteren Felder mit den Typen submit und reset. Sie dienen dazu, die Clientanfrage loszuschicken, bzw. die Inhalte der Formularfelder zu löschen. Der Name des zweiten Feldes ist kein Druckfehler: HTML versteht weder Umlaute noch einige andere Sonderzeichen, weshalb man sich mit sogenannten HTML-Entitäten behelfen muß. Das Kürzel &ouml; hat die Bedeutung von “o (klein) Umlaut” – wichtig ist das führende &, sowie das Semikolon am Ende.

Was außer der Angabe der Felder wichtig ist, sind die beiden Attribute action und method des <form>-Tags. Mit ihrer Hilfe werden das Ziel, also das CGI-Programm, das unsere Formularinhalte als Parameter erhalten soll, sowie die Art der Parameterübergabe festgelegt. Hier handelt es sich um ein Perlskript, das sich im Verzeichnis cgi-bin auf unserem Webserver befindet. Das GET an dieser Stelle ist übrigens das gleiche, welches wir schon kennengelernt haben. Die Zusammenhänge werden hier sofort erkennbar, sobald wir uns die weiteren Abläufe ansehen.

Mit einem Klick auf den Absenden-Button des Formulars wird der Browser veranlaßt, eine HTTP-Anfrage abzusenden. Ziel der Anfrage ist der im action-Attribut des Formulars angegebene URL. Genau genommen ist das Ziel zunächst der im URL enthaltene Host, bzw. der Server, von dem die Seite stammt. Dieser nimmt die Anfrage entgegen und leitet relevante Teile davon an die im zweiten Teil des URL angegebene Programmdatei weiter. Der Typ dieser Datei spielt an dieser Stelle keine Rolle, wichtig ist nur, daß sie ausgeführt werden kann und darf – es kann sich also sowohl um eine Binärdatei als auch ein Skript handeln.

1               GET /cgi-bin/skript.pl?Feld1=Inhalt1&Feld2=Inhalt2&submit=Submit HTTP/1.1

2               Connection: Keep-Alive

3               User-Agent: Mozilla/5.0 (compatible; Konqueror/3.1)

4               Referer: http://webserver.org:8080/index.html

5               Pragma: no-cache

6               Cache-control: no-cache

7               Accept: text/*, image/jpeg, image/png, image/*, */*

8               Accept-Encoding: x-gzip, x-deflate, gzip, deflate, identity

9               Accept-Charset: iso-8859-15, utf-8;q=0.5, *;q=0.5

10            Accept-Language: de, DE, en

11            Host: webserver.org:8080

Zeile 1 der Anfrage liefert auch gleich die Erklärung für die Bedeutung der GET-Methode im <form>-Tag des Formulars. Für den Webbrowser besteht hier kein Unterschied zur Anforderung einer konventionellen Datei. Es ist jedoch die Aufgabe des Webservers, zu erkennen, daß der Client nicht die Übertragung der Datei skript.pl wünscht. Vielmehr handelt es sich hierbei um ein Programm, das für die Generierung der tatsächlich gewünschten Datei verantwortlich ist. Zu diesem Zweck wird es vom Webserver ausgeführt und mit den Formularfeldern parametriert. Im Fall des GET-Request funktioniert die Übergabe dieser Parameter folgendermaßen:

GET /cgi-bin/skript.pl?Feld1=Inhalt1&Feld2=Inhalt2&submit=Submit HTTP/1.1

Die GET-Anfrage beinhaltet, durch ein Fragezeichen vom Namen der angesprochenen Ressource getrennt, der Parameterstring. Hier finden sich sowohl die Bezeichner, als auch die Inhalte der Formularfelder wieder. Der Webserver muß nun erkennen, um welches Programm es sich handelt und wo der Parameterstring beginnt, damit dieser an das Programm übergeben werden kann.

Nachdem die Angabe der GET-Methode im Formular erforderlich war, ist die Vermutung naheliegend, daß noch andere Methoden im Zusammenhang mit CGI von Bedeutung sind. Wir erinnern uns, daß HTTP eine Methode mit Namen POST definiert, die die Aufgabe hat, Daten an vorhandene Ressourcen übermitteln. Im Sinne von HTTP handelt es sich bei dem angegebenen Programm um eine solche Ressource. Sehen wir uns an, wie die Ausgabe des Browsers aussieht, wenn wir statt GET die Methode POST verwenden.

1               POST /cgi-bin/skript.pl HTTP/1.1

2               Connection: Keep-Alive

3               User-Agent: Mozilla/5.0 (compatible; Konqueror/3.1)

4               Referer: http://webserver.org:8080/form.html

5               Pragma: no-cache

6               Cache-control: no-cache

7               Accept: text/*, image/jpeg, image/png, image/*, */*

8               Accept-Encoding: x-gzip, x-deflate, gzip, deflate, identity

9               Accept-Charset: iso-8859-15, utf-8;q=0.5, *;q=0.5

10            Accept-Language: de, DE, en

11            Host: webserver.org:8080

12            Content-Type: application/x-www-form-urlencoded

13            Content-Length: 47

14            Feld1=Inhalt1&Feld2=Inhalt2&submit=Submit

Es fällt auf, daß in Zeile 1 neben dem anderen Methoden nun keine Parameter mehr aufgeführt sind. Diese finden sich nun am Ende der Anfrage, als Entität wieder. Die Entität wird durch die Headerfelder in den Zeilen 12 und 13 beschrieben und durch die Angabe der Nutzdatenmenge (Zeile 13) ist der Server in der Lage, die übermittelten Parameter einzulesen.

Aus Sicht des Webservers befinden wir uns in der bekannten Funktion serve_client und haben gerade die Annahme der Client anfrage durchgeführt. Im nächsten Schritt wird prepare_response aufgerufen. Hier verzweigt die Ausführung zu einer der Methodenhandler. Im Beispiel von handle_get wird in

Zeile 7 der MIME-Typ der angesprochenen Ressource ermittelt. Bis dahin entsprechen die Abläufe der bereits vorgestellten Anforderung einer statischen Datei. In Zeile 8 wird dann schließlich der für diesen MIME-Typ eingerichtete Handler aufgerufen. Hier handelt es sich nun um eine ausführbare Datei. Daher wurde für den MIME-Typ application/x-perl, dem die Datei skript.pl zugeordnet ist, ein MIMEHandler implementiert, der Parameterübergabe, sowie Ausführung dieser Datei durchführt.

1               int getExecutedFile (MIME_TYPE *type, HTTP_REQUEST *request, HTTP_RESPONSE  *response) {

2                  int fdReadPipe[2];

3                  int fdWritePipe[2];

4

5                  /* …*/

6                  if (pipe (fdReadPipe) < 0 || pipe (fdWritePipe) < 0) {

7                                 return ERROR;

8                  } else {

9                                 forkPid = fork ();

10

11                              switch (forkPid) {

12                                 case -1:

13                                                return ERROR;

14                                 case 0:

15                                                close (fdReadPipe[0]);

16                                                close (fdWritePipe[1]);

17                                                generate_cgi_environment (request);

18

19                                                if (dup2 (fdReadPipe[1], STDOUT_FILENO) < 0 ||

20                                                   dup2 (fdWritePipe[0], STDIN_FILENO) < 0) {

21

22

23                                                   exit (1);

24                                                } else {

25                                                   execl (cmdpath, cmd, request->url->file, NULL);

26                                                }

27                                                break;

28                                 default:

29                                                close (fdReadPipe[1]);

30                                                close (fdWritePipe[0]);

31                                                memset (buffer, 0, BUFLEN);

32                                                if (!strcmp(request->method, „POST“)) {

33                                                   write (fdWritePipe[1], request->ent->content, strlen (request->ent->content));

34                                                }

35

36                                                snprintf (buffer, BUFLEN, „%i.tmp\0“, getpid ());

37                                                tmp = fopen (buffer, „w“);

38                                                while ((len = readLine (fdReadPipe[0], line, BUFLEN)) > 0) {

39                                                   fputs (line, tmp);

40                                                }

41                                                fclose (tmp);

42                                                close (fdReadPipe[0]);

43                                                close (fdWritePipe[1]);

44                                                forkPid = wait (&child_stat);

45                                                ret = getFile (buffer, &(response->ent->content),  &(response->ent->content_length));

46                                                return ret;

47                              }

48               }

49               return ERROR;

50            }

Den Systemruf fork haben wir bereits kennengelernt. Während jedoch im Falle des Echoservers der erzeugte Sohnprozeß von seinem Vater unabhängig war, ist die Situation nun anders. Hier sind wir an der Ausgabe des Sohnes interessiert und müssen daher einen Mechnismus zur Übernittlung dieser Ausgabe an den Vater einrichten. Unix bietet zahlreiche Möglichkeiten für die Interprozeßkommunikation. Allerdings geht es in diesem konkreten Fall nicht um die bloße Möglichkeit, Daten auszutauschen. Um die CGI-Schnittstelle möglichst einfach zu halten, macht man sich die folgende Eigenschaft von Unixprozessen zu Nutze. Jeder Prozeß verfügt über mindestens drei Streams, die mit stdin, stdout und stderr bezeichhnet sind. Sie dienen dem Prozeß der Datenein- und -ausgabe, sowie der Ausgabe von Fehlermeldungen. Der Vorteil des Streamskonzepts kommt im Zusammenspiel mit Pipes zum Tragen. Dabei handelt es sich um Systemobjekte, die es ermöglichen, einen beliebigen Ein- mit einem Ausgabestream zu verbinden. Es handelt sich hierbei um die gleichen Pipes, die von der Unixshells bekannt sind. Ein Aufruf wie „# ls | grep filename“ hat die folgende Bedeutung. Der Ausgabestream des Programms ls wird für die Dauer der Ausführung mit dem Eingabestream des Programms grep verbunden. Das bedeutet, die Ausgabe von ls findet nicht, wie gewohnt, auf dem Terminal statt, sondern kann von grep über dessen Eingabe gelesen werden.

Betrachtet man diesen Aufruf noch genauer, erkennt man Parallelen zu der obigen getExecutedFile-Funktion. Aus Sicht der Shell passiert folgendes:

  • die Eingabe # ls | grep filename wird eingelesen
  • der Shellprozeß erkennt beim Parsen der eingelesenen Zeile, daß es sich um zwei Programme handelt, die durch das Pipesymbol | getrennt sind.
  • Anstatt wie üblich einen einzigen neuen Prozeß zu erzeugen, ruft der Shellprozeß nun zweimal den Befehl fork auf.
  • Aus dem Aufruf geht hervor, in welcher Weise die beiden neuen Prozesse nun verbunden werden sollen. Die Shell installiert diese Verbindung durch den Systemruf pipe
  • Nachdem beide Prozesse auf diese Weise verbunden sind, ruft die Shell für jeden Prozeß einen exec-Befehl auf, und übergibt diesem die beiden Programmnamen ls und grep, sowie die angegebenen Kommandozeilenparameter.
  • Exec überschreibt das Textsegment der neuen Prozesse mit den beiden Programmen und startet mit deren Ausführung.

Auf die ähnnliche Weise funktioniert der Aufruf eines CGI-Programmes. Lediglich zwei Unterschiede fallen auf: Zum einen ist es der Vaterprozeß selbst, also der Webserver, der mit dem CGI-Programm verbunden wird. Zum anderen verlangt die CGI-Schnittstelle eine andere Form der Parameterübergabe, sowie die Belegung einiger Umgebungsvariablen.

Sehen wir uns zunächst die Einrichtung der beiden Pipes an. Da eine Pipe nur unidirektionale Übertragung ermöglicht, werden zwei hier benötigt. Die Pipes werden in Form von int[]-Feldern definiert und in Zeile 6 eingerichtet. Das Umlenken der std-Streams des Sohnprozesses erfolgt dann in den Zeilen 19 und 20. Falls kein Fehler auftritt, kann das CGI-Programm in Zeile 25 gestartet werden.

Für die Übergabe der Parameter sind zwei Varianten vorgesehen, die sich nach der verwendeten HTTP-Methode richten. Parameter, die durch einen GET-Request übertragen wurden, stellt der Webserver dem CGI-Programm über Umgebungsvariablen zur Verfügung (Zeile 17)

Bei POST-Requests wird dagegen lediglich die Länge des Parameterstrings in der Umgebung abgelegt. Die Daten werden dem Sohnprozeß über den stdin-Stream übermittelt. Da der aufrufende Prozeß eine Pipe in Richtung des Sohnes eingerichtet hat, die mit dessen Eingabestream verknüpft ist, müssen die Parameter lediglich auf diese Pipe geschrieben werden (Zeile 33). Auf dem umgekehrten Wewie eine statische Datei an den Client übertragen.

Da das CGI-Programm theoretisch eine beliebig komplexe Anwendung sein kann, steht dem Entwickler aufwändiger Websites ein mächtiges Werkzeug zur Verfügung.

An Grenzen stößt man jedoch bei dem Versuch, einen gemeinsamen Kontext über mehrere Anfragen hinweg aufrecht zu halten. Da HTTP keine Zustände definiert, ist es nur mit zusätzlichen Mitteln möglich, eine Art Gedächtnis zu erzeugen. Man gelangt auf diese Weise zum Begriff der HTTP-Sitzung oder Session, der hier abschließend noch eingeführt werden soll.

HTTP-Sessions

Aus Sicht eines HTTP-Servers stellt eine Anfrage mit darauffolgender Antwort eine abgeschlossene Transaktion dar. Jeder Request wird weitgehend unabhängig von vorangeganenen behandelt. Diese Eigenschaft des Protokolls kann jedoch zu einem Problem werden, wenn es darum geht, umfangreiche Applikationen zu betreiben, die sich über mehrere HTML-Seiten erstrecken. In einer solchen Umgebung ist es wünschenswert, Variablen länger anhaltenden Lebensdauern definieren zu können.

Ein erster sinnvoller Anwendungsbereich solcher langlebiger Variablen ist bei der Authentifizierung von Benutzern erkennbar. Eine Webapplikation setze sich aus mehreren Seiten und CGI-Programmen zusammen. Soll nun zu jeder Zeit, was für HTTP soviel heißt wie: bei jeder Anfrage, sichergestellt sein, daß nur registrierte Benutzer Zugriff, müssten mit jeder Anfrage an den Webserver Benutzerdaten wie Username und Passwort übertragen werden. Das bedeutet natürlich zusätzlichen Aufwand für den Entwickler, aber auch nicht zuletzt für den Benutzer selbst. Für ihn wäre eine Lösung optimal, bei der es eine dedizierte Loginseite gibt, die er auf jeden Fall einmal besuchen muß, wobei diese einmal gemachte Eingabe daraufhin für alle Seiten der Webanwendung gilt.

Technisch stellt sich diese Loginseite als HTML-Formular dar, dessen Inhalt an ein CGI-Programm gesendet wird. Dieses hat nun die Aufgabe, die Benutzerdaten persistent zu machen. Damit bezeichnet man die Lebensdauer einer Variable, die über die Laufzeit eines Programms hinweg andauert. Dies kann durch ein Speichern der Variable in einer Datei oder Datenbank geschehen. Wichtig ist nur, daß die Benutzerdaten auch beim Aufruf des nächsten CGI-Programms noch verfügbar sind, obwohl beide CGI-Programme weder einen gemeinsamen Adressraum haben, noch zur selben Zeit laufen. Dies ist durch eine Textdatei leicht zu erreichen.

Trotzdem bleibt noch die Frage, wie verschiedene CGI-Programme, die Anfragen von verschiedenen Benutzern erhalten, in die Lage versetzt werden können, die persistenten Variablen den entsprechenden Benutzern zuzuordnen? Hier setzt der Begriff der HTML-Session an. Eine Session existiert ab der Anmeldung eines Benutzers, bis zu dessen Abmeldung oder dem Ablauf einer Frist. Dazwischen bildet die Session einen gemeinsamen Kontext für alle von diesem Benutzer angeforderten Dateien. Um verschiedene HTML-Sessions gegeneinander abzugrenzen, ist ein eindeutiger Session-Key notwendig. Dieser Begriff stammt aus der Datenbankwelt und bezeichnet wie dort einen Schlüssel, der eineindeutig einer Session zugeordnet ist. Um sowohl die Eindeutigkeit, als auch eine zeiltiche Begrenzung der Gültigkeit zu erreichen, kann man den Schlüssel aus einem Zeitstempel und der IP-Adresse des Clients generieren. Auf welche Arten kann man nun einen Datenaustausch zur Verwaltung und Erhaltung einer HTTP-Session bewerkstelligen?

Dazu sind in der Praxis drei Verfahren üblich, von denen uns zwei bereit bekannt vorkommen werden. Allen Verfahren gemeinsam ist, daß der Benutzer Einblick in die übertragenen Werte hat. Es empfiehlt sich also eine Verschlüsselung auf Session-Keys anzuwenden, da sonst Manipulationen möglich sind.

Die einfachste Variante nutzt sogenannte versteckte Felder in HTML-Formularen. Sie verhalten sich genau wie die bekannten Textfelder mit dem Unterschied, daß sie nur im Quelltext der HTML-Datei sichtbar sind. Ein CGI-Programm empfängt diese Felder auf die gleiche Weise wie andere Formularfelder auch, unabhängig von der verwendeten HTTP-Methode.

Die zweite Vorgehensweise wird als URL-Encoding bezeichnet und kann nur in Verbindung mit GET-Anfragen benutzt werden. Hier werden Sessiondaten an den URL der Anfrage angehängt und ebenfalls mit den übrigen Parametern übermittelt.

Als dritte und komfortabelste Alternative können HTTP-Cookies verwendet werden. Hierbei handelt es sich um blanke Textdateien, die nach einer definierten Struktur aufgebaut sind. Diese werden zusammen mit der Entität der Serverantwort als Headerfeld an den Webbrowser übertragen und von diesem lokal gespeichert.

Set-Cookie: NAME=VALUE; expires=DATE;

path=PATH; domain=DOMAIN_NAME; secure

Vor dem Versenden einer Anfrage überprüft der Browser dann, ob ein Cookie der angesprochenen Domain und des angesprochenen Pfades des URL lokal existiert und fügt diesen in den Header der Anfrage ein, so daß er vom Webserver empfangen und unter Umständen an ein CGI-Programm weitergeleitet werden kann.

Cookie: NAME1=VALUE1; NAME2=VALUE2…

Neben einem Namen und einem Wert kann für einen Cookie ein Verfallsdatum angegeben werden, so daß die Webapplikation keine Rücksicht auf die zeitliche Gültigkeit der Session nehmen braucht. Ist der Gültigkeitsdauer abgelaufen, verhält sich der Browser so, als wäre kein Cookie gespeichert und die Webapplikation, bzw. das empfangende CGI-Programm kann den Benutzer auffordern, sich anzumelden.
Ein Nachteil der Cookies ist, daß ihr Empfang vom Benutzer genehmigt werden muß. Cookeis stehen in dem Licht, Rückschlüsse auf das Surfverhalten eines Benutzers zuzulassen, was allerdings nicht ganz richtig dargestellt ist. Eine Übertragung an den Webserver findet nämlich nur bei denjenigen Cookies statt, die auch von diesem gesendet wurden. Es mag möglich sein, einen Cookie mit einer falschen Domain-Angabe zu setzen, doch dem Empfang geht eine Überprüfung durch den Client voraus, in der festgestellt wird, ob Cookies für die in der Anfrage enthaltene Ressource vorliegen. Die Daten jedoch, die auf diesem Wege an den Server gelangen, sind diesem ohnehin bekannt und könnten ohne Verwendung von Cookies ebensogut durch eins der beiden obigen Verfahren ermittelt und in einer Datei oder Datenbank gehalten werden. Darüberhinaus hat sich das Gerücht breit gemacht, Cookies könnten Viren oder aktive Inhalte einschleusen, bzw. lokale Festplatten auslesen. Dies ist ebenfalls nicht richtig, da die übertragenen Daten zum einen nicht ausgeführt werden, zum anderen ist der lokale Speicherort der Cookies festgelegt und sowohl die Anzahl als auch die maximale zulässige Größe sind begrenzt.

Aufgrund der geringen Akzeptanz empfiehlt es sich daher, auf Cookies völlig zu verzichten. Die am besten geeignete Methode ist die Verwwendung der versteckten HTML-Felder, da dies für den Benutzer transparent abläuft.