8. Übung

Beispiel 8.1

Socket-Kommunikation

Bei Office-Anwendungen oder auch bei der Softwareentwicklung bearbeiten wir meist dediziert ein Dokument mit einer Anwendung. Die überwiegende Anzahl der im Einsatz befindlichen Software befindet sich aber nicht auf Office- oder Entwicklungs-PCs sondern auf sogenannten Embedded Rechnern zur Steuerung von Anlagen (vom Kraftwerk über den E-Motor, den Netzwerk-Switch bis zur Kaffeemaschine). Die Software in diesen Geräten ist aus Gründen der Modularität oder der Funktionalität in viele kleine Softwareprogramme aufgeteilt. Damit die Geräte ihre Aufgabe erfüllen können, müssen alle diese Softwaremodule zusammenarbeiten und tauschen zu diesem Zweck rechnerintern oder rechnerübergreifend Daten aus. Sofern die Daten zwischen unterschiedlichen Rechnern ausgetauscht werden, benötigen wir eine Vernetzungstechnologie, welches je nach Anwendungsgebiet unterschiedlich ausgelegt wird, wie z.B. der CAN-Bus in Fahrzeugen, der USB zur Anbindung von Computer-Peripherie oder das Ethernet im Bereich der LANs (Local Area Network). Die Vernetzungstechnologien stellen dabei immer eine Kommunikation aus einem physikalischen Medium zur Datenübertragung und einer Vereinbarung über die Nutzung des Mediums dar. Letzeres wird als sog. Protokoll bezeichnet und regelt, wann und wie die an der Vernetzung beteiligten Rechner auf das Medium zugreifen können, wie die Teilnehmer identifiziert werden und wie beim Auftreten von Fehlern verfahren wird. Dies mag als ganz grobe Einführung in dieses Gebiet genügen. Das Thema der Vernetzung selbst ist Gegenstand mehrerer Lehrveranstaltungen auf die für eine Vertiefung verwiesen sei.

IP-Adressen

Wir wollen uns konkret eine bestimmte Form der Kommunikation anschauen, die sog. Socket-Kommunikation. Diese entstand an der UCLA in Berkley als Kommunikationsschnittstelle primär zur Nutzung in Verbindung mit der TCP/IP-Protokollfamilie. In der IP-Welt besitzt jede Netzwerkschnittstelle in jedem beteiligten Rechner eine sog. IP-Adresse. Deren Format (nach IPv4) haben bestimmt alle schon einmal gesehen, z.B. 192.168.92.42. Um sich auf einem PC unter Windows die Adressen aller Netzwerkschnittstellen anzeigen zu lassen müssen wir > ipconfig in der Kommandozeile eingeben, unter Linux oder macOS hingegen $ ifconfig. Neben den an eine physikalische Netzwerkschnittstelle gebundenen Adressen gibt es auch eine Art virtuelle Netzschnittstelle, die zur Kommunikation innerhalb des selben Rechners genutzt werden kann. Diese bezeichnet man als sog. "loopback device" und es hat immer die IP-Adresse 127.0.0.1.

../_images/exer_08_udp_01.png

PC mit 2 Netzwerkschnittstellen und 2 IP-Adressen: Anwendungen wie ein Mailclient oder Webserver können sich an mehrere IP-Adressen und mehrere Ports binden.

Ports

Wie wir aus eigener Erfahrung wissen, kommunizieren auf einem PC-artigen Rechner diverse Anwendungen (Email, Webbrowser etc.) über die gleiche Netzwerkschnittstelle. Wie aber weiß der Rechner (eigentlich ist es das Betriebssystem) für welche Anwendung eine über das Netz ankommende Nachricht bestimmt ist? Um hier eine Differenzierung zu erreichen, werden an jeder Netzwerkschnittstelle sogenannte Ports definiert. Jeder Port entspicht einem numerischen Identifier und für viele Anwendungsdienste sind Portnummern vordefiniert. Ein Webserver etwa erwartet "http"-Anfragen von Webclients auf dem Port 80, während "https"-Anfragen auf Port 443 erwartet werden. Ein Webclient, der eine "http"-Anfrage an einen anderen Port adressiert wird keine Antwort erhalten. Ebenso wird eine Anfrage an den Port 80 ins Leere laufen, wenn auf dem adressierten Rechner gar kein Webserver aktiv ist, der sich für Nachrichten auf den Ports 80 bzw. 443 beim TCP/IP-Protokollstapel angemeldet hat.

../_images/exer_08_udp_02.png

2 Netzwerke mit insgesamt 4 PCs: PC1/PC3 sind über jeweils 2 Netzwerkschnittstellen in beide Netzwerke eingebunden, PC2 und PC4 jeweils nur in das "blaue" bzw. "rote" Netzwerk. (Die Anwendungen sind in dieser Darstellung nicht eingeblendet.)

TCP- und UDP-Protokoll

Den Begriff "TCP/IP"(-Protokoll) haben sicher viele in Verbindung mit dem Datentransfer in Internet und Intranets schon gehört. Es handelt sich dabei um einen sogenannten Protokollstack, der in mehreren Ebenen organisiert ist und die sichere und transparente Kommunikation bestimmter Dienste zwischen Rechnern organisiert. Über die Details lassen sich ganze Bücher füllen, für die Anwendung reichen uns aber relativ wenige Informationen aus.

Gemeinsamkeiten

Beide Protokoll nutzen das unterlagerte IP(Internet Protocol)-Protokoll um Daten in Form von Paketen zwischen Anwendungen auf einem oder unterschiedlichen Rechnern auszutauschen. Die Rechner werden über deren IP-Adressen angesprochen, die Anwendungen über deren Port.

TCP (Transmission Control Protocol)

TCP baut vor dem Datenaustausch eine Verbindung zur Gegenseite auf und baut diese nach dem Ende der Datenübertragung auch wieder ab. Die Datenpakete werden durchnummeriert und empfangene Datenpakte werden dem Sender bestätigt. Dadurch ist es dem Sender möglich den Verlust eines Datenpakets zu erkennen und dieses dann erneut zu senden. Längere Nachrichten werden in mehrere Pakete zerlegt, die unabhängig voneinander an den Empfänger verschickt werden. Jedes dieser Paket nimmt u.U. einen unterschiedlichen Weg durch das Netzwerk, wobei sich durchaus auch Pakete überholen können. Anhand der Nummerierung kann der Empfänger die eintreffenden Pakete dennoch wieder in die richtige Reihenfolge bringen. Damit stellt TCP ein sicheres Verfahren zur Übertragung auch größerer Datenmenge über längere Distanzen dar. Der Preis ist ein höherer Aufwand und eine höhere Latenzzeit bei der Übertragung.

UDP (User Datagram Protocol)

Im Gegensatz zu TCP handelt es sich bei UDP um ein verbindungsloses Protokoll. Der Sender verschickt ein Datenpaket an den Empfänger, erhält dafür aber keine Bestätigung und kann damit auch keine Ausfälle erkennen. Eine Fehlersicherung bei Paketausfällen ist damit genauso wie das Versenden längerer Nachrichten komplett in Verantwortung der kommmunizierenden Anwendungen. Im Gegenzug ist UDP dafür aber deutlich schlanker und schneller in der Kommunikation und bietet sich daher für den Austausch kürzerer Nachrichten in zuverlässigen lokalen Netzen an.

Client-Server-Kommunikation

Das Client-Server-Prinzip ist in Inter- und Intranet-Anwendungen weit verbreitet. Webbrowser und Webserver stellen ebenso ein Client-Server-Paar dar, wie auch Mail-Frontends (z.B. MS Outlook) und Mailserver. Das Prinzip basiert darauf, dass der Server - dem Namen entsprechend - einen oder mehrere Dienste (Services) zur Verfügung stellt. Der Server wartet passiv auf Anfragen der aktiv nachfragenden Clients. Hat der Server eine Client-Anfrage erhalten, so bearbeitet er diese (z.B. Zugbuchung) und sendet die Antwort an den Client zurück. Es kann sich bei diesem Ping-Pong zwischen Client und Server um einmalige separierte Vorgänge handeln, es können aber auch umfangreichere Aktionen erfolgen, z.B. wenn der Server als Antwort einen Videostream startet (dabei wird der Videostream in viele einzelne Nachrichten zerlegt). Ob es sich bei der Kommunkikation um eine Client-Server-Verbindung handelt oder ein anderes Prinzip (Sender-Receiver) verwendet wird, hängt von den Anwendungen ab und hat nichts mit dem darunterliegenden Protokollstapel zu tun.

../_images/exer_08_udp_03.png

Client-Server-Prinzip: Der Client übernimmt die aktive Rolle und sendet Anfragen ("Request") an den passiv wartenden Server. Bei Eintreffen einer Anfrage wird diese vom Server beantwortet ("Reply").

Jetzt wirds aber langsam Zeit für ein Code-Beispiel zur Socket-Kommunikation, zuerst den UDP-Server:

 1"""
 2Einfacher UDP-Server, der auf eine Nachricht wartet, eine Antwort sendet und sich dann beendet.
 3"""
 4
 5# Für die Sockets gibt es natürlich ein eigenes Modul
 6import socket
 7
 8# Wir nutzen das Loopback-Device zur rechnerinternen Kommunikation ...
 9localIP     = "127.0.0.1"
10# ... und einen nicht belegten Port
11localPort   = 1234
12# Das ist die maximale Paketlänge für den Empfang ...
13bufferSize  = 1024
14
15# ... und das unsere Testnachricht ...
16msgFromServer       = "Hello UDP Client"
17# ... die wir in eine Byte-Folge konvertieren müssen
18bytesToSend         = bytes(msgFromServer, 'UTF-8')
19
20# Jetzt legen wir einen Socket für die UDP an ...
21UDPServerSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
22# ... und binden ihn an unseren Port ...
23UDPServerSocket.bind((localIP, localPort))
24
25print("UDP server up and listening")
26
27# ... und schon können wir auf Empfang gehen
28# Die Funktion recvfrom() kehrt nur zurück, wenn eine Nachricht empfangen wurde
29# oder ein Fehler aufgetreten ist ...
30bytesAddressPair = UDPServerSocket.recvfrom(bufferSize)
31# ... und liefert uns im Erfolgsfall ein Tupel mit diesem Inhalt:
32message = bytesAddressPair[0]
33address = bytesAddressPair[1]
34
35# Die Nachricht und die Absender-IP schauen wir uns mal an...
36print('Message from Client: ', message)
37print('Client IP Address: ', address)
38
39# ... und senden dann - freundlich wie wir sind - eine Antwort zurück
40UDPServerSocket.sendto(bytesToSend, address)

Die wesentlichen Code-Zeilen sind hervorgehoben:

  1. Zeile 21: Wir legen einen neue Socket-Datenstruktur an.

  2. Zeile 23: Den Socket binden wir dann an eine IP-Adresse und einen Port. Ab jetzt kommen alle Nachrichten, die an diese IP-Adresse und diesen Port gerichtet sind, bei uns an.

  3. Zeile 30: Um tatsächlich eintreffende Nachrichten abzuholen müssen wir die Methode recvfrom() unseres Sockets aufrufen.

    Achtung

    Diese Methode wartet bis in alle Ewigkeit, dass eine Nachricht eintrifft und lässt sich - je nach Betriebssystem - auch durch ein STRG-C nicht beeindrucken!

  4. Zeile 40: Mit der Methode sendto() unseres Sockets senden wir dann eine Antwort an den Client.

Und dann brauchen wir noch einen dazu passenden UDP-Client:

 1"""
 2Einfacher UDP-Client, der eine Nachricht sendet, auf eine Antwort sendet und sich dann beendet.
 3"""
 4
 5# Für die Sockets gibt es natürlich ein eigenes Modul
 6import socket
 7
 8# Wir nutzen das Loopback-Device zur rechnerinternen Kommunikation ...
 9localIP     = "127.0.0.1"
10# ... und einen nicht belegten Port
11localPort   = 1234
12# IP und Port müssen wir als Tupel zusammenfassen
13serverAddressPort   = ("127.0.0.1", 1234)
14
15# Das ist die maximale Paketlänge für den Empfang ...
16bufferSize  = 1024
17
18# ... und das unsere Testnachricht ...
19msgFromClient       = "Hello UDP Server"
20# ... die wir in eine Byte-Folge konvertieren müssen
21bytesToSend         = bytes(msgFromClient, 'UTF-8')
22
23# Jetzt legen wir einen Socket für die UDP an ...
24UDPClientSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
25
26print("UDP client up and sending")
27
28# ... und schon können wir auf Senden gehen
29UDPClientSocket.sendto(bytesToSend, serverAddressPort)
30
31# ... und dann auf die Antwort warten
32# Die Funktion recvfrom() kehrt nur zurück, wenn eine Nachricht empfangen wurde
33# oder ein Fehler aufgetreten ist ...
34bytesAddressPair = UDPClientSocket.recvfrom(bufferSize)
35# ... und liefert uns im Erfolgsfall ein Tupel mit diesem Inhalt:
36message = bytesAddressPair[0]
37address = bytesAddressPair[1]
38
39# Die Nachricht und die Absender-IP schauen wir uns mal an...
40print('Message from Server: ', message)
41print('Server IP Address: ' , address)

Auf den ersten Blick sieht der Client-Code dem Server-Code zum Verwechseln ähnlich, bis auf eine Ausnahme:

  1. Zeile 24: Wir legen einen neue Socket-Datenstruktur an.

  2. Zeile 29: Mit der Methode sendto() unseres Sockets senden wir eine Nachricht an den Server (der darauf schon wartet). Wir benutzen dabei die IP-Adresse und den Port, an die der Server sich mit seinem Socket mittels bind() gebunden hat.

  3. Zeile 34: Um auf die Antwortnachricht vom Server zu warten, rufen wir die Methode recvfrom() unseres Sockets auf.

Im Unterschied zum Server binden wir uns beim Client nicht an eine unserer (eigenen) IP-Adressen und an einen Port. Stattdessen geben wir beim Senden die entsprechenden Daten des Servers an. Beides muss dem Client demnach bekannt sein und darüberhinaus muss der Server auch "up" (also bereits ausgeführt werden) und nicht etwa "down" sein, damit wir ihn auch erreichen können.

Unsere Testanordnung sieht grafisch wie folgt aus:

../_images/exer_08_udp_04.png

Loopback-Device

In unserem einfachen Beispiel zur Client-Server-Kommunikation lassen wir (zunächst) beide Anwendungen auf dem selben PC ausführen und miteinander kommunizieren. Dafür nutzen wir eine virtuelle Netzwerk-"Karte", das sog. "Loopback-Device", das es unter allen Betriebssystemen mit der Adresse "127.0.0.1" gibt. Mit dieser virtuellen Schnittstelle lassen sich sehr einfach Netzwerkanwendungen testen - wenn die Anwendungen schon rechnerintern nicht funktionieren, wird es auch zwischen PCs über ein Netzwerk eher schlecht aussehen.

Test:
  1. Zunächst starten wir den UDP-Server in einem Terminal. Der Server meldet seine Bereitschaft, eine Client-Anfrage zu empfangen.

  2. Danach starten wir den UDP-Client. Der Client sendet eine "Hello"-Botschaft an den Server und wartet dann auf eine Antwort vom Server.

  3. Der Server empfängt die "Hello"-Botschaft vom Client und antwortet seinerseits mit einem "Hello" als Antwort. Danach beendet sich der Server.

  4. Der Client empfängt die Antwort vom Server und beendet sich ebenfalls.

Sofern ein weiterer Rechner vorhanden ist und beide Rechner über ein Netzwerk verbunden ist, können wir nun auch den Server auf dem einen und den Client auf dem anderen Rechner laufen zu lassen. Damit das funktioniert müssen wir natürlich zuvor in beiden Programmen die entsprechende IP-Adresse des Servers als IP-Adresse eintragen.

Aufgabe 8.1

Nach diesem "Funktionstest" für die UDP-Kommunikation wollen wir nun eine erste kleine Client-Server-Anwendung schreiben. Wir werden dazu das Beispiel 7.2 in einen Client- und einen Server-Teil zerlegen:

  1. Die Berechnung der Sinus-Funktionswerte durch die Klasse Sinus soll im Client stattfinden.

  2. Die grafische Ausgabe der Funktion mittels der Klasse SinPlot wiederum soll in den Server eingebettet werden.

Unsere Anordnung sieht also wie folgt aus:

../_images/exer_08_udp_05.png

In der Folge bedeutet dies, dass wir die im Client berechneten Funktionswerte mittels UDP-Paketen zum Server übertragen müssen. Die Nachrichten, die wir übertragen, müssen also folgende Informationen beinhalten:

  1. id: Identifier um den Inhalt der Nachricht zu kennzeichnen

  2. x: Wert auf der Abszisse (x-Achse)

  3. y: Wert auf der Ordinate (y-Achse)

Bei der Definition des Paketinhalts haben wir die Bezeichner etwas allgemeiner gewählt als in unserem Sinus-Beispiel, weil es dem Server beim Plotten eigentlich egal ist, um welche Funktion es sich handelt. Außerdem haben wir vorausschauend dem Paket einen sog. Identifier vorangestellt. Dieser ist wichtig, falls wir später auch andere Informationen als die reinen Daten übertragen wollen. In diesem Fall muss der Server anhand der (unterschiedlichen) Identifier unterscheiden können, was der Client von ihm möchte.

Um unser Nachrichtenformat in Form eines Datentyps in Python darzustellen, bieten sich die sog. "Structured Arrays" von NumPy an. Dabei können wir (ähnlich wie in C/C++) einen eigenen Datentyp definieren, der benamte Elemente unterschiedlichen Datentyps enthält:

# Mit np.dtype definieren wir einen neuen NumPy-Datentyp ...
person_dtype = np.dtype([('age',    np.int32),
                         ('height', np.float32),
                         ('weight', np.float32)])

# ... legen uns ein leeres Element davon an ...
person_1 = np.zeros(1, dtype=person_dtype)
# ... und befüllen dieses unter Angaben der Feldnamen ...
person_1['age'] = 22
person_1['height'] = 184.0
person_1['weight'] = 72.5
# .. ehe wir es zur Kontrolle ausgeben
print(person_1)

Die so definierten Datentypen lassen sich genauso transparent verwenden wie die eingebauten Datentypen. Die o.a. Form der Definition eines eigenen Datentyps ist nur eine von mehreren Varianten, die NumPy zur Verfügung stellt. Auch sehr konplexe und verschachtelte Datentypen (z.B. mit Arrays) lassen sich sich definieren.

To Do

Das Beispiel 7.2 soll in einen Client- und einen Server-Anteil aufgespalten werden:

  1. Der Server soll die Zeichenfunktionalität mithilfe der Klasse SinPlot() realisieren.

  2. Der Client soll die Berechungfunktionalität mithilfe der Klasse Sinus() realisieren.

  3. Die Kommunikation zwischen Client und Server soll über UDP-Sockets erfolgen.

  4. Das Nachrichtenformat soll wie folgt aussehen:

    msg_type = np.dtype([('id', np.int32),
                        ('x',  np.float32),
                        ('y',  np.float32)])
    
  5. Es sollen 3 verschiedene Nachrichten ausgetauscht werden:

    id=1 - Initialisierung:

    Der Client fordert den Server auf, das Plot-Fenster zu öffnen.

    id=2 - Daten:

    Der Client sendet dem Server einen Datensatz (x,y).

    id=3 - Terminierung:

    Der Client beendet den Server nach Ende der Berechnungen.

  6. Um die Daten ausgeben zu lassen, muss der Client dem Server die Nachrichten in folgender Reihenfolge senden:

    1. Initialisierung

    2. Daten 0...N

    3. Terminierung

  7. Um die Grafikausgabe auf Seiten des Servers effizient zu gestalten, soll der Plot nur nach jedem 10. Datensatz aktualisiert werden.

Lösungshinweise:

  1. Schrittweise vorgehen:

    • Ausgehend vom einfachen Client-Server-Beispiel den Datenaustausch auf das definierte Nachrichtenformat umstellen und eine Testnachricht versenden.

    • Erweiterung um alle 3 Nachrichten-IDs auf Client-/Server-Seite und wieder testen.

    • Funktionalität für die Initialisierung in den Server integrieren.

    • Funktionalität für die Terminierung in den Server integrieren.

    • Datenübertragung integrieren und zunächst mit einem einfachen Testdatensatz, z.B. 10 Punkte (x,y), ausprobieren.

    • Zum Schluss die iterative Berechnung der Sinus-Funktion in den Client integrieren und testen.

  2. Das Versenden eines Structured Arrays über einen Socket kann direkt erfolgen. Auf der Empfangsseite kommen die Daten allerdings als reiner Bytestrom an. Um Daten aus einem unformatieren Bytebuffer in einen NumPy-Datentyp umzuwandeln kann man die Funktion np.frombuffer() einsetzen.

  3. Wir sprechen zwar hier auch von einer Client-Server-Kommunikation, allerdings brauchen wir in diesem Fall den Rückkanal vom Server an den Client gar nicht - es müssen also keine Antworten vom Server an den Client gesendet werden.

  4. Auf der Client-Seite sollte am Ende kein Code zur grafischen Ausgabe mehr enthalten sein, also sollte auch der Import der Matplotlib nicht mehr erforderlich sein.

Aufgabe 8.2

Die Aufgabe 8.2 erweitert unsere Lösung der Aufgabe 8.1 dahingehend, dass wir die Plotausgabe universeller gestalten wollen, denn eine ganze Reihe von Angaben ist bei uns derzeit noch "fest verdrahtet", wie z.B. das Werteintervall und die Dimension des Plot-Fensters.

To Do

Das Ergebnis der Aufgabe 8.1 soll so modifiziert werden, dass der Client folgende Informationen während der Initialiserung an den Server überträgt:

  1. die Dimension des Plot-Fensters

  2. die Dimensionen der Achsen (xlim, ylim)

  3. die Zeichenfarbe

  4. eine Legende

  5. Grid on | off

Lösungshinweise:

  1. Um die zusätzlichen Informationen bei der Initialsierung zu übertragen, müssen wir eine eigene Initialisierungsnachricht (also einen neuen NumPy-Datentyp) anlegen.

  2. Es bietet sich an, auch für den Nachrichtenkopf ("id") einen eigenen Datentyp zu definieren. Beim Eingang einer Nachricht wertet man dann erst diese "id"-Nachricht aus und je nachdem, um welche "id" es sich handelt, interpretiert man dann die Nachricht unterschiedlich.