Tour 3: "tcp-server"

Überblick

In diesem Beispiel wollen wir einen einfachen TCP-Server schreiben, der auf eine eigehende Verbindung eines Webbrowsers wartet und auf die Konsole ausgibt, was der Browser bei seiner Anfrage alles mit sendet.

In unserem Programm müssen wir die folgenden Aufgaben lösen:

  1. Einen TCP-Socket erzeugen
  2. Den Socket an eine lokale Adresse binden
  3. Den Socket in den listen-Zustand versetzen
  4. Auf eingehende Verbindungen warten und diese akzeptieren
  5. Daten auf der eingegangenen Verbindung empfangen und ausgeben
  6. Die eingegangene Verbindung schließen
  7. Den Server-Socket schließen

Die wichtigsten Stellen werden im Folgenden besprochen, auf der Seite Beispiel-Programme sind die vollständigen Programme zu finden.

Einen TCP-Socket erzeugen

Dieser Vorgang wurde im vorherigen Beispiel ausführlich besprochen. Der Vollständigkeit halber hier der Code:

int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock == -1)
{
        fprintf(stderr, "Couldn't create socket: %s\n", strerror(errno));
}

Den Socket an eine lokale Adresse binden

Da wir einen Server schreiben wollen, der von einem Client gefunden werden soll, benötigen wir eine festgelegte Ziel-Adresse. Die IP-Adresse ist hierbei durch die im System verfügbaren Schnittstellen vorgegeben. Wir können z.B. 127.0.0.1, die lokale Loopback-Adresse, benutzen. Wenn das System an ein lokales Netzwerk angeschlossen ist, dann könnte eine weitere gültige Adresse 192.168.2.100 sein. Oder aber uns ist es egal, dann geben wir INADDR_ANY an. Damit akzeptieren wir Verbindungen auf allen zur Verfügung stehenden Schnittstellen.

Der zweite Teil einer eindeutigen TCP-Adresse ist die Port-Nummer. Hier könnten wir z.B. Port Nummer 80 nehmen, der für "echte" Webserver benutzt wird. Der Vorteil: wir müssen im Webbrowser später zum Testen keine andere Port-Nummer angeben. Der Nachteil: die Port-Nummern bis 1024 gehören zu den well known ports, die größtenteils für bestimmte Dienste reserviert sind, und damit niemand Schindluder mit ihnen treibt, benötigen unter UNIX alle Programme, die diese Ports benutzen wollen, besondere Zugriffsrechte ("root-Rechte"). Weil man die Programme dann alle via sudo bzw. als Benutzer root starten müsste, wollen wir als Ausweichlösung den Port 1234 benutzen.

struct sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_port = htons(LISTEN_PORT);
saddr.sin_addr.s_addr = INADDR_ANY;
if (bind(sock, (struct sockaddr*) &saddr, sizeof(saddr)) == -1)
{
        fprintf(stderr, "Couldn't bind socket: %s\n", strerror(errno));
}

Wir sehen, dass der Aufruf quasi identisch zu connect() ist. Das liegt daran, dass beide Funktionen einem Socket einen Endpunkt zuweisen, nur dass dieser bei connect() beim Ziel-System liegt und bei bind() beim eigenen System (man spricht auch von destination address und source address).

Es kann vorkommen, dass man eine Fehlermeldung wie z.B. Address already in use bekommt, es aber zu einem späteren Zeitpunkt (ein paar Minuten) wieder funktioniert. Dazu mehr auf der Seite Hintergrundwissen.

Den Socket in den listen-Zustand versetzen

Dieser Schritt ist sehr einfach, aber enorm wichtig. Er weist das System an den Socket in den listen-Zustand zu versetzen, d.h. der Socket wird hier zum eigentlichen Server-Socket. Man könnte sich fragen, warum das nicht schon bei bind() passiert. Die Antwort ist ebenso einfach wie verblüffend: man kann bind() auch für Client-Sockets benutzen, um etwa wie bei FTP den Quellport einer ausgehenden Verbindung festzulegen.

In unserem Fall rufen wir listen() wie folgt auf:

if (listen(sock, 3) == -1)
{
        fprintf(stderr, "Couldn't set socket to listen mode: %s\n", strerror(errno));
}

Die "3" gibt die maximale Anzahl an Verbindungen an, die in der Warteschlange stehen dürfen um mit accept() endgültig akzeptiert zu werden. Ist die Warteschlange voll (z.B. weil der Server mit dem Aufrufen von accept() nicht nachkommt), werden weitere Verbindungen abgewiesen.

Sobald der Socket im listen-Modus ist, kann man dies per netstat sehen:

$ netstat -a | grep 1234
tcp        0      0 0.0.0.0:1234            0.0.0.0:*               LISTEN

Auf eingehende Verbindungen warten und diese akzeptieren

Wir haben jetzt einen Socket im Lauschmodus, aber wie kommen wir zu Client-Verbindungen? Das geschieht mit dem accept()-Befehl:

struct sockaddr_in saddr;
socklen_t size = sizeof(saddr);
int client = accept(sock, (struct sockaddr*) &saddr, &size);
if (client == -1)
{
        fprintf(stderr, "Accepting client failed: %s\n", strerror(errno));
}

accept() liefert uns zwei verschiedene Informationen:

  1. Einen verbundenen Socket
  2. Eine Client-Adresse

Der verbundene Socket wird als Rückgabewert geliefert. Er ist gleichwertig zu einem von socket() erzeugten Socket und muss nach Gebrauch ebenso mit close() geschlossen werden.

Die Client-Adresse ist quasi eine Zusatzdienstleistung. Wenn man daran nicht interessiert ist, dann kann man als zweiten Parameter NULL und als dritten Parameter 0 angeben. Die Client-Adresse kann man nachträglich auch jederzeit mit getpeername() erneut ermitteln.

Daten auf der eingegangenen Verbindung empfangen und ausgeben

Daten empfangen funktioniert wieder genauso wie im vorherigen Beispiel. Wichtig ist nur, dass wir vom Client-Socket und nicht vom Server-Socket lesen müssen.

Zum Empfangen von HTTP-Requests ist die Vorgehensweise, solange zu lesen bis die Verbindung von der Gegenstelle geschlossen wird, eigentlich falsch. Richtig wäre die gelesenen Daten zu interpretieren und logische Zeilen zu formen. Mehr dazu findet man in RFC 2616. In unserem Fall führt dies zu dem Effekt, dass zunächst keine Ausgabe erfolgt und der Browser vergeblich auf eine Antwort wartet. In Firefox, Chrome, etc. kann man jedoch einfach mit der Escape-Taste das Laden abbrechen, woraufhin die Socket-Verbindung geschlossen wird und die Lese-Funktion zurückkehrt. Für das Beispiel-Programm ist das gut genug.

Ein paar Ausgaben des fertigen Programms

Hier die Ausgabe des Programms unter Linux mit einem Firefox, der den URL localhost:1234/path/to/file.html abruft:

$ ./http-print
Binding to 0.0.0.0:1234... OK.
Got a new client from 127.0.0.1:48728.
Received 358 bytes: <GET /path/to/file.html HTTP/1.1
Host: localhost:1234
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:65.0) Gecko/20100101 Firefox/65.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: keep-alive
Upgrade-Insecure-Requests: 1

>

Hier die Ausgabe des Programms unter Windows mit einem Chrome, der den URL localhost:1234/path/to/file.html abruft:

C:\>http-print.exe
Binding to 0.0.0.0:1234... OK.
Got a new client from 127.0.0.1:50802.
Received 425 bytes: <GET /path/to/file.html HTTP/1.1
Host: localhost:1234
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: en,en-US;q=0.9,de-DE;q=0.8,de;q=0.7

>

Was für einen "richtigen" Server fehlt

Von dem bereits angesprochenen Zusammenhang von HTTP-Requests und logischen Zeilen abgesehen gibt es noch ein paar weitere Punkte, die bei einer richtigen Server-Anwendung berücksichtigt werden sollten.

Dass sich die Anwendung nach dem ersten Client-Besuch beendet ist in den meisten Fällen sicher nicht erwünscht. Benötigt wird also eine Schleife. Diese muss den accept()-Aufruf beinhalten und sicherstellen, dass der hier neu erzeugte Socket wieder geschlossen wird, damit kein Ressourcen-Leck entsteht.

Oft soll ein einziger Server in der Lage sein, mehrere Clients gleichzeitig zu bedienen. Dazu gibt es verschiedene Ansätze, die in späteren Touren/Beispielen detailliert erklärt werden.

Wenn ein Server später mal produktiv eingesetzt werden soll, dann möchte man normalerweise kein Konsolenfenster mehr offen haben müssen. Hier gibt es auch verschiedene Ansätze, die jedoch nicht im Rahmen der Socket-Tipps erklärt werden, weil sie prinzipiell nichts mit Sockets zu tun haben und auch sehr systemabhängig sind. Dennoch gibt es auf der Seite Wie geht's weiter? ein paar Hinweise.


Zurück