Zum Inhalt
wannacode

Netzwerkprogrammierung

Kurz gesagt: Netzwerkprogrammierung heißt, dass zwei Programme über das Netzwerk miteinander sprechen: Der Server wartet auf einer IP-Adresse und einem Port, der Client verbindet sich, und Sockets senden die Daten über TCP oder UDP.

Problem

Die Kasse und das Lager

Die Kasse im Shop muss das Lager fragen, wie viel Kaffee noch da ist, und das Lagerprogramm läuft auf einem anderen Computer. Eine Datei hilft nicht: Die beiden Programme sehen die Festplatten des anderen nicht. Eins muss fragen und das andere antworten, sofort, über das Netzwerk.

So funktioniert fast alles um dich herum: Der Browser fragt den Server nach einer Seite, die Banking-App fragt die Bank nach deinem Kontostand, ein Spiel schickt deine Züge an die anderen Spieler.

Die Idee

Adresse, Port und zwei Arten zu senden

Damit Daten ein Programm auf einem anderen Computer erreichen, brauchst du zwei Dinge:

  • Eine IP-Adresse: die Nummer des Computers im Netzwerk, zum Beispiel 192.168.1.20 oder 142.250.185.78. Dein eigener Computer ist immer 127.0.0.1, mit dem Namen localhost.
  • Einen Port: eine Zahl von 0 bis 65 535 für das Programm auf diesem Computer. Die Adresse ist das Gebäude, der Port ist die Wohnung. HTTPS nutzt 443, und wir nutzen 5971 und höher.

Das Programm, das darauf wartet, dass man es erreicht, ist der Server. Das Programm, das ihn erreicht, ist der Client. Wie die Daten reisen, entscheidet das Protokoll:

TCP UDP
Verbindung erst verbinden sie sich, dann sprechen sie keine Verbindung, jedes Paket ist ein eigener Brief
Zustellung alles kommt an, in derselben Reihenfolge, oder du erfährst, dass es nicht geklappt hat ein Paket kann verloren gehen oder in anderer Reihenfolge ankommen
Geschwindigkeit langsamer schneller
Wofür Web, E-Mail, Zahlungen, Dateien Sprache und Video live, Spiele, Sensoren
Die Endpunkte einer Verbindung heißen Sockets. In C# sind ihre Klassen in System.Net.Sockets.
Genauer, für Neugierige
  • Das Netzwerk wird in Schichten beschrieben. Das OSI-Modell hat sieben: Bitübertragung (Bits über das Kabel oder die Luft), Sicherung (die Verbindung zum nächsten Gerät), Vermittlung (IP-Adressen und Routen), Transport (TCP, UDP), Sitzung, Darstellung und Anwendung (HTTP, E-Mail). In der Praxis nutzt das Internet das einfachere TCP/IP-Modell mit vier Schichten. Dein Programm arbeitet auf der obersten: Um alles darunter kümmern sich .NET und das Betriebssystem.
  • IP liefert einzelne Pakete an eine Adresse, verspricht aber nicht, dass sie ankommen. TCP baut auf IP auf und nummeriert die Pakete, damit es sie ordnen und die verlorenen noch einmal anfordern kann.
  • Protokolle, denen du begegnen wirst: HTTP und HTTPS (das Web; HTTPS ist HTTP, verschlüsselt mit TLS, dessen älterer Name SSL ist), WebSocket (eine Verbindung in beide Richtungen für Chat und Benachrichtigungen im Browser), FTP (Dateien), SSH (sicherer Zugriff auf einen entfernten Computer).

Sieh es dir an

Der Server

Der Lagerserver bekommt den Namen eines Produkts und antwortet mit seinem Preis:

static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["Kaffee"] = 7.50, ["Tee"] = 3.20 };
static void Serve(TcpListener server)
{
try
{
using (TcpClient client = server.AcceptTcpClient())
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
string product = reader.ReadLine();
Console.WriteLine("Server erhielt: " + product);
writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "unbekanntes Produkt");
}
}
catch (IOException e)
{
Console.WriteLine("Server: " + e.Message);
}
}
  • TcpListener lauscht auf einem Port. server.AcceptTcpClient() wartet, bis sich ein Client verbindet, und gibt einen TcpClient für die Verbindung mit ihm zurück: denselben Typ, mit dem sich auch der Client verbindet.
  • client.GetStream() gibt dir den Stream der Verbindung: Über ihn fließen Bytes in beide Richtungen. Wir verpacken ihn in zwei Klassen: StreamReader liest Text Zeile für Zeile mit ReadLine(), und StreamWriter schreibt mit WriteLine(). Dieselben Klassen lesen und schreiben auch Dateien.
  • AutoFlush = true bedeutet „nach jedem WriteLine senden“. Du wirst gleich sehen, warum das wichtig ist.
  • using (…) schließt die Verbindung und die Streams am Ende des Blocks, auch wenn in der Mitte eine Exception auftritt: als würde ein finally Dispose() aufrufen. Mehrere using-Zeilen untereinander teilen sich einen Block und werden in umgekehrter Reihenfolge geschlossen.
  • TryGetValue sucht das Produkt und gibt dir den Preis über out.

Sieh es dir an

Der Client

Der Client verbindet sich mit new TcpClient(address, port) und nutzt dieselben StreamReader und StreamWriter. Hier sind Server und Client in einem Programm: Der Server läuft in seinem eigenen Thread, und Main ist der Client. So kannst du beide ohne zweiten Computer ausprobieren:
TcpListener server = new TcpListener(IPAddress.Loopback, 5971);
server.Start();
new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5971))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.WriteLine("Kaffee");
Console.WriteLine("Client erhielt: " + reader.ReadLine());
}
server.Stop();
Ausgabe
Server erhielt: Kaffee
Client erhielt: Kaffee: 7.5 €
new TcpListener(IPAddress.Loopback, 5971) öffnet den Port noch nicht. Das macht server.Start() in Main, bevor der Thread startet. So lauscht der Server schon, wenn sich der Client verbindet. IPAddress.Loopback ist 127.0.0.1: Der Server nimmt nur Programme vom selben Computer an.

Die beiden Seiten wechseln sich ab: Der Client schreibt eine Zeile und wartet mit ReadLine() auf eine Antwort, und der Server liest die Zeile und antwortet. Wenn sie die Rollen tauschen und beide darauf warten, etwas zu lesen, warten sie ewig.

TcpListener server = new TcpListener(IPAddress.Loopback, 5971);
server.Start();
new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5971))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.WriteLine("Kaffee");
Console.WriteLine("Client erhielt: " + reader.ReadLine());
}
server.Stop();
Schritt für Schritt, in Worten
  1. Der Server öffnet Port 5971 und beginnt zu lauschen.
  2. In seinem eigenen Thread ruft der Server AcceptTcpClient() auf und wartet auf einen Client.
  3. Der Client verbindet sich. AcceptTcpClient() gibt einen TcpClient zurück, und jede Seite hat einen writer zum Schreiben und einen reader zum Lesen.
  4. Der Client schreibt eine Zeile: „Kaffee“. Sie wandert über die Verbindung zum Server.
  5. Der Client wartet in ReadLine(). Der Server liest die Zeile und gibt sie aus.
  6. Der Server findet den Preis und schreibt zurück: „Kaffee: 7.5 €“.
  7. ReadLine() gibt dem Client die Antwort zurück, und der Client gibt sie aus.
  8. using schließt die Verbindung, und server.Stop() schließt den Port.
Genauer, für Neugierige
  • Das ganze Programm, mit den imports:
Program.cs
using System.Net;
using System.Net.Sockets;
class Program
{
static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["Kaffee"] = 7.50, ["Tee"] = 3.20 };
static void Serve(TcpListener server)
{
try
{
using (TcpClient client = server.AcceptTcpClient())
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
string product = reader.ReadLine();
Console.WriteLine("Server erhielt: " + product);
writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "unbekanntes Produkt");
}
}
catch (IOException e)
{
Console.WriteLine("Server: " + e.Message);
}
}
static void Main()
{
TcpListener server = new TcpListener(IPAddress.Loopback, 5971);
server.Start();
new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5971))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.WriteLine("Kaffee");
Console.WriteLine("Client erhielt: " + reader.ReadLine());
}
server.Stop();
}
}
  • In zwei getrennten Dateien und zwei Terminals ist es dasselbe: Du startest zuerst den Server, dann den Client. Wenn auf dem Port kein Server ist, wirft new TcpClient eine SocketException (Connection refused). Wenn der Port schon von einem anderen Programm belegt ist, wirft server.Start() eine SocketException (Address already in use). In .NET ist eine SocketException keine IOException, deshalb fängt catch (IOException) sie nicht.
  • Text reist als Bytes über das Netzwerk. StreamReader und StreamWriter nutzen standardmäßig UTF-8, deshalb kommen Umlaute wie ü und das Zeichen € unversehrt an.
  • using hat auch eine kurze Form, ohne Klammern und ohne Block: using TcpClient client = new TcpClient("localhost", 5971); schließt die Verbindung am Ende des Blocks, in dem die Zeile steht.

Probier es aus

Auf einen Client warten

Welche Methode wartet, bis sich ein Client verbindet?

TcpListener server = new TcpListener(IPAddress.Loopback, 5971);
server.Start();
TcpClient client = server.();

Die Idee

Viele Clients gleichzeitig

Während der Server mit einem Client spricht, wird AcceptTcpClient() nicht aufgerufen, und die anderen warten. Deshalb läuft ein echter Server in einer Endlosschleife: Er nimmt einen Client an, gibt ihn an einen neuen Thread und kehrt sofort zu AcceptTcpClient() zurück:
static void AcceptAll(TcpListener server)
{
int number = 0;
while (true)
{
try
{
TcpClient client = server.AcceptTcpClient();
number++;
int n = number;
new Thread(() => Handle(client, n)).Start();
}
catch (SocketException)
{
return;
}
}
}
static void Handle(TcpClient client, int number)
{
try
{
using (client)
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine("Client " + number + ": " + line);
writer.WriteLine("Echo: " + line);
}
}
}
catch (IOException e)
{
Console.WriteLine("Client " + number + " getrennt: " + e.Message);
}
}
Handle beantwortet jede Zeile, bis der Client die Verbindung schließt: Dann gibt ReadLine() null zurück. Zwei Clients nacheinander:
TcpListener server = new TcpListener(IPAddress.Loopback, 5972);
server.Start();
Thread acceptor = new Thread(() => AcceptAll(server));
acceptor.IsBackground = true;
acceptor.Start();
foreach (string order in new string[] { "Tisch 4: zwei Kaffee", "Tisch 7: die Rechnung" })
{
using (TcpClient client = new TcpClient("localhost", 5972))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.WriteLine(order);
Console.WriteLine(reader.ReadLine());
}
}
server.Stop();
Ausgabe
Client 1: Tisch 4: zwei Kaffee
Echo: Tisch 4: zwei Kaffee
Client 2: Tisch 7: die Rechnung
Echo: Tisch 7: die Rechnung
Genauer, für Neugierige
  • IsBackground = true macht den Thread zu einem „Hintergrund“-Thread: Das Programm endet, wenn Main endet, ohne auf die Endlosschleife zu warten. Ein Thread aus new Thread ist standardmäßig kein Hintergrund-Thread. Ein echter Server macht ihn nicht zum Hintergrund-Thread: Er muss laufen, bis du ihn stoppst.
  • server.Stop() schließt den Port. Das wartende AcceptTcpClient() wirft eine SocketException, und die Schleife endet mit return.
  • using (client) nimmt den schon vorhandenen TcpClient in ein using auf, damit er am Ende geschlossen wird.
  • int n = number; ist nötig: Das Lambda merkt sich nicht den Wert von number, sondern die Variable selbst. Ohne die Kopie könnte der Thread des ersten Clients number lesen, nachdem die Schleife es für den zweiten schon erhöht hat.
  • Bei Tausenden von Clients ist ein Thread für jeden teuer. Dann gibt die Annahmeschleife die Clients an den Thread-Pool aus der vorigen Lektion: Task.Run(() => Handle(client, n)).

Probier es aus

Beide warten

Der Client sendet „Kaffee“ und wartet auf eine Antwort. Der Server ist derselbe, aber das Programm hängt und gibt nichts aus. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

UDP: Briefe ohne Verbindung

Die Sensoren im Shop senden jede Minute die Temperatur. Wenn ein Messwert verloren geht, kommt der nächste eine Minute später, deshalb passt UDP hier gut. Es gibt keine Verbindung und keine Streams: Du sendest Bytes an eine Adresse und einen Port über einen UdpClient:
UdpClient receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 5973));
using (UdpClient sensor = new UdpClient())
{
foreach (string reading in new string[] { "Küche 21.5", "Lager 4.0", "Kühlschrank 3.2" })
{
byte[] data = Encoding.UTF8.GetBytes(reading);
sensor.Send(data, data.Length, "localhost", 5973);
}
}
IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0);
for (int i = 0; i < 3; i++)
{
byte[] data = receiver.Receive(ref sender);
string text = Encoding.UTF8.GetString(data);
Console.WriteLine("Temperatur: " + text + " °C");
}
receiver.Close();
Ausgabe
Temperatur: Küche 21.5 °C
Temperatur: Lager 4.0 °C
Temperatur: Kühlschrank 3.2 °C
  • Der Empfänger lauscht auf Port 5973: new UdpClient(new IPEndPoint(IPAddress.Loopback, 5973)). Ein IPEndPoint ist eine Adresse plus ein Port. Der Sender ist new UdpClient() ohne Port: Das System gibt ihm einen freien.
  • Über das Netzwerk reisen Bytes. Encoding.UTF8.GetBytes wandelt den Text in Bytes um, und Encoding.UTF8.GetString wandelt sie zurück (Encoding ist in System.Text).
  • Send(bytes, count, address, port) sendet ein Paket. Receive(ref sender) wartet auf ein Paket, gibt seine Bytes zurück und schreibt in sender, woher es kam: deshalb ref.
  • Niemand bestätigt, dass das Paket angekommen ist. Auf localhost gehen keine verloren, in einem echten Netzwerk kann das passieren.
Genauer, für Neugierige

Wenn der Empfänger antworten muss, sendet er ein neues Paket an die Adresse, die Receive in sender geschrieben hat: receiver.Send(answer, answer.Length, sender). Ein Paket hat eine Größengrenze (unter 64 KB, und über das Internet bleibt man sinnvollerweise bei etwa 1 400 Bytes), deshalb taugt UDP nicht für Dateien.

Die Idee

HTTP: die Sprache des Webs

Wenn du eine Seite öffnest, baut der Browser eine TCP-Verbindung zum Server auf und sendet eine textbasierte HTTP-Anfrage (Request): „gib mir /“. Der Server schickt eine Antwort (Response) zurück: einen Status, Header und einen Body (HTML, JSON, ein Bild). In C# macht das HttpClient, aber er arbeitet mit async und await, die erst im Modul „Web und Desktop“ kommen. Hier ist die Anfrage von Hand geschrieben und über einen TcpClient gesendet. So siehst du, dass HTTP einfach Text über eine TCP-Verbindung ist:

using System.Net.Sockets;
using (TcpClient client = new TcpClient("example.com", 80))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.Write("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n");
string status = reader.ReadLine();
Console.WriteLine("Status: " + status.Split(' ')[1]);
string line;
while ((line = reader.ReadLine()) != "")
{
if (line.StartsWith("Content-Type: "))
{
Console.WriteLine("Typ: " + line.Substring(14));
}
}
string body = reader.ReadToEnd();
int start = body.IndexOf("<title>") + 7;
Console.WriteLine("Titel: " + body.Substring(start, body.IndexOf("</title>") - start));
}
Ausgabe (mit Internet)
Status: 200
Typ: text/html; charset=utf-8
Titel: Example Domain
  • Die Anfrage besteht aus ein paar Zeilen, und jede endet mit \r\n: GET / HTTP/1.1 („gib mir /“), Host: (welche Website) und Connection: close („nach der Antwort schließen“). Die leere Zeile am Ende sagt, dass die Anfrage zu Ende ist.
  • Die Antwort beginnt mit einer Zeile wie HTTP/1.1 200 OK. Ihr zweiter Teil ist der Status. Danach folgen die Header (Content-Type: …) bis zu einer leeren Zeile, und dann kommt der Body. ReadToEnd() liest ihn, bis der Server die Verbindung schließt.
  • Der Status sagt dir, wie es gelaufen ist: 200 ist Erfolg, 404 heißt, dass es keine solche Seite gibt, 500 ist ein Fehler auf dem Server.
  • Port 80 ist HTTP ohne Verschlüsselung. Die meisten Websites wollen heute HTTPS auf Port 443, wo der Text verschlüsselt reist (TLS). HttpClient erledigt das für dich.

Die Codeprüfung der Website läuft ohne Internet, deshalb wird dieses Programm mit der Ausgabe eines Laufs mit Netzwerk gezeigt. Ohne Netzwerk wirft new TcpClient eine SocketException. Im Modul „Web und Desktop“ werden Anfrage und Antwort Zeile für Zeile zerlegt, und dann erledigt HttpClient das alles für dich.

Genauer, für Neugierige
.NET selbst hat auch einen HTTP-Server (HttpListener). So kannst du dieselbe Anfrage auch ohne Internet ausprobieren, auf localhost:
static void Answer(HttpListener server)
{
try
{
while (true)
{
HttpListenerContext context = server.GetContext();
bool found = context.Request.Url.AbsolutePath == "/stock";
byte[] answer = Encoding.UTF8.GetBytes(found ? "Kaffee: 12 Stk." : "unbekannte Adresse");
context.Response.StatusCode = found ? 200 : 404;
context.Response.ContentLength64 = answer.Length;
context.Response.OutputStream.Write(answer, 0, answer.Length);
context.Response.Close();
}
}
catch (HttpListenerException)
{
return;
}
}
HttpListener server = new HttpListener();
server.Prefixes.Add("http://localhost:5974/");
server.Start();
Thread answering = new Thread(() => Answer(server));
answering.IsBackground = true;
answering.Start();
foreach (string path in new string[] { "/stock", "/prices" })
{
using (TcpClient client = new TcpClient("localhost", 5974))
using (StreamReader reader = new StreamReader(client.GetStream()))
using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true })
{
writer.Write("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
string status = reader.ReadLine().Split(' ')[1];
while (reader.ReadLine() != "")
{
// die Header der Antwort brauchen wir hier nicht
}
Console.WriteLine(path + " -> " + status + " " + reader.ReadToEnd());
}
}
server.Stop();
Ausgabe
/stock -> 200 Kaffee: 12 Stk.
/prices -> 404 unbekannte Adresse
GetContext() wartet auf eine Anfrage, so wie AcceptTcpClient() auf einen Client wartet. Für /prices setzt der Server den Status 404 selbst. ContentLength64 sagt, wie viele Bytes der Body hat, damit der Client weiß, wo er endet. server.Stop() unterbricht das wartende GetContext() mit einer HttpListenerException. In echten Projekten schreibt man Webserver mit ASP.NET Core, im Modul „Web und Desktop“. Es baut auf denselben Sockets und Threads auf.

Übung

Server auf localhost

Löse die Aufgaben in deinem Editor. Server und Client können in einem Programm sein (der Server in einem Thread) oder in zwei getrennten, die du in zwei Terminals startest.

1. Echo-Server. Der Server schickt jede Zeile mit „Echo: “ davor zurück, bis er „ende“ bekommt. Dann antwortet er „Auf Wiedersehen“ und schließt die Verbindung. Der Client sendet „hallo“, „wie geht es dir“ und „ende“ und gibt die Antworten aus:

Ausgabe
Echo: hallo
Echo: wie geht es dir
Auf Wiedersehen

2. Ein Server mit Befehlen. GROSS Text gibt den Text in Großbuchstaben zurück, ZEIT gibt die aktuelle Uhrzeit als 14:05 zurück (DateTime.Now.ToString("HH:mm")), und alles andere gibt „Unbekannter Befehl: “ und den Befehl zurück. Die Uhrzeit ändert sich, deshalb prüft der Client nur ihre Form mit TimeOnly.TryParseExact(time, "HH:mm", out _):

Ausgabe
BESTELLUNG 42 IST FERTIG
Die Uhrzeit hat die Form HH:MM: True
Unbekannter Befehl: UHRZEIT
3. Ein Chat für zwei. Anna öffnet den Server auf Port 5977, Jonas verbindet sich. Jeder ist in seinem eigenen Thread und sendet seine vorbereiteten Zeilen, wobei er nach jeder auf eine Antwort wartet:
Ausgabe
Jonas erhielt: Hallo Jonas! Hast du um 18 Uhr Zeit?
Anna erhielt: Ja, ich komme.
Jonas erhielt: Super, bis gleich.
Anna erhielt: Tschüss!
Dann mach es echt: zwei Programme, jedes mit zwei Threads. Ein Thread liest von der Tastatur und sendet, der andere liest vom Socket und gibt aus. So kannst du tippen, während du auf eine Antwort wartest.

Test

Teste dich

1. Worauf zeigt der Port?
2. Wann ist UDP die bessere Wahl als TCP?
3. Was macht server.AcceptTcpClient()?
4. Der Client schreibt mit WriteLine, aber der Server bekommt nichts, und beide warten. Was ist die häufigste Ursache?
5. Die erste Zeile der Antwort ist HTTP/1.1 404 Not Found. Was bedeutet das?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·