9. Übung
Beispiel 9.1
JSON-Datenformat
JSON (JavaScript Object Notation) ist ein offenes und standardisiertes Format zum Datenaustausch zwischen Anwendungen sowie für Dateien. Entstanden ist das Format - wie man anhand des Namens unschwer erkennen kann - in Verbindung mit der Programmiersprache JavaScript. Aufgrund seines sehr einfachen und dazu noch für Menschen lesbaren Formats hat es sich aber allgemein für viele Aufgaben im Bereich der IT etabliert und wird von vielen Programmiersprachen direkt unterstützt u.a. auch von Python.
Dateiformate
Ein anderes weit verbreitetes Dateiformat/Datenaustauschformat ist die XML (eXtended Markup Language), die extra für den rechner- und anwendungsübergreifenden Einsatz konzipiert wurde. Zwar ist die XML sehr vielseitig, dabei leider aber auch recht länglich in der Formatierung, was die Lesbarkeit und manuelle Erzeugbarkeit für uns Menschen recht umständlich macht. In diese "Lücke" ist dann zum einen die JSON gestoßen, zum anderen auch die YAML (YAML Ain't Markup Language - Anmerkung: eine sog. "rekursive" Definition). Letztere ist noch umfangreicher als JSON und beinhaltet diese als echte Untermenge, hat aber nicht die Verbreitung von JSON gefunden und wird von Python ebenfalls unterstützt, allerdings nicht originär.
Wir schauen uns am besten einfach ein Beispiel an, wie eine Datei im JSON-Format aussieht (Wikipedia):
{
"first_name": "John",
"last_name": "Smith",
"is_alive": true,
"age": 27,
"address": {
"street_address": "21 2nd Street",
"city": "New York",
"state": "NY",
"postal_code": "10021-3100"
},
"phone_numbers": [
{
"type": "home",
"number": "212 555-1234"
},
{
"type": "office",
"number": "646 555-4567"
}
],
"children": [
"Catherine",
"Thomas",
"Trevor"
],
"spouse": null
}
Das sieht nicht nur auf den ersten Blick sehr ähnlich aus zu den Dictionaries, die wir in Python kennen. Tatsächlich ist der einfachste Weg eine JSON-Datei/Datenstruktur zu erzeugen ein Dictionary anzulegen und in JSON zu konvertieren und umgekehrt ist es ebenso einfach eine JSON-Datei/Datenstruktur wieder in ein Dictionary umzwandeln. Im ersten Fall sprechen wir von der JSON-Codierung, im zweiten Fall von der JSON-Decodierung. Das schauen wir uns gleich mal an einem einfachen Beispiel an:
1import json
2
3# Wir definieren ein Datenobjekt als Dictionary ...
4input_data = {"Adresse": "Kleiststr.", "Hausnummer": 14}
5print('Originale Daten:', input_data)
6
7# ... und codieren diese mit dumps() in eine JSON-formatierte Byte-Folge um ...
8json_string = bytes(json.dumps(input_data), 'UTF-8')
9print('JSON codierte Daten:', json_string)
10
11# .. die wir mit der 'loads()'-Methode wieder decodieren können
12output_data = json.loads(json_string)
13print('Decodierte JSON-Daten:', output_data)
14
15# Wenn wir ein Datenobjekt im JSON-Format in eine Datei schreiben wollen,
16# sieht das ganz ähnlich aus ...
17with open('data.json', 'w', encoding='UTF-8') as f:
18 json.dump(input_data, f, ensure_ascii=False, indent=4)
19
20# ... und auch das Wieder-Einlesen aus der Datei ghet sehr leicht
21with open('data.json') as f:
22 loaded_data = json.load(f)
23
24print('Aus der Datei geladener Datensatz:', loaded_data)
In den Zeilen 7-13 machen wir sozusagen eine "Trockenübung", indem wir ein Dictionary mittels der Methode dumps() in einen JSON-String umwandeln (codieren), den wir anschließend unter zuhilfenahme der inversen Methode loads() wieder in ein Dictionary konvertieren (decodieren). Das durch dumps() erzeugte Byte-Array können wir z.B. über einen Socket an einen andere Anwendung schicken, die das empfangene Array dann mit loads() wieder in ein Dictionary wandelt und auswertet. In den Zeilen 17-24 wiederum erzeugen wir ein Datei im JSON-Format und speichern unser Test-Dictionary mittels der Methode dump() darin, um es gleich darauf mit der Methode load() als Dictionary wieder einzulesen.
Wie wir der o.a. Beispieldatei entnehmen, können die JSON-Datenstrukturen auch verschachtelt sein, also z.B. Arrays oder weitere Dictionaries enthalten. Auch dies wird 1:1 in Python-Dictonaries abgebildet. Darüberhinaus können wir auch eigene Datenobjekte/-klassen in JSON-Objekte umwandeln (und vice versa), nur müssen wir dazu eigene Coder und Decoder in unseren Klassen schreiben. Auf diese Möglichkeit sei an dieser Stelle nur verwiesen, vertiefen wollen wir das in diesem Kurs aber nicht weiter.
Aufgabe 9.1
In dieser Aufgabe wollen wir den Umgang mit JSON-Objekten in Verbindung mit der Socket-Kommunikation ausprobieren.
To Do
In den Aufgaben 8.1/8.2 wurden die zwischen Client und Server ausgetauschten Nachrichten durch separat definierte NumPy-Datentypen realisiert. Diese sollen nun durch JSON-Objekte ersetzt werden.
Der Datenaustausch zwischen Client und Server soll im JSON-Format erfolgen.
Die Inhalte der Nachrichten sowie die verschiedenen Nachrichtentypen sollen prinzipiell erhalten bleiben.
Die Funktionalität der Anwendungen soll identisch bleiben.
Lösungshinweise:
Analog zu Beispiel 9.1 können die Nachrichten clientseitig jeweils als Dictionary definiert, in ein Byte-Array umgewandelt und dann über die UDP-Verbindung an den Server geschickt werden.
Auf der Serverseite muss die Decodierung dann entsprechend der jeweiligen Nachrichten-ID erfolgen.
Zum Testen und zur Fehlersuche bietet es sich an, die Kommunikation zunächst ohne Zeichenfunktionalität unter Anzeige der gesendeten und empfangenen Inhalte auf beiden Seiten zu testen.
Beispiel 9.2
Exceptions
Wenn wir eine Datei öffnen wollen, die gar nicht existiert, oder, wenn wir eine Division durch Null durchführen wollen, die nicht erlaubt ist, oder auch, wenn bei der Socket-Kommunikation ein Fehler auftritt, haben wir es mit sogenannten "Laufzeitfehlern" zu tun - dies waren nur einige wenige Beispiele für Laufzeitfehler. Diese sind dadurch gekennzeichnet, dass man sie im Vorfeld nicht diagnostizieren kann, weil sie von den konkreten Rahmenbedingungen zu einem Zeitpunkt während der Programmausführung abhängen. In allen diesen Fällen kann der Python-Interpreter ein Programm ohne weitere Maßnahmen nicht sinnvoll fortsetzen. Daher löst Python in diesen Fällen eine sog. "Exception" aus, auf die wir programmiertechnisch reagieren können oder nicht. Bislang haben wir nicht auf Exceptions reagiert mit der Folge, dass unser Programm bei Auftreten eines Laufzeitfehlers unter Ausgabe einer Fehlermeldung einfach abgebrochen wird. In der Tat ist es aber relativ einfach, eine Fehlerbehandlung in unsere Programme zu integrieren. In Python gibt es dazu das try-except-Konstrukt:
try:
# Hier steht der eigentliche Code, der funktionell auszuführen ist ...
except FirstError:
# Dieser Abschnitt wird ausgeführt, falls im try-Block ein Fehler des Typs
# FirstError auftritt.
except (SecondError, ThirdError):
# Dieser Abschnitt wird ausgeführt, falls im try-Block einer der Fehler aus
# der Liste auftritt.
except FourthError as error:
# Beim Auftreten von FourthError enthält 'error' nähere Informationen zum
# Fehler, die wir z.B. ausgeben können.
except:
# Reaktion auf alle anderen noch möglichen Fehler.
else:
# Code-Block, der nur ausgeführt wird, wenn kein Fehler aufgetreten ist.
finally:
# Code-Block, der unabhängig davon, ob ein Fehler aufgetreten ist oder
# nicht, immer ausgeführt wird.
Wie wir sehen, kann man die Fehlerbehandlung sehr vielschichtig gestalten. Verpflichtend sind aber - neben dem obligatorischen try-Block - mindestens ein except-Block oder der finally-Block - alles andere ist optional. Betrachten wir ein einfaches Beispiel dazu:
1try:
2 f = open('nonsense.txt', 'r')
3 print('Datei erfolgreich geöffnet')
4
5except:
6 print('Exception')
Im Beispiel versuchen wir eine (hoffentlich tatsächlich) nicht vorhandene Datei mit Namen 'nonsense.txt' zu öffnen. Beim Versuch des Öffnens einer nicht vorhandenen Datei wird dann eine Exception ausgelöst, die uns mit der print()-Anweisung in Zeile 6 dann auch mitgeteilt wird. Möchten wir genaueres zur Exception wissen, können wir wie folgt vorgehen:
1try:
2 f = open('nonsense.txt', 'r')
3 print('Datei erfolgreich geöffnet')
4
5except Exception as error:
6 print('Exception:', error)
Damit bekommen wir die Fehlerursache mitgeteilt und können diese ausgeben. Der gleiche Text wird im Übrigen von Python selber beim Programmabbruch angezeigt, wenn wir auf das try-except-Konstrukt verzichtet hätten (Ausprobieren!).
Es gibt in Python eine ganze Reihe vordefinierter Fehler. Viele Programmpakete definieren aber darüberhinaus auch eigene Exceptions. Und natürlich können auch wir eigene Exceptions definieren. Dafür müssen wir eine Exception-Klasse anlegen, die mittels Vererbung von der Exception-Basisklasse abgeleitet ist. Diese Möglichkeit werden wir an dieser Stelle aber nicht weiter verfolgen.
Wir können aber auch die vordefinierten Exceptions nutzen und diese selber auslösen. Dazu gibt es den Befehl raise, der dazu dient eine Exception auszulösen:
1try:
2 # Hier belegen wir 2 Variablen absichtlich 'falsch'
3 a = 4
4 b = 2
5
6 # Als gute Programmierer prüfen wir hier ab, ob die Voraussetzungen für die
7 # weiteren Berechnungen erfüllt sind.
8 if a >= b:
9 raise ValueError('a muss immer kleiner als b sein!')
10
11 # Hier ginge es weiter, wenn a < b wäre ...
12except Exception as error:
13 print('Exception:', error)
Aufgabe 9.2
Bei unseren bisherigen Programmen zur Socket-Kommunikation war es in den meisten Fällen nicht möglich, dass wir diese durch Betätigen von 'STRG-C' unterbrechen konnten. Dies war immer dann nicht möglich, wenn insbesondere der Server einen Aufruf von socket.recvfrom() getätigt hatte: dieser Aufruf kehrt nämlich nur zurück, wenn wirklich Daten empfangen wurden und ist - außer durch das (harte) Killen des Prozesses - nicht unterbrechbar. Damit unser Programm etwas reaktiver wird, können wir für die Sockets einen Timeout setzen: wenn innerhalb des Timeouts keine Daten empfangen wurden, dann bricht die recvfrom()-Funktion unter Auslösung einer Exception ab. Diese können wir abfangen und damit z.B. auf den ausbleibenden Nachrichtenempfang reagieren. Gleichzeitig ist unser Programm dann als Nebeneffekt auch wieder durch STRG-C unterbrechbar.
Im folgenden Code-Fragment ist eine mögliche Abfolge von 'try-except'-Blöcken zu sehen - je nach Lösung der Aufgaben 8.1/8.2/9.1 sieht der Code vermutlich bei jedem etwas anders aus - die Neuerungen sind jeweils durch einen Kommentar der Form '# NEU: ...' gekennzeichnet:
1# ... Klassen, Funktionen, ...
2
3# Hauptprogramm
4if __name__ == '__main__':
5 # NEU: Dieser 'try'-Block fängt Initialsierungsfehler beim Öffnen und
6 # Binden des Sockets ab.
7 try:
8 # ... Initialisierung
9
10 # UDP-Socket anlegen ...
11 UDPServerSocket = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
12
13 # ... und an IP-Adresse und Port binden
14 UDPServerSocket.bind((gfx_srv_ip, gfx_srv_port))
15
16 # NEU: Hier setzen wir einen Timeout-Wert von 1s für den UDP-Socket
17 UDPServerSocket.settimeout(1.0)
18
19 # ... Initialisierung
20
21 # Warten auf eingehende Nachrichten von Clients
22 while not terminated:
23 # NEU: Dieser 'try'-Block soll in der Hauptsache den Timeout des
24 # Sockets abfangen: Wenn der Socket >= 1s erfolglos auf den Eingang
25 # einer Nachricht wartet wird eine Exception ausgelöst ...
26 try:
27 bytesAddressPair = UDPServerSocket.recvfrom(bufferSize)
28
29 # ... Nachricht empfangen und decodieren
30
31 # NEU: ... die hier abgefangen wird ...
32 except socket.timeout as inst:
33 print('socket.timeout aufgetreten', type(inst))
34
35 # NEU: ... bei anderen Exceptions landen wir hier
36 except Exception as inst:
37 print('Exception aufgetreten', type(inst))
38
39 # NEU: Hier fangen wir den Abbruch durch STRG-C ab ...
40 except KeyboardInterrupt:
41 print('STRG-C gedrückt')
42
43 # NEU: ... und hier alle Exceptions, die sonst noch auftreten können
44 except Exception as inst:
45 print('Exception aufgetreten', type(inst))
To Do
Es soll der Code der Aufgabe 9.1 durch Einfügen von 'try-except'-Konstrukten sicherer gemacht werden:
Es soll zunächst die Serverseite analog zum o.a. Code-Fragment entsprechend ergänzt werden.
Danach soll auch der Client durch Einfügen entsprechender Konstrukte abgesichert werden. Dabei soll mindestens das Hauptprogramm insgesamt als auch die Schleife zum Senden der Daten an den Server abgesichert werden.