Networking mit C#

Veröffentlicht: 01. Okt 2002 | Aktualisiert: 10. Nov 2004

Von Axel Goldbach

Diese Artikelreihe gibt einen Überblick über TCP/IP-basiertes Networking mit C#. Der erste Artikel bietet eine Einführung in die Netzwerkkommunikation im Allgemeinen und TCP (Transmission Control Protocol) im Speziellen. Der zweite Artikel beschäftigt sich mit UDP (User Datagram Protocol) und dessen Möglichkeiten. Der dritte und letzte Artikel zeigt, wie man mit dem Wissen aus den vorangegangenen Artikeln eine P2P-Anwendung (Peer-To-Peer) entwickeln kann.

Auf dieser Seite

 Netzwerkkommunikation
 Sockets und Protokoll-Stacks
 Das Transmission Control Protocol (TCP)
 Ports
 Basics
 Der Zeitgeber-Server
 Der Zeitgeber-Client
 Kompilieren und starten
 Fazit und Ausblick

Zur Homepage von dot.net magazin

Diesen Artikel können Sie dank freundlicher Unterstützung von dot.net magazin auf MSDN Online lesen. dot.net magazin ist ein Partner von MSDN Online.

Zur Partnerübersichtsseite von MSDN Online

In Zeiten von CORBA, Remoting, SOAP etc. mutet Netzwerkkommunikation auf TCP/IP-Ebene für manche Leute archaisch an. Dieses Rumschicken von einzelnen Bytes oder Byte-Strömen ist ziemlich steinzeitlich. Ach, ne, lass' mal.

Doch plötzlich hat man den Auftrag, einen simplen Zeitgeber-Server zu schreiben. Also ein kleines Programm, das sogar noch auf dem UNIX-Fileserver des Unternehmens laufen soll. Es soll alle Uhren auf den Arbeitsplatzrechnern beim Booten synchronisieren oder einheitliche Zeitstempel für Transaktionen generieren.

Also her mit CORBA, das ist ja plattformunabhängig. Aber nun mal ehrlich: Schießt man da nicht mit Kanonen auf Spatzen, wenn man an die im Verhältnis zur eigentlichen Applikation riesige CORBA-Laufzeitumgebung denkt? Für solche Fälle bietet sich dann doch das altertümliche Networking mit TCP/IP an.

Netzwerkkommunikation

Networking kann allgemein als Interprozesskommunikation bezeichnet werden. Zwei oder mehr Prozesse (z.B. Applikationen) kommunizieren miteinander. Die Prozesse können auf demselben oder verschiedenen Computern oder auch anderen Maschinen laufen.

Verbunden werden diese Maschinen durch ein Netzwerk. Somit können die Maschinen als Knoten in einem Kommunikationsnetz angesehen werden. Verbindungen zwischen den Netzwerkknoten werden meistens per Kabel hergestellt. Ein Beispiel dafür ist das so genannte Local Area Network (LAN), das in Unternehmen innerhalb eines Hauses eingesetzt wird. Auch das Internet funktioniert teilweise über Kabel (z.B. Telefon).

Andere Formen von Netzwerken arbeiten ohne Kabel (engl. wireless) per Funk (z.B. Mobiltelefone, Wireless LAN, Internet, Bluetooth) oder per Infrarotlicht (z.B. Infrarotkommunikation zwischen Mobiltelefon und Laptop). Abbildung 1 zeigt ein Beispiel mit verschiedenen Geräten und Kommunikationsmedien.

Bild01

Abb. 1: Ein Szenario mit verschiedenen Geräten und Kommunikationsmedien

Für die Kommunikation werden allerdings nicht nur Transportmedien wie elektrischer Strom in Kabeln oder elektromagnetische Wellen wie Funk oder Licht benötigt. Damit zwei Prozesse sich verstehen, müssen sie dieselbe Sprache sprechen, also gleiche Kommunikationsprotokolle verwenden. Die heute am häufigsten verwendeten Protokolle auf Netzwerkebene stammen aus der Protokollfamilie TCP/IP.

Eine Protokollfamilie ist eine Sammlung mehrerer Kommunikationsprotokolle, die es miteinander verbundenen Computern erlaubt, über ein Netzwerk zu kommunizieren und Ressourcen zu teilen.

Das Transmission Control Protocol (TCP) und das Internet Protocol (IP) sind nur zwei der Protokolle. Es sind aber die am häufigsten verwendeten Protokolle und so bekam die Familie ihren Namen. Das User Datagram Protocol (UDP), welches ich im nächsten Artikel vorstellen werde, ist ebenfalls Teil von TCP/IP. Welche Protokolle vom .NET Framework unterstützt werden, kann man in der .NET Reference nachlesen und zwar in der Dokumentation der Klasse System.Net.Sockets.Socket.

 

Sockets und Protokoll-Stacks

Um TCP/IP-basierte Netzwerke nutzen zu können, benötigt man so genannte Sockets. Ein Socket ist eine Programmierschnittstelle und Kommunikationsendpunkt, der von einem Programm verwendet wird, um sich mit einem anderen Programm auf einem anderen oder demselben Computer zu verbinden und Daten zu senden und zu empfangen.

Sockets wurden für das UNIX der Berkeley Universität entwickelt. Das ist der Grund warum Sockets oft Berkeley Sockets genannt werden. Man kann sie als Steckdose verstehen, wie der Name Socket (engl. Begriff für Steckdose) bereits andeutet. So wie man den Stecker seines Radios in die Steckdose steckt und es damit mit dem Stromnetzwerk verbindet, um es mit Energie zu versorgen, kann ein Programm sich mit einem Netzwerk verbinden, um sich mit Daten zu versorgen. Abbildung 2 zeigt schematisch die prinzipielle Architektur der TCP/IP-basierten Kommunikation per Sockets.

Bild02

Abb. 2: Schematische Kommunikationsarchitektur mit TCP/IP

Generell können Sockets in drei Arten unterteilt werden:

  • Raw-Sockets: Dieser Typ wird in der Netzwerkschicht heutiger Protokoll-Stacks verwendet (siehe Abb. 3). Ein Raw-Socket ist sehr nahe an den Funktionalitäten, die das eigentliche Netzwerk (z.B. Ethernet) zur Verfügung stellt. Ein Beispiel für solch ein Protokoll in der Netzwerkschicht ist IP.

  • Datagram-Sockets: Datagramme sind Datenpakete, d.h. Datagram-Sockets verschicken Daten paketweise. Dieser Socket-Typ wird in der Transportschicht verwendet (siehe Abb. 3). Die Zuordnung kann nicht prinzipiell gemacht werden, da z.B. IP auch Datagramm-orientiert arbeitet. Ein Beispiel für ein Datagramm-orientiertes Protokoll in der Transportschicht ist UDP.

  • Stream-Sockets: Diese Art von Sockets ermöglichen im Gegensatz zu Datagram-Sockets den Zugriff auf einen Datenstrom. Daten werden in Form einer langen Kette von Bytes versendet oder empfangen. Genau das macht die Arbeit mit solchen Sockets sehr einfach. Ein Beispiel für eine Stream-orientiertes Protokoll in der Transportschicht ist TCP.

Wie schon erwähnt verwenden moderne Kommunikationsarchitekturen einen Stapel verschiedener Protokolle (engl. Protocol Stack). Daten werden auf der einen Seite der Kommunikationsstrecke der obersten Schicht des Stapels übergeben. Die Daten wandern nun von der obersten Schicht abwärts durch die Schichten.

Jede Protokollschicht fügt protokollspezifische Informationen hinzu. Die niedrigste Schicht sendet nun die Daten zur anderen Seite der Kommunikationsstrecke. Dort werden die Daten von der untersten Schicht bis zur obersten durchgereicht und in jeder Schicht die protokollspezifischen Informationen wieder entfernt. Abbildung 3 zeigt beispielhaft solch einen Protokoll-Stack.

Bild03

Abb. 3: Ein Protokoll-Stack

 

Das Transmission Control Protocol (TCP)

TCP ist ein zuverlässiges, Verbindungs- und Stream-orientiertes Punkt-zu-Punkt-Protokoll (zwischen einem Client und einem Server). Uh, was für grässlicher Tazzelwurm. Das kann man auch nur verstehen, wenn man schon weiß, um was es geht.

Also verständlicher: Die TCP-Kommunikation ist so etwas wie ein Telefonanruf. Ein Freund von mir (der Client) möchte also mit mir (dem Server) telefonieren. Er hebt den Hörer seines Telefons ab und wählt meine Telefonnummer. Es wird also dadurch eine Verbindung zwischen den beiden Telefonen hergestellt (Punkt-zu-Punkt).

Ich bin zu Hause, mein Telefon klingelt, ich hebe ab und wir reden miteinander. Die Telefongesellschaft garantiert uns, dass die Worte, die ich spreche, alle und in der richtigen Reihenfolge bei meinem Freund ankommen (Zuverlässigkeit). Solange wir beide am Telefon sind, kann jeder von uns kontinuierlich sprechen (Stream-Orientiertheit).

Die Verbindung bleibt so lange bestehen, bis einer von uns auflegt. Dann und erst dann ist die Verbindung beendet. Sie wird auch während des Gesprächs nicht abgebaut und wieder aufgebaut (Verbindungsorientiertheit).

Da ist doch bestimmt ein Haken bei so viel tollen Eigenschaften. Der Haken bei TCP ist die Performance. Bitte nicht falsch verstehen: TCP ist keine lahme Schnecke, es ist verdammt schnell. Es gibt aber in der TCP/IP-Familie schnellere Protokolle wie z.B. UDP. Allerdings erkauft man sich die bessere Performance dadurch, dass Eigenschaften wie z.B. Zuverlässigkeit nicht mehr vorhanden sind.

TCP nutzt IP als Netzwerkprotokoll. IP ist Datagramm-orientiert und ein so genanntes Best-Effort-Protokoll. Best-Effort bedeutet, dass die Datenpakete ohne eine Garantie verschickt werden, dass sie korrekt, vollständig und in der richtigen Reihenfolge ankommen (UDP ist auch ein Best-Effort-Protokoll). Das wiederum bedeutet, dass TCP einen erhöhten Aufwand treiben muss, seine Eigenschaften zu garantieren.

TCP muss Datenströme und stete Verbindungen simulieren und die Reihenfolge von Paketen durch Nummerierung garantieren. Falls ein Paket verloren geht oder verändert ankommt, muss es das Paket neu senden oder wieder anfordern. Diese Überprüfung wird mit Prüfsummen durchgeführt. Das Resultat aller dieser Aktivitäten ist natürlich eine verminderte Performance.

Für viele Anwendungen sind die Eigenschaften von TCP aber sehr wichtig bzw. unerlässlich. Bei SMTP, dem Simple Mail Transfer Protocol, ist es sehr wichtig, dass eine Mail, die mit "Hallo mein Freund" beginnt, beim Empfänger nicht als "Mixl frundumpf" ankommt. Abbildung 4 zeigt schematisch am Beispiel von HTTP die verschiedenen Schichten der verwendeten Protokoll-Stacks.

Bild04

Abb. 4: Protokoll-Stack mit zuverlässigen und Best-Effort-Schichten

 

Ports

Bevor wir praktisch loslegen können, müssen wir noch eine Kleinigkeit lernen: Ports. Die meisten Computer haben nur eine physikalische Verbindung zu einem Netzwerk. Wenn aber alle Daten nur durch eine Verbindung hereinkommen, wie kann man dann bestimmen, welche Anwendung die ihr bestimmten Daten bekommt? Die Antwort ist: Durch Verwendung von Ports.

Ein Port wird durch eine 16-bit Zahl repräsentier, also eine Zahl zwischen 0 und 65535. Allgemein bekannte Ports sind z.B. Port 80 (hexadezimal) für HTTP, Port 25 (hexadezimal) für eMail oder Port 23 (hexadezimal) für Telnet.

Eine mit dem Netzwerk verbundene Anwendung muss an mindestens einen Port gebunden sein, um Daten empfangen zu können. Gebunden bedeutet, dass ein Port einem Socket zugewiesen wird, der wiederum von der Anwendung verwendet wird. Alle eingehenden Datenpakete, die diese Port-Nummer in ihrem Header enthalten, werden dann automatisch an diesen Socket weitergegeben (siehe Abb. 5).

Bild05

Abb. 5: Ports

Dies bedeutet nicht, dass nur ein Socket an einen Port gebunden werden kann. Wenn ein Socket auf eine eingehende Verbindung wartet, dann ist der zugehörige Port normalerweise für andere Anwendungen blockiert. Normalerweise bedeutet, dass diese Eigenschaft abgeschaltet werden kann (siehe hierzu die .NET-Referenz zur Methode System.Net.Sockets.Socket.SetSocketOption()).

Ein Socket, der auf eine eingehende Verbindung wartet, wird manchmal auch Server-Socket genannt. Falls ein Verbindungswunsch von einem Server-Socket akzeptiert wurde, erzeugt er einen neuen Socket, der die Verbindung repräsentiert. Somit kann der Server-Socket auf neue Verbindungswünsche warten. Damit ist es möglich, dass mehrere Clients gleichzeitig über einen Port mit dem Server kommunizieren.

Ein Beispiel für eine Anwendung, die diese Eigenschaft nutzt, ist ein Webserver. Wenn ein Browser bei einem Webserver eine Seite erfragt, kann ein weiterer Browser auch eine Seite erfragen, während der Server noch am Versenden der ersten Seite ist.
So, genug der Theorie. Bauen wir jetzt unseren Zeitgeber-Server und -Client, die TCP als Protokoll verwenden.

 

Basics

In unserem Beispiel verwenden wir nicht direkt die Klasse System.Net.Sockets.Socket. Das hat zwei Gründe. Zum einen ist es nicht trivial, mit Sockets zu arbeiten: Man sollte sich schon recht gut mit den Socket-Konzepten und den unterstützten Kommunikationsprotokollen auskennen. Zum anderen bietet das .NET Framework uns zwei Klassen, die die Arbeit mit TCP dermaßen vereinfachen, dass man einfach zugreifen muss.

Die zentralen Klassen für den Zeitgeber-Server sind System.Net.Sockets.TcpListener und System.Net.Sockets.TcpClient. TcpListener ist eine Klasse, die auf einen TCP-Verbindungswunsch eines Clients wartet. Wird der Verbindungswunsch akzeptiert, gibt die Klasse ein Objekt vom Typ TcpClient zurück, mit dem dann die Kommunikation durchgeführt wird. Listing 1 zeigt den Code, der für Kommunikation zuständig ist.

Listing 1

// Listener initialisieren
TcpListener listener = new TcpListener ( 4711 );
// Listener starten
listener.Start ();
// Warten bis ein Client die Verbindung wünscht
TcpClient c = listener.AcceptTcpClient ();
// An dieser Stelle ist der Listener wieder bereit, 
// einen neuen Verbindungswunsch zu akzeptieren
// Stream für lesen und schreiben holen
Stream inOut = c.GetStream ();
// Hier kann in den Stream geschrieben werden
// oder aus dem Stream gelesen werden
// Verbindung schließen
c.Close ();
// Listener beenden
listener.Stop ();

Für den Zeitgeber-Client wird nur die Klasse System.Net.Sockets.TcpClient gebraucht. Der Code für den Client sieht dem des Servers sehr ähnlich:

// Client initialisieren und mit dem Server verbinden
TcpClient c = new TcpClient ( "localhost", 4711 );
// Stream für lesen und schreiben holen
Stream inOut = c.GetStream ();
// Hier kann in den Stream geschrieben werden
// oder aus dem Stream gelesen werden
// Verbindung schließen
c.Close ();

Das war's eigentlich schon. So simpel ist Networking. Wir haben damit schon alles, was wir für das Versenden von Daten benötigen. Aber für uns ist das natürlich viel zu einfach. Wir bauen uns lieber gleich einen richtigen Server, der mehrere Clients gleichzeitig bedienen kann.

In diesem Artikel wird nur Code gezeigt, bei dem der Client die Verbindung aufbaut und vom Server Daten empfängt. Dies funktioniert natürlich auch in der anderen Richtung. Wie z.B. bei HTTP kann der Client nach dem Verbindungsaufbau dem Server einen Request schicken, ihm also sagen, was er überhaupt haben will. Die Methode TcpClient.GetStream() gibt einen bidirektionalen Stream zurück, also ein Stream, der sowohl zum Lesen wie auch zum Schreiben verwendet werden kann. Was nach dem Verbindungsaufbau geschieht, entscheiden komplett Sie bzw. das Protokoll, welches in der Schicht über TCP läuft.

Der weitere, über die Basics hinausgehende Code, den ich jetzt vorstellen werde, dient nur der Funktionalität des Zeitgeber-Servers bzw. -Clients.

 

Der Zeitgeber-Server

Die Architektur des Servers ist ähnlich einem Web-Server und sieht folgendermaßen aus: Der Server startet einen TcpListener und wartet auf einen Verbindungswunsch. Ist dieser akzeptiert worden, startet der Server einen Thread, der den Client bedient (siehe Abb. 6). Der Server steht dann wieder für einen neuen Verbindungswunsch eines anderen Clients zur Verfügung.

Bild06

Abb. 6: Verbindungsaufbau und Kommunikation

Werfen wir nun einen Blick auf die Zeitgeber-Server-Klasse TimeServer. Das Code-Segment in Listing 2 zeigt die Klasse ohne den eigentlichen Server-Code.

Listing 2

using System;
using System.Text;
using System.Collections;
using System.Threading;
using System.IO;
using System.Net.Sockets;
public class TimeServer
{
  // Der Listener
  private static TcpListener listener = null;
  // Die Liste der laufenden Server-Threads
  private static ArrayList threads = new ArrayList ();
  // Die Hauptfunktion des Servers
  public static void Main ()
  {
    ...
  }
  // Hauptthread des Servers
  // Nimmt die Verbindungswünsche von Clients entgegen
  // und startet die Server-Threads für die Clients
  public static void Run ()
  {
    ...
  }
}

Nun zur Methode Main(). Diese Methode initialisiert einen TcpListener auf Port 4711 und startet ihn. Danach wird der Haupt-Server-Thread initialisiert und gestartet. Der Listener wird von dem Haupt-Server-Thread verwendet, dazu aber später. Dann wird in einer Schleife auf den Stoppbefehl gewartet, den ein Benutzer direkt in der Konsole eingeben kann. Nachdem der Stoppbefehl eingegeben wurde, stoppt die Main()-Methode den Haupt-Server-Thread und dann alle Server-Threads, die die Clients bedienen. Als letztes wird der TcpListener gestoppt (siehe Listing 3).

Listing 3

public static void Main ()
  {
    // Listener initialisieren und starten
    listener = new TcpListener ( 4711 );
    listener.Start ();
    // Haupt-Server-Thread initialisieren und starten
    Thread th = new Thread ( new ThreadStart ( Run ) );
    th.Start ();
    // Benutzerbefehle entgegennehmen
    String cmd = "";
    while ( !cmd.ToLower ().Equals ( "stop" ) )
    {
      cmd = Console.ReadLine ();
      if ( !cmd.ToLower ().Equals ( "stop" ) )
        Console.WriteLine ( "Unbekannter Befehl: " + cmd );
    }
    // Haupt-Server-Thread stoppen
    th.Abort ();
    // Alle Server-Threads stoppen
    for ( IEnumerator e = threads.GetEnumerator (); e.MoveNext (); )
    {
      // Nächsten Server-Thread holen
      ServerThread st = (ServerThread)e.Current;
      // und stoppen
      st.stop = true;
      while ( st.running )
        Thread.Sleep ( 1000 );
    }
    // Listener stoppen
    listener.Stop ();   
   }

Als nächstes wenden wir uns der Methode Run() zu. Diese Methode entspricht dem Haupt-Server-Thread. Sie wartet, bis ein Client einen Verbindungswunsch anmeldet. Danach initialisiert sie einen Server-Thread, der den Client bedient, und startet diesen. Als letztes fügt sie den Thread zu der Liste der Server-Threads hinzu, sodass der Server beim Herunterfahren alle noch laufenden Threads stoppen kann. Später werden wir sehen, dass die Run()-Methode in Verbindung mit den Server-Threads tatsächlich die Eigenschaft hat, mehrere Clients gleichzeitig zu bedienen:

public static void Run ()
  {
    while ( true )
    {
      // Wartet auf eingehenden Verbindungswunsch
      TcpClient c = listener.AcceptTcpClient ();
      // Initialisiert und startet einen Server-Thread
      // und fügt ihn zur Liste der Server-Threads hinzu
      threads.Add ( new ServerThread ( c ) );
    }
  }

Der Server-Thread, der den Client bedient, wird durch die Klasse ServerThread repräsentiert. Der Konstruktor bekommt einen TcpClient, der die Verbindung zum Client darstellt. Danach initialisiert und startet der Konstruktor den eigentlichen Server-Thread (siehe Listing 4).

Listing 4

class ServerThread
{
  // Stop-Flag
  public bool stop = false;
  // Flag für "Thread läuft"
  public bool running = false;
  // Die Verbindung zum Client
  private TcpClient connection = null;
  // Speichert die Verbindung zum Client und startet den Thread
  public ServerThread ( TcpClient connection )
  {
    // Speichert die Verbindung zu Client,
    // um sie später schließen zu können
    this.connection = connection;
    // Initialisiert und startet den Thread
    new Thread ( new ThreadStart ( Run ) ).Start ();
  }
  // Der eigentliche Thread
  public void Run ()
  {
    ...
  }
}

Die Run()-Methode sendet in einer Schleife immer wieder die aktuelle Zeit an den Client. Die Schleife bricht ab, wenn entweder von außen das Stopflag gesetzt wird oder wenn während des Versendens ein Fehler aufgetreten ist. Wenn die Schleife verlassen wird, wird schließlich die Verbindung geschlossen und das Flag "Thread läuft" zurückgesetzt (siehe Listing 5).

Listing 5

public void Run ()
  {
    // Setze Flag für "Thread läuft"
    this.running = true;
    // Hole den Stream für's schreiben
    Stream outStream = this.connection.GetStream ();
    String buf = null;
    bool loop = true;
    while ( loop )
    {
      try
      {
        // Hole die aktuelle Zeit als String
        String time = DateTime.Now.ToString ();
        // Sende Zeit nur wenn sie sich von der vorherigen unterscheidet
        if ( !time.Equals ( buf ) )
        {
          // Wandele den Zeitstring in ein Byte-Array um
          // Es wird noch ein Carriage-Return-Linefeed angefügt
          // so daß das Lesen auf Client-Seite einfacher wird
          Byte[] sendBytes = Encoding.ASCII.GetBytes ( time + "\r\n" );
          // Sende die Bytes zum Client
          outStream.Write ( sendBytes, 0, sendBytes.Length );
          // Merke die Zeit
          buf = time;
        }
        // Wiederhole die Schleife so lange bis von außen der Stopwunsch kommt
        loop = !this.stop;
      }
      catch ( Exception )
      {
        // oder bis ein Fehler aufgetreten ist
        loop = false;
      }
    }
    // Schließe die Verbindung zum Client
    this.connection.Close ();
    // Setze das Flag "Thread läuft" zurück
    this.running = false;
  }

Das war der Server. Im Nachhinein betrachtet ist die Komplexität des Codes nicht durch die Kommunikation entstanden, sondern nur durch das Feature der Thread-Based Multi-Client Connectivity. Hört sich toll an, oder? Entscheiden Sie, wie viele Buchstaben die Technologieabkürzung hat.

 

Der Zeitgeber-Client

Der Zeitgeber-Client wird durch die Klasse TimeClient repräsentiert. Diese Klasse ist ganz einfach gehalten und hat nur eine Main()-Methode:

using System;
using System.IO;
using System.Net.Sockets;
public class TimeClient
{
  public static void Main ()
  {
    ...
  }
}

Die Main()-Methode sieht abgesehen von einer Endschlosschleife nicht viel anders aus als der Client-Code im Abschnitt Basics. Die Schleife sorgt dafür, dass, solange die Verbindung zu Server besteht und dieser die Server-Uhrzeit sendet, diese gelesen und auf die Konsole ausgegeben wird. Bei dem Client habe ich auf einen Thread verzichtet, sodass dieses kleine Programm nur mit CTRL-C abgebrochen werden kann bzw. sich selbst beendet, wenn der Server aufhört zu senden (siehe Listing 6).

Listing 6

public static void Main ()
  {
    // Verbindung zum Server aufbauen
    TcpClient c = new TcpClient ( "localhost", 4711 );
    // Stream zum lesen holen
    StreamReader inStream = new StreamReader ( c.GetStream () );
    bool loop = true;
    while ( loop )
    {
      try
      {
        // Hole nächsten Zeitstring vom Server
        String time = inStream.ReadLine ();
        // Setze das Schleifen-Flag zurück
        // wenn der Server aufgehört hat zu senden
        loop = !time.Equals ( "" );
        // Gib die Zeit auf der Console aus
        Console.WriteLine ( time );
      }
      catch ( Exception )
      {
        // Setze das Schleifen-Flag zurück
        // wenn ein Fehler in der Kommunikation aufgetreten ist
        loop = false;
      }
    }
    // Schließe die Verbindung zum Server
    c.Close ();
  }

Ich verwende hier die Klasse System.IO.StreamReader, um das Lesen aus dem Stream zu vereinfachen. Wie wir vom Server-Code wissen, wird der Zeit-String mit einen Carriage Return Linefeed abgeschlossen. Der StreamReader besitzt eine ReadLine()-Methode, die so lange aus dem Stream liest, bis ein Zeilenumbruch gefunden wird. Dies ist eine Methode, die auch bei Internet-Protokollen wie HTTP verwendet wird, um einzelne Zeilen von einander zu trennen.

 

Kompilieren und starten

Die Klassen TimeServer und TimeServerThread seien in einer Datei namens TimeServer.cs abgelegt, die Klasse TimeClient in einer Datei namens TimeClient.cs.
Wir kompilieren die beiden Dateien mit den Befehlen csc TimeServer.cs bzw. csc TimeClient.cs. Diese Befehle gibt man einfach in der Konsole (auch DOS-Eingabeaufforderung genannt) ein. csc.exe ist der C# Compiler. Ist das .NET Framework korrekt installiert, existiert ein Pfad auf das Verzeichnis, in dem der C# Compiler liegt.

Wenn beim Kompilieren keine Fehler aufgetreten sind, haben wir nun in demselben Verzeichnis, in dem unser Source-Code liegt, zwei Dateien namens TimeServer.exe und TimeClient.exe. Nun können wir unser kleines Beispiel starten. Als erstes muss der Server gestartet werden, was z.B. in der Konsole geschehen kann (siehe Abb. 7). Ich habe im Code für die laufenden Beispiele noch ein paar Kommentare eingefügt, die in die Konsole geschrieben werden. Sonst würde der Server sich etwas unspektakulär ohne jegliche Regung in der Konsole melden. So bekommt man ein gutes Server-Feeling.

Bild07

Abb. 7: Starten des Servers

Nun starten wir einen Client in einer neuen Konsole. Das Prozedere ist das gleiche wie beim Server. Der Zeitgeber-Client verbindet sich mit dem Server und zeigt sogleich etwa sekundenweise die Server-Zeit an.

Zuletzt testen wir die Thread-Based Multi-Client Connectivity unseres Servers. Einfach eine neue Konsole öffnen und den Client ein zweites Mal starten (siehe Abb. 8). Nun sehen wir, wie der Server den Verbindungswunsch des zweiten Clients annimmt und ihn bedient. Der erste Client läuft dabei weiter.

Bild08

Abb. 8: Ein Client

 

Fazit und Ausblick

Was war jetzt eigentlich das Tolle an dem, was wir hier programmiert haben? Mit, wie ich meine, sehr wenig Code haben wir einen Server geschrieben, der einen kleinen aber feinen Service anbietet und auch noch mehrere Clients gleichzeitig bedienen kann.

Was wesentlich mehr wiegt, ist die Tatsache, dass der Code für eine Server-Anwendung auf anderen Plattformen wie UNIX vom Prinzip her genau so einfach und genau so wenig umfangreich ist. In anderen Programmiersprachen (abseits von den .NET-Sprachen) hat man andere Klassen, die ein wenig anders zu programmieren sind. Aber mit dem Wissen aus diesem Artikel kann man beispielsweise auch einen Zeitgeber-Server in Java schreiben. Das Prinzip ist genau das gleiche, man muss nur die entsprechenden Klassen kennen.

Im nächsten Artikel stelle ich das Protokoll UDP vor und zeige, wie man damit Daten austauschen kann. Hinzu kommt ein weiterer sehr interessanter Aspekt von UDP, das so genannte Multicasting. Das ist die Fähigkeit eines Servers, Daten an eine Gruppe von Clients zu senden, ohne dass der Server diese kennen muss. Dieses Feature kann z.B. für einen News- oder Börsenticker verwendet werden. Also bis dann und viel Spaß mit den fast unbeschränkten Möglichkeiten des Networking.