Kapitel 7 · Übung
Ein Server, viele Clients
Gib jeder Nachricht eine Form
Dem Echo-Dienst aus Kapitel 6 war egal, was eine Nachricht bedeutete. Welchen Text ein Client auch sendete, er kam direkt zurück, und jeder Client sprach allein mit dem Server. Dieses Kapitel baut etwas anderes: eine Anzeigetafel für einen Quizabend. Ein Punktezähler sendet Wertungen, und jeder Zuschauer, der auf einem Bildschirm zusieht, muss dieselbe Tafel sehen. Bevor irgendetwas davon funktioniert, müssen sich der Server und seine Clients einig sein, wie eine Nachricht aussieht.
Ein Umschlag pro Frame
Jede Nachricht reist als ein Text-"type", das dem Leser sagt, wie er den Rest lesen soll. Eine solche vereinbarte Form nennt man oft einen Umschlag (Envelope): Der type steht außen drauf, und die Felder darin hängen von ihm ab.
Die Anzeigetafel hat drei
{"type": "snapshot", "version": 3, "scores": {"Owls": 4, "Foxes": 2}}
{"type": "score", "team": "Owls", "points": 3}
{"type": "error", "version": 3, "reason": "that team is not playing tonight"}
Ein snapshot ist die ganze Tafel: die Punkte jedes Teams. Eine score ist die Bitte des Punktezählers an den Server, einem Team Punkte gutzuschreiben. Ein error sagt, dass etwas schiefgegangen ist; dieses Kapitel definiert ihn, und in Kapitel 9 beginnt der Server, ihn zu senden.
Der Server teilt einem neuen Zuschauer die Punkte jedes Teams mit. Welchen Typ sendet er?
Versionen nummerieren die Tafel
Ein Snapshot und ein Fehler tragen eine version, eine ganze Zahl, die für die leere Tafel bei 0 beginnt und mit jeder Änderung um eins steigt. Die Version eines Snapshots sagt, welchen Zustand der Tafel er zeigt. Ein Client, der Version 6 nach Version 5 empfängt, weiß also, dass er die neuere Tafel hat. Versionen steigen nur.
Eine score hat keine Version. Nur der Server ändert die Tafel, also kann nur der Server sagen, welchen Zustand eine Nachricht beschreibt. Der Punktezähler bittet um eine Änderung; der Server nimmt sie vor und nummeriert das Ergebnis. Das ist die Regel dieses Kurses für sein Protokoll, keine Regel von RFC 6455, der nichts darüber sagt, was deine Nachrichten enthalten.
Warum trägt eine score-Nachricht keine Version?
Der vollständige Vertrag, Feld für Feld:
snapshot:version, eine ganze Zahl von 0 oder mehr, undscores, ein Objekt, dessenalle ganze Zahlen sind; score:team, ein, der nicht leer ist, und points, eine ganze Zahl von 1 bis 10 (MIN_POINTSundMAX_POINTSoben inscoreboard_messages.py);error:version, wie bei einem Snapshot, undreason, ein String.
Jeder andere type wird abgelehnt, und ebenso alles, was kein Objekt ist, zum Beispiel eine JSON-
True ist ein int
“Eine ganze Zahl” klingt nach isinstance(points, int). Ganz stimmt das nicht. In Python ist bool eine Art int, also besteht True diese Prüfung, und JSONs true kommt in Python als True an. Ein Punktezähler, der "points": true sendet, würde aus Versehen einen Punkt vergeben.
Was gibt das aus?
print(isinstance(True, int), type(True) is int)
is_whole_number(value) gibt also nur für ein int, das kein bool ist, True zurück. Zwei isinstance-Prüfungen, verbunden mit and not, sagen genau das. Verwende sie für Punkte, Versionen und jeden Punktestand in einem Snapshot. Eine Fließkommazahl wie 2.0 und ein String wie "3" sind ebenfalls keine ganzen Zahlen, und is_whole_number sagt das bereits. Prüfe den Typ, bevor du vergleichst: "3" < 1 löst ValueError.
Beim Senden prüfen, beim Empfangen prüfen
check_message(message) enthält den ganzen Vertrag. Die ValueError mit einem kurzen Grund aus, wenn er es nicht ist. Lies die Felder mit message.get(...), das für einen fehlenden Schlüssel None liefert. So scheitert ein fehlendes Team an derselben Prüfung wie ein leeres. Für ein leeres Team ist team.strip() == "" der Test.
Zwei Funktionen verwenden sie. encode_message(message) prüft vor dem Codieren, damit der Server nie einen kaputten Umschlag senden kann, und gibt json.dumps(message, ensure_ascii=False) zurück. Ohne ensure_ascii=False schreibt json.dumps ein Team namens Café als Caf\u00e9; beides ist gültiges JSON, und die lesbare Form lässt sich in einem Protokoll leichter prüfen.
parse_message(data) arbeitet in die andere Richtung. data ist das, was recv der Bibliothek zurückgegeben hat. Ein Text-Frame liefert einen str, ein Binär-Frame liefert bytes, und die Anzeigetafel spricht nur Text. Lehne zuerst alles ab, was kein str ist, mit ValueError. Diese Prüfung liegt ganz bei dir: json.loads akzeptiert auch Bytes und würde einen Binär-Frame bereitwillig lesen. Rufe dann json.loads auf. Text, der kein JSON ist, lässt es json.JSONDecodeError auslösen, und das ist eine Art ValueError, also kannst du es einfach durchlassen. Prüfe zum Schluss den Umschlag und gib ihn zurück.
Jede Ablehnung ist ein ValueError, egal aus welchem Grund. Der Handler des Punktezählers in Lektion 6 wird genau diese eine Ausnahme abfangen und die Verbindung schließen.
Ausführen
Klicke auf Run. scoreboard_demo.py gibt einen Abschnitt pro Lektion aus; solange die Funktionen einer Lektion nicht geschrieben sind, steht in ihrem Abschnitt not written yet. Eine fertige Version hat auf der Seite für Lektion 1 das hier ausgegeben:
Lesson 1: two envelopes and one refusal
encoded: {"type": "snapshot", "version": 3, "scores": {"Owls": 4, "Café Quiz": 2}}
parsed: {'type': 'score', 'team': 'Owls', 'points': 3}
ValueError: points must be a whole number from 1 to 10
Die erste Zeile ist der Text, den ein Frame tragen würde. Die zweite ist das parse_message zurückgegeben hat. Die dritte ist die Ablehnung von "points": true; ihr Wortlaut stammt von dir, deiner kann also anders aussehen. Klicke dann auf Submit.
Die Umschläge gibt es jetzt, und noch sendet sie niemand. In der nächsten Lektion verbindet sich ein Zuschauer und empfängt seinen ersten Snapshot.
Aufgabe
Schreibe in scoreboard_messages.py die vier NotImplementedError auslöst.
is_whole_number(value):Truefür einint, das keinboolist, undFalsefür alles andere.check_message(message): Gib für einen gültigen Umschlag nichts zurück und löse für alles andereaus. Ein snapshotbraucht eine ganzzahligeversionvon 0 oder mehr und einscores-, dessen alle ganze Zahlen sind. Ein errorbraucht dieselbeversionund einenreason, der einist. Eine scorebraucht einenteam-String, der nicht leer ist, und ganzzahligepointsvonMIN_POINTSbisMAX_POINTS. Lehne jeden anderentypeund alles ab, was kein Dictionary ist. Ignoriere zusätzliche Schlüssel.encode_message(message): Rufecheck_messageauf und gib dannjson.dumps(message, ensure_ascii=False)zurück.parse_message(data): LöseValueErroraus, wenndatakeinstrist; wende dannjson.loadsdarauf an, rufecheck_messagemit dem Ergebnis auf und gib es zurück.
Klicke auf Run, lies den Abschnitt zu Lektion 1 und klicke dann auf Submit. Die Prüfung ruft deine Funktionen mit Umschlägen auf, die die Ausgabe von Run nicht zeigt, etwa mit einem Snapshot mit einem Team namens Café Quiz in den Versionen 0 und 41 und mit Wertungen von 1 und 10 Punkten für ein Team namens Bärchen. Sie erwartet, dass jeder davon als Text codiert wird, den json.loads wieder in denselben Umschlag verwandelt, und unverändert zurückgeparst wird, zusätzliche Schlüssel eingeschlossen. Sie schickt fünfzehn kaputte Umschläge durch encode_message und durch parse_message, darunter Punkte von 0, 11, true, 2.0 und "3", ein leeres Team, eine Version von -1 oder true, einen Snapshot-Punktestand von false, einen Fehler ohne Grund, einen unbekannten ValueError. parse_message muss außerdem die Bytes eines Binär-"not json" und den leeren String ablehnen. Den Text jedes ValueError wählst du selbst, und type(value) is int ist so gut wie zwei isinstance-Prüfungen.