Interprozesskommunikation / Sockets – Ein Chatprogramm in Python implementieren – Teil 2
Ziel dieses dreiteiligen Python-Tutorials ist es, ein eigenes kleines Chatprogramm zu schreiben, um zu verstehen, wie Sockets funktionieren. Im ersten Teil haben wir dazu die theoretischen Grundlagen kennengelernt und einen Server und einen Client, die sich rudimentär miteinander verbinden können, implementiert. Im zweiten Teil werden wir nun Nachrichten vom Client versenden. Der Server wird diese Nachrichten an alle anderen Clients, die aktuell mit ihm verbunden sind, weiterleiten. Dazu werden wir zuerst überlegen, wie der Client sich beim Server identifizieren kann, und anschließend den Client- und Servercode anpassen.
Den Sender der Nachricht identifizieren
Beginnen wir mit der Überlegung, wie wir dem Server mitteilen, wer wir sind. Der Absender einer Nachricht ist wie die Länge eine Metainformation und könnte daher als Header mitgesendet werden. Daher könnten wir einen Header mit acht Bytes für die Textlänge und danach einen weiteren Header mit 16 Bytes für den Nutzernamen zusammen mit der eigentlichen Nachricht verschicken. Sendet der Nutzer Max_Mustermann also beispielsweise die Nachricht „Hallo!“, würde sich insgesamt folgende Nachricht ergeben:
6 Max_Mustermann Hallo!
Passen wir dazu die Datei header_utils.py an, die wir in Teil 1 dieses Tutorials angelegt haben:
Wir haben nun nicht nur eine, sondern zwei
konstante Größen LENGTH_HEADER_SIZE und USER_HEADER_SIZE. Die
Methode format_message erwartet außerdem einen Parameter mehr, nämlich
den Nutzernamen. In der Methode werden beide Header mit dem entsprechenden
Padding erstellt und zusammen mit der Nachricht zurückgegeben.
Änderungen am Client
Nachdem wir die header_utils angepasst haben, können wir mit den Änderungen an Client und Server beginnen. Werfen wir zuerst einen Blick auf den Client. Aktuell haben wir folgenden Code:
Lagern wir zuerst die IP-Adresse und den Port in eigenen Kostanten aus und implementieren die Eingabe des Nutzernamens.
Da ein Nutzername eingegeben werden muss, verbleiben wir in einer Endlosschleife, bis der Nutzer einen Namen angegeben hat. Dadurch wird verhindert, dass die Eingabe durch Drücken der Enter-Taste übersprungen werden kann.
Beachten Sie, dass wir den Socket mit setblocking(False) außerdem auf nicht-blockierend setzen. Das bedeutet, dass wenn ein recv– oder send-Request fehlschlägt, der Socket nicht die weitere Ausführung des Codes verhindert, sondern stattdessen eine Exception wirft, auf die wir reagieren können.
Schreiben wir als Nächstes eine send- und eine receive-Methode, die jeweils eine der Richtungen des Kommunikationskanals behandeln.
Insofern der Nutzer tatsächlich eine Nachricht eingegeben hat, formatieren wir die Nachricht mit den header_utils und schicken sie kodiert über den Socket des Clients ab.
Zudem wäre es vorteilhaft, wenn der Nutzer sich ausloggen bzw. das Programm mit der Eingabe eines Befehls (beispielsweise [exit]) beenden könnte. Prüfen wir dazu zuerst, ob es sich bei der Eingabe um [exit] handelt. Falls ja, senden wir eine kurze Benachrichtigung, dass wir die Verbindung beenden wollen, an den Server und schließen den Socket und das Programm. Ansonsten verfahren wir wie bisher mit dem Versenden der Nachricht.
Schreiben wir als Nächstes die receive-Methode. Dazu können Sie den Code, der aktuell in der while-Schleife ausgeführt wird, als Ausgangspunkt verwenden.
In der Methode wird wieder zuerst ein Buffer für den ersten Header bereitgestellt und verarbeitet. Anschließend wird der Nutzername ausgelesen und zum Schluss die eigentliche Nachricht. Achten Sie darauf, dass die Variable username, die global vorliegt, nicht versehentlich beim Auslesen überschrieben wird.
Da unsere Sockets nicht-blockierend sind, kann es passieren, dass wir an dieser Stelle auf einen Fehler laufen, beispielsweise wenn wir keine Daten erhalten. Je nach Betriebssystem wird dies über die Errorcodes AGAIN oder WOULDBLOCK deklariert. In diesem Fall wollen wir den Client nicht beenden. In allen anderen Fällen soll der Client im Fehlerfall mit sys.exit() beendet werden. Fügen Sie daher einen try-except-Block ein. In diesem wird zuerst geprüft, ob es sich explizit um einen IOError mit den Errorcodes EAGAIN oder EWOULDBLOCK handelt. Ist dies nicht der Fall, oder läuft das Programm auf eine andere Exception als IOError, schließen wir den Socket und beenden den Client.
Wir schließen die Implementierung des Clients ab, indem wir in der while-Schleife sowohl send() als auch receive() aufrufen.
Implementierung des Servers
Fahren wir als Nächstes mit dem Server fort. Bisher haben wir folgenden Code geschrieben:
Zuerst lagern wir auch in dieser Datei IP-Adresse und Port in Konstanten aus. Außerdem benötigen wir eine Liste, in der wir alle uns bekannten Sockets speichern können. Wenn wir eine Verbindung annehmen, wird der neue Socket zu dieser Liste hinzugefügt. Die Nachricht, die wir bisher versendet haben, entfällt, genauso wie der listen()-Parameter.
Als Nächstes benötigen wir eine Methode receive, die Nachrichten annimmt. Diese Methode muss die Nachricht zurückgeben, damit wir sie weiterverwenden können. Da wir nicht wissen, wie lang die gesamte Nachricht sein wird, ist es auch in diesem Fall notwendig, zuerst den vorderen Header auszulesen und in einen int zu konvertieren. Anschließend werden die Bytes für den Nutzernamen und die Bytes der Nachricht ausgelesen und zu der finalen Nachricht zusammengefügt.
Dieser Code ist relativ ähnlich zum Clientcode. Der wichtigste Unterschied ist, dass wir die Nachricht am Ende als String zurückgeben. Aus diesem Grund müssen alle Teile der Nachricht, die mit einem separaten Buffer ausgelesen werden, dekodiert werden und vollständig erhalten bleiben – zum Trimmen oder Strippen wird also eine neue Variable benötigt.
Anschließend benötigen wir eine Methode broadcast, mit der die Nachricht an alle verbundenen Clients weitergeleitet wird. In dieser Methode iterieren wir über alle Sockets. Insofern der Socket nicht der Server und nicht der sendende Socket ist, verschicken wir die Nachricht:
Nun müssen wir den Code in der while-Schleife so anpassen, dass wir Nachrichten von allen Clients erhalten. Dazu können wir die Methode select aus dem Modul select verwenden:
Das Modul select erlaubt es, die select oder poll-Funktionen des Betriebssystems zu nutzen. Dadurch können beispielsweise alle Sockets, die zum Lesen oder Schreiben bereit sind oder sich in einem Fehlerzustand befinden, abgefragt werden. Die Liste an Sockets, die hinsichtlich ihres Zustands geprüft werden sollen, wird als Parameter an die Funktion übergeben. Aus der ersten Liste werden alle Sockets, die Daten zum Lesen geschrieben haben, aus der zweiten Liste alle Sockets, die Daten empfangen können bzw. Platz in ihrem Buffer haben, und aus der dritten Liste alle fehlerbehafteten Sockets ermittelt. Es ist möglich, eine leere Liste als Parameter zu übergeben.
An dieser Stelle sind für uns nur die Sockets, die Daten geschrieben haben oder auf einen Fehler gestoßen sind, relevant. Die zweite Liste wird daher nicht verwendet.
Nachdem die Sockets selektiert wurden, iterieren wir zuerst über alle die, die Daten geschrieben haben.
Handelt es sich bei dem aktuellen Socket um den Server selbst, führen wir den bereits bekannten Code zum Annehmen der Verbindung aus. Ansonsten lesen wir die Nachricht des Clients mit der vorhin implementierten receive-Methode. Ist die Nachricht leer, bedeutet das, dass der Client die Verbindung beendet hat und wir entfernen ihn aus der Liste. Ansonsten leiten wir die Nachricht an die anderen Clients mit der Methode broadcast weiter.
Anschließend gehen wir über alle fehlerhaften Sockets und entfernen diese aus unserer Liste.
Für den Fall, dass ein Client den Socket nicht korrekt schließt oder der Prozess einfach beendet wird, kann innerhalb dieser Methode trotzdem ein ConnectionResetError auftreten. In diesem Fall wollen wir den Socket ebenfalls aus der Liste aller uns bekannten Sockets entfernen. Fügen Sie dazu einen entsprechenden try-except-Block ein.
Damit ist die Implementierung von Client und Server angeschlossen.
Test der Implementierung
Testen wir die Implementierung, indem wir einen Server und zwei Clients – am besten wieder in einer Kommandozeile – starten:
Geben Sie für beide Clients einen Nutzernamen und eine erste Nachricht ein.
Wie Sie sehen, kommen die Nachrichten direkt beim Server an und werden dort ausgegeben. Die Clients erhalten die Nachrichten aber erst, nachdem sie selbst eine Nachricht abgeschickt haben, weil das Senden und Empfangen in der while-Schleife sequentiell ausgeführt wird. Eine Möglichkeit, dieses Problem zu lösen, wäre das Empfangen und Versenden von Nachrichten in separaten Threads. Schreiben Sie dazu im Client eine Funktion loop_receive mit einer eigene while-Schleife, die die receive-Methode ausführt. Starten Sie die Methode in einem Thread und entfernen Sie den receive-Aufruf aus der while-Schleife.
Mit dieser Implementierung erhält der Client sofort alle Nachrichten, die vom Server weitergeleitet werden. Allerdings erscheint die Ausgabe direkt hinter der Eingabe des Nutzers:
Wir passen daher die Ausgabe wie folgt an:
Starten Sie nun noch einmal einen Server und zwei Clients.
Beide Clients erhalten jetzt sofort die Nachricht des jeweils anderen und haben eine Indikation, dass sie eine Eingabe tätigen können, auch wenn diese mit einem Zeilenumbruch versetzt ist. Da wir aktuell nur den Python-Standard-Output zur Verfügung haben, ist es nicht möglich, Ein- und Ausgabe mit einfachen Mitteln zu separieren. Im dritten und letzten Teil des Tutorials werden wir deshalb eine grundlegende Oberfläche implementieren, die ein eigenes Eingabefeld zur Verfügung stellt.
Testen Sie abschließend, ob Sie sich mit der Eingabe [exit] ausloggen können (obere Abbildung) und ob der Server damit umgehen kann, wenn eine Client-Konsole einfach beendet wird (untere Abbildung):
Damit ist der zweite Teil des Tutorials abgeschlossen.
Gesamter Code: