0%

Kapitel 5 · Übung

Ein Gespräch auf Loopback

Nachrichten nach dem Handshake austauschen

Dein aus Kapitel 2 stellt eine Verbindung auf um, und dein Codec aus den Kapiteln 3 und 4 verwandelt Nachrichten in und zurück. Beide funktionieren, und beide sind bisher nur allein gelaufen. In diesem Kapitel teilen sie sich eine Verbindung: ein Server und ein Client in einem Python-Programm auf 127.0.0.1, die Nachrichten austauschen, bis einer von ihnen sagt, dass das Gespräch vorbei ist, wobei jedes Warten begrenzt ist.

Der Projektbaum hat sich geändert. loopback_demo.py ist neu, und das ist die Datei, die du das ganze Kapitel über bearbeitest. ws_handshake.py und ws_frames.py sind die fertigen Versionen des Kurses vom Handshake aus Kapitel 2 und vom Codec aus Kapitel 4, sodass dieses Kapitel von bekannten Dateien ausgeht, egal wie dein eigener Code aussah; nichts in diesem Kapitel ändert sie. Die Demo aus Kapitel 4, fakes.py und die Frame-Tests haben ihre Aufgabe erfüllt und sind weg.

Was schon geschrieben ist

Öffne loopback_demo.py und lies die mitgelieferten vor den leeren.

  • make_read_exact(reader) gibt ein read_exact(n) über einen echten zurück. Es ist der Empfangspuffer aus Networks with Python hinter der Nahtstelle, die decode_frame seit Kapitel 3 verwendet: Es beginnt mit den Bytes, die der Handshake in reader["buffer"] gelassen hat, ruft recv auf, bis es n Bytes hat, und löst ConnectionError aus, wenn die Gegenseite vorher schließt.

  • serve_connection(conn) führt den Handshake des Servers und dann serve_conversation aus. client_connection(sock, host, path, texts) führt den Handshake des Clients und dann client_conversation aus.

  • run_once(talk) ist dieselbe Verdrahtung, die du im Projekt von Kapitel 2 geschrieben hast: ein Listener auf 127.0.0.1 0 mit einem Timeout von 2 Sekunden, der Server in einem Thread, der Client im Haupt-Thread mit seinem eigenen Timeout von 2 Sekunden und ein join mit Timeout. Es gibt aus, was jede Seite zurückgegeben hat, oder die , die sie gestoppt hat, statt sie auszulösen.

main führt run_once zweimal aus, einmal mit einem wohlerzogenen Client und einmal mit einem unhöflichen, und nur, wenn du die Datei ausführst, wegen der Zeile if __name__ == "__main__": ganz unten. Lektion 5 ersetzt diese Verdrahtung für ein einzelnes Gespräch durch eine eigene, die mehrere Clients bedient.

Die Hälfte des Servers

ws_frames.read_message(read_exact, role="server") liest einen Frame und gibt ihn als Ereignis zurück, ein kleines wie {"type": "text", "data": "hello"} oder {"type": "ping", "data": b"p?"}. Mit role="server" besteht es darauf, dass jeder Frame maskiert ist, wie es jeder Client-Frame sein muss.

Die Regel des Servers lautet: ein Ereignis rein, höchstens ein Frame raus, und server_reply(event) enthält sie:

  • Text kommt als Text zurück und binär als binär, mit denselben Daten, unmaskiert, weil ein Server nie maskiert;

  • ein bekommt einen Pong mit derselben Payload, unmaskiert;

  • ein Pong bekommt nichts: Gib b"" zurück.

serve_conversation(sock, read_exact, *, max_messages=MAX_MESSAGES) läuft in einer : Lies ein Ereignis, sende server_reply(event) mit sock.sendall und zähle es, wenn es Text oder binär war. b"" zu senden sendet nichts, also braucht der Fall des Pongs keinen eigenen Zweig. Sobald max_messages Nachrichten als Echo zurückgesendet wurden, hör auf und gib {"messages": n, "code": None} zurück. Der Code bleibt vorerst None, weil nichts in dieser Lektion ein Gespräch mit einem Code beendet; Lektion 2 füllt ihn aus.

Die Grenze zählt Nachrichten, nicht Frames. Ein Ping ist die Gegenseite, die fragt: “Bist du noch da?”, und ihn zu beantworten gehört dazu, die Verbindung gesund zu halten, nicht zum Inhalt des Gesprächs.

max_messages ist 3. Ein Client sendet einen Ping und drei Texte. Wie viele Echos sendet der Server?

Die Hälfte des Clients

Jeder Frame, den ein Client sendet, ist mit einem neuen 4 Byte langen Schlüssel maskiert. client_frame(opcode, payload, *, random_bytes=os.urandom) baut einen: Bitte random_bytes(4) um einen Schlüssel und gib ws_frames.encode_frame(opcode, payload, mask_key=key) zurück. In einem echten Programm ist random_bytes gleich os.urandom; ein Test kann stattdessen eine Funktion übergeben, die bekannte Bytes zurückgibt, dieselbe reine Schlüsselwort-Nahtstelle, die client_handshake für seinen Schlüssel verwendet.

Der Schlüssel muss für jeden Frame neu sein. Kapitel 4 hat gezeigt, warum es das Maskieren gibt: Ein Skript in einem Browser darf die genauen Bytes, die das Netzwerk durchqueren, nicht wählen können, weil ein Proxy oder Cache auf dem Weg Bytes, die wie aussehen, für echtes HTTP halten könnte. RFC 6455, Abschnitt 5.3, verlangt aus diesem Grund einen neuen, unvorhersehbaren Schlüssel pro Frame. Ein Schlüssel, der für jeden Frame wiederverwendet wird, ist nach dem ersten vorhersagbar.

Warum bittet client_frame für jeden Frame um einen neuen Schlüssel?

client_conversation(sock, read_exact, texts, *, random_bytes=os.urandom) redet mit dem Server:

  1. Sende einen maskierten Ping mit einer Payload deiner Wahl, etwa b"are you there?", und lies die Antwort mit ws_frames.read_message(read_exact, role="client").

  2. Wenn die Antwort kein Pong mit genau dieser Payload ist, löse ValueError(1002, "the server did not answer the ping") aus, dieselbe Form mit zwei , die der Codec für eine verletzte Regel verwendet.

  3. Sende jeden Text mit client_frame(ws_frames.OP_TEXT, text, random_bytes=random_bytes), lies das Echo und behalte sein "data".

  4. Gib {"echoes": [...], "code": None} zurück.

Gib random_bytes an jeden Aufruf von client_frame weiter, damit die eine Funktion, die der Aufrufer dir gegeben hat, jeden Schlüssel liefert.

Run, und ein Ende, das keins ist

Klicke auf Run. Hier ist die Ausgabe nach dieser Lektion:

A conversation:
client: {'echoes': ['hello', 'café ✓', 'third message'], 'code': None}
server: stopped by ConnectionError: the peer closed the connection
server thread still running: False
A rude client:
client: the server closed the connection without a close frame
server: stopped by ValueError: (1002, 'the frame breaks the masking rule')
server thread still running: False

Der Client hat alle drei Echos bekommen, das Häkchen in café ✓ eingeschlossen. Die Zeile “stopped by” des Servers ist das erwartete Ergebnis dieser Lektion, kein Fehler. Der Client hat seine Texte beendet und seinen with-Block verlassen, was seinen Socket geschlossen hat. Der Server, noch in seiner Schleife, hat recv aufgerufen, b"" bekommen, und read_exact hat ConnectionError ausgelöst. Von der Seite des Servers aus sehen ein Client, der fertig ist, und ein Client, der abgestürzt ist, genau gleich aus.

Der unhöfliche Client sendet einen unmaskierten Text-Frame. Der Codec lehnt ihn mit ValueError(1002, ...) ab, die Ausnahme beendet den Thread des Servers, und der Client sieht, wie seine Verbindung kommentarlos geschlossen wird. Lektion 3 bringt den Server dazu, dieser Art von Client richtig zu antworten.

Run gibt server: stopped by ConnectionError: the peer closed the connection aus. Was ist passiert?

Wenn das erste Gespräch drei Echos ausgibt, klicke auf Submit. Es prüft deine vier Funktionen mit Stellvertreter-Sockets, nie mit einem echten, und lässt dann deinen Client und deinen Server über sie miteinander reden.

Das Gespräch funktioniert, aber es hat kein Ende: Eine Seite legt auf, und die andere muss raten, warum. Lektion 2 gibt WebSocket-Gesprächen ihr richtiges Ende, den Schließ-Handshake.

Aufgabe

Schreibe vier in loopback_demo.py, in dieser Reihenfolge. Submit nennt die erste, die noch NotImplementedError auslöst, sodass du sie eine nach der anderen angehst.

  1. server_reply(event): Gib für ein Text- oder Binärereignis ws_frames.encode_frame mit demselben und denselben Daten und ohne zurück; für einen einen Pong mit derselben Payload und ohne Maske; für einen Pong b"".

  2. client_frame(opcode, payload, *, random_bytes=os.urandom): Gib ws_frames.encode_frame(opcode, payload, mask_key=random_bytes(4)) zurück, ein neuer Schlüssel pro Aufruf.

  3. serve_conversation(sock, read_exact, *, max_messages=MAX_MESSAGES): Lies Ereignisse mit ws_frames.read_message(read_exact, role="server"), sende server_reply(event) mit sock.sendall, zähle nur Text- und Binärnachrichten, und sobald max_messages als Echo zurückgesendet wurden, gib {"messages": n, "code": None} zurück, ohne erneut zu lesen.

  4. client_conversation(sock, read_exact, texts, *, random_bytes=os.urandom): Sende einen Ping mit einer Payload deiner Wahl, lies die Antwort mit role="client" und löse ValueError(1002, "the server did not answer the ping") aus, außer wenn sie ein Pong mit derselben Payload ist; sende dann jeden Text und lies sein Echo vor dem nächsten; gib {"echoes": [...], "code": None} zurück. Baue jeden mit client_frame(..., random_bytes=random_bytes).

Klicke auf Run: Das erste Gespräch sollte drei Echos ausgeben, und die Zeile “stopped by ConnectionError” des Servers ist nach dieser Lektion zu erwarten.

Klicke dann auf Submit. Die Prüfung öffnet nie einen . Sie ruft server_reply und client_frame direkt auf. Sie gibt serve_conversation einen Stellvertreter-Socket, dessen Client einen maskierten Ping, einen Text und eine Binärnachricht sendet, oder einen Text, einen Pong, einen leeren Ping und eine Binärnachricht, am Stück, ein Byte nach dem anderen und jeweils zwei Bytes geliefert, und der dann verstummt, ohne zu schließen, sodass ein Server, der über seine Grenze hinaus liest, ewig wartet und scheitert. Sie gibt client_conversation einen Stellvertreter-Server, der auf alles antwortet, was dein Client sendet, prüft, dass jeder Frame mit seinem eigenen Schlüssel aus random_bytes maskiert ist, und beantwortet deinen Ping einmal mit der falschen Payload. Zum Schluss redet dein Client mit einem korrekten Stellvertreter-Server, und die Bytes, die er gesendet hat, werden deinem serve_connection zugeführt, dessen Antworten Byte für Byte mit denen dieses Servers übereinstimmen müssen. Das Importieren von loopback_demo.py darf nichts ausgeben.