Nach der etwas längeren Osterpause geht es nun weiter mit meinen Artikeln. Im ersten Teil haben wir uns mit einem kurzen Konzept auseinandergesetzt. Dieser Artikel beschreibt nun, wie wir einen Chatserver aufsetzten können.

Sockets – TCP oder UDP

Mit Sockets wird eine Verbindung zwischen netzwerkfähigen Geräten hergestellt. Als Protokolle zur Datenübertragung stehen UDP und TCP zur Verfügung.

TCP ist ein verbindungsorientiertes Protokoll. Dies bedeutet, dass das Protokoll Mechanismen besitzt, welche die Datenübertragung sicherstellen. Es wird geprüft, ob alle Daten korrekt übertragen wurden. Falls Pakete fehlerhaft sind oder fehlen, werden diese von Neuem angefordert.

UDP hingegen kümmert sich nicht um die Vollständigkeit der Daten. Es fehlen Sicherheitsmechanismen, sodass fehlerhafte Pakete gelöscht und nicht empfangene Paket nicht erneut übertragen werden. Der Vorteil liegt hierbei in einer höheren Übertragungsgeschwindigkeit[1].

Um sicherzugehen, dass alle Daten und Nachrichten korrekt ankommen verwenden wir eine TCP-Verbindung. Da die Pakete relativ klein sind, können wir die Übertragungsgeschwindigkeit ignorieren.

Befehlsstruktur für Funktionen

Da es sich um eine Konsolenanwendung handelt, haben wir keine grafische Schnittstelle, um Befehle und Daten über Tastendruck anzufragen. Daher soll der Server ähnlich wie bei IRC (Internet-Relay-Chat) funktionieren. Befehle fangen mit einem Schrägstrich (/) an. Text ohne Schrägstrich wird als normaler Chat-Text, der an alle Teilnehmer gesendet wird, behandelt[2].

Angelehnt an die Liste der benötigten Funktionen aus dem ersten Teil, werden folgende Funktionen definiert:

  • /login [Benutzername] = Meldet den Client mit dem Benutzernamen an
  • /logoff = Meldet den aktuellen Benutzer vom Server ab
  • /getuser = Liefert eine Liste aller zurzeit angemeldeten Personen
  • /whisper [Benutzername] [Nachricht] = Schreibt eine private Nachricht an Benutzername
  • /help = Zeigt eine Hilfe zu den aktuellen Befehlen an

Mehrbenutzerbetrieb durch Threads

Die Konsolenanwendung ist ein Prozess und führt einen Thread aus, welcher die Ein- und Ausgabe der Konsole verarbeitet. Wenn innerhalb der Konsole ein Socket geöffnet wird und der Konsolen-Thread auf eine Eingabe des Benutzers wartet, ist der Server blockiert. Möchte sich nun ein zweiter Client anmelden ist dies nicht möglich. Der erste Socket wartet auf Benutzereingaben und blockiert die weitere Behandlung.

Als Lösung dieses Problems verwenden wir Threads. Ein Thread kann innerhalb eines Prozesses erzeugt werden und auf die Ressourcen zugreifen. Mittels Threads kann ein Prozess verschiedene Aufgaben parallel erledigen. Echtes paralleles Arbeiten kommt jedoch nur zustande, wenn der Computer auch einen Mehrkernprozessor besitzt.

Parallele Programmierung ist in der Regel nicht trivial. Vor allem, wenn es um gemeinsam genutzte Ressourcen geht. Unter Umständen kann sehr schnell ein Deadlock entstehen[3]. Abgesehen von der Benutzerliste wird keine Ressource direkt durch mehrere Threads in Anspruch genommen, sodass die Gefahr eines Deadlocks nicht gegeben sein sollte. Aber wie bei jedem Multithreading-Projekt sollte immer darauf geachtet werden.

Im Hinblick auf unseren Server gibt es eine Komponente, die für jeden Client einen eigenen Thread startet. Diese Threads kommunizieren mit dem Server nur, wenn ein neuer Client sich angemeldet hat und der Benutzername erfasst werden muss. Ansonsten wartet der Client auf eine Nachricht und leitet diese direkt an den Server weiter, der diese Verarbeitet.

Umsetzung

Erst wird der Server gestartet und wartet auf eine Anfrage vom Client. Nachdem eine Verbindung mit dem Client hergestellt wurde, wartet ein Thread vom Server auf eine Nachricht vom Client. Je nach Nachricht wird eine bestimmte Aktion durchgeführt. Dies ist exemplarisch in folgender Grafik dargestellt:

Links befindet sich der Server, rechts ein Client. Die Zeit ist auf der vertikalen Achse abgebildet. Die Pfeile zwischen den Zeitstrahlen geben Aktionen bzw. Nachrichten an, die vom Client oder Server ausgeführt werden. Zu beachten ist hierbei, dass „Nachricht“ stellvertretend für Chatnachrichten als auch Befehle (z.B. /help) steht. Ein Client kann, nachdem er sich erfolgreich angemeldet hat, beliebig viele Nachrichten senden.

Serverseitig wird aus der Main-Methode ein ServerHandler gestartet. Diese Klasse verwaltet die Clients und reagiert auf die Nachrichten und Befehle. Intern erstellt dieser die Threads für die jeweiligen Clients. Jeder verbundene Client erhält einen eigenen Thread. Dies ist kurz in nachfolgendem Quellcode skizziert:

// ServerHandler.cs
// Listener erstellen, welcher auf eine Verbindungsanfrage wartet
TcpListener lListener = new TcpListener(IPAddress.Any, _port);
lListener.Start();

// Durch AcceptTcpClient wird auf eine Anfrage von einem Client gewartet
TcpClient lClientSocket = lListener.AcceptTcpClient();

// Den Client an die eigene Klasse übergeben und einen Thread starten, welcher
// diesen Client verwaltet
AcceptedClient lClient = new AcceptedClient(lClientSocket, this);
Thread lClientThread = new Thread(lClient.WaitForRequest);
lClientThread.Start();

Die Methode WaitForRequest() wartet auf Eingaben vom Client. Wenn diese mit einem Schrägstrich beginnen, wird die Servermethode HandleCommand() aufgerufen. Somit wird speziell in dieser Methode auf alle Befehle wie /getuser und /help reagiert. Die andere Methode BroadcastMessage() sendet die Nachricht vom Client an alle verbundenen Clients.

public void HandleCommand(string xMessage) {
  switch (xMessage.ToLower()) {
    case "help":
      // Befehl Help verarbeiten
      break;

    case "getuser":
      // Befehl GetUser verarbeiten
      break;

    // .. weitere Befehle ..
		
    default:
      // Befehl nicht vorhanden. Fehlermeldung ausgeben
      break;
  }
}

Damit der Server weiß, welche Clients verbunden sind (und somit ein Broadcast überhaupt möglich wird), führt dieser intern eine Liste. Bei der Implementierung nutzen wir ein Dictionary. Ein Dictionary ist wie eine Liste, jedoch kann über einen Schlüssel (key) direkt auf die Einträge zugegriffen werden. Bei einer Liste gibt es nur einen Index. Der jeweilige Client müsste manuell in der Liste gesucht werden.

Der Server verwendet den Benutzernamen als eindeutigen Schlüssel. Dies bietet den Vorteil, dass jeder Client mit seinem “Namen” ansprechbar ist und auch die Funktion für Privatnachrichten einfacher realisiert werden kann. Ist der Benutzername (key) vorhanden, wird die Nachricht an den Client gesendet. Gibt es den Namen nicht, gibt der Server eine Fehlermeldung aus.

In der Klasse AcceptedClient kommen die Klassen StreamReader und StreamWriter zum Einsatz. Diese dienen dem Lesen und Schreiben von Textnachrichten des Clients. Beim Versenden von Nachrichten mittels StreamReader.WriteLine() ist zu beachten, dass die Methode Flush() aufgerufen werden muss. Erst dadurch werden die Daten an den Client gesendet. Ohne den Aufruf dieser Methode wartet der Stream, bis der Puffer voll ist. Ist der Puffer voll, werden die Daten automatisch gesendet. Mit Flush() wird das Entleeren des Puffers manuell erzwungen. Details zur Pufferung und den Klassen findet ihr im MSDN

Ausblick: Der Client

Der Server beinhaltet die Logik zur Kommunikation und Verwaltet die Clients. Der Client muss im Prinzip nicht mehr können, als Text zu senden und zu empfangen. In dem Artikel werde ich noch etwas stärker auf die Threads eingehen. Mit Abschluss des Clients ist auch das Projekt Chat fast schon am Ende angekommen.

[1]: Vgl. Mandl, P., Bakomenko, A., Weiss, J. (2010) – Grundkurs Datenkommunikation: TCP/IP-basierte Kommunikation: Grundlagen, Konzepte und Standards . Vieweg+Teubner Verlag S. 332
[2]: https://de.wikipedia.org/wiki/Internet_Relay_Chat#Nutzerinduzierte_Befehle
[3]: Vgl. Mandl, P. (2014) – Grundkurs Betriebssysteme: Architekturen, Betriebsmittelverwaltung, Synchronisation, Prozesskommunikation, Virtualisierung. Springer Vieweg S. 77ff.