Unterschiede
Hier werden die Unterschiede zwischen zwei Versionen der Seite angezeigt.
| Beide Seiten, vorherige Überarbeitung Vorherige Überarbeitung Nächste Überarbeitung | Vorherige Überarbeitung | ||
| talit:web:webapps:server [2025-04-10 05:06] – hof | talit:web:webapps:server [2025-04-13 10:59] (aktuell) – hof | ||
|---|---|---|---|
| Zeile 55: | Zeile 55: | ||
| Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http:// | Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http:// | ||
| - | Der Code bedeutet: Wann immer eine Anfrage | + | Der Code bedeutet: Wann immer eine _Request_ (_res_) |
| - | Es ist auch möglich, Teile der im Request | + | Es ist auch möglich, Teile des im Request verlangten |
| <code javascript> | <code javascript> | ||
| Zeile 80: | Zeile 80: | ||
| ### Server-Side Vier-Gewinnt | ### Server-Side Vier-Gewinnt | ||
| - | Erstelle eine Kopie deiner `connect4.js` Datei, um auf dem Server ein Spiel laufen zu lassen. | + | Erstelle eine Kopie deiner `connect4.js` Datei als `connect4_server.js`, um auf dem Server ein Spiel laufen zu lassen. |
| Was muss sich ändern? | Was muss sich ändern? | ||
| Zeile 87: | Zeile 87: | ||
| - | In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, | + | In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, |
| <code javascript connect4_server.js> | <code javascript connect4_server.js> | ||
| + | /* Returns a fresh game. */ | ||
| export function newGame() { | export function newGame() { | ||
| - | | + | |
| + | " | ||
| + | " | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | 0, 0, 0, 0, 0, 0, 0, | ||
| + | ], | ||
| + | " | ||
| + | " | ||
| + | " | ||
| + | } | ||
| } | } | ||
| - | export function dropPiece() { | + | |
| + | /* Drops a piece into given column. */ | ||
| + | export function dropPiece(game, column) { | ||
| ... | ... | ||
| } | } | ||
| Zeile 181: | Zeile 197: | ||
| \\ | \\ | ||
| - | |||
| ### User Ids & Cookies | ### User Ids & Cookies | ||
| Um sicherzustellen, | Um sicherzustellen, | ||
| Zeile 230: | Zeile 245: | ||
| Falls ein Spieler direkt auf einen Spiel-URL geht (z.B. weil der erste Spieler den URL mit ihm geteilt hat), so muss das Spiel noch gejoint werden. Die Zustandsabfrage ändert sich also: | Falls ein Spieler direkt auf einen Spiel-URL geht (z.B. weil der erste Spieler den URL mit ihm geteilt hat), so muss das Spiel noch gejoint werden. Die Zustandsabfrage ändert sich also: | ||
| - | <code javascript> | + | <code javascript |
| /** Serve the game state of any valid game id. */ | /** Serve the game state of any valid game id. */ | ||
| app.get('/: | app.get('/: | ||
| Zeile 248: | Zeile 263: | ||
| </ | </ | ||
| - | Damit obiger Code funktioniert, | + | Damit obiger Code funktioniert, |
| <code javascript connect4_server.js> | <code javascript connect4_server.js> | ||
| + | /* Returns true if the game is waiting and userid | ||
| + | * has not joined, false otherwise */ | ||
| export function isWaiting(game, | export function isWaiting(game, | ||
| return game.state == " | return game.state == " | ||
| } | } | ||
| + | /* Lets userid join the given game, throws an error if | ||
| + | * the game cannot be joined. */ | ||
| + | export function joinGame(game, | ||
| + | if (!isWaiting(game, | ||
| + | throw Error(" | ||
| + | } | ||
| + | if (game.player1 == undefined) { | ||
| + | game.player1 = userid; | ||
| + | } else { | ||
| + | game.player2 = userid; | ||
| + | game.state = " | ||
| + | } | ||
| + | } | ||
| + | |||
| + | /* Returns the game state ready to be sent to the client. */ | ||
| export function toJson(game, | export function toJson(game, | ||
| // TODO: create a copy of game using structuredClone and | // TODO: create a copy of game using structuredClone and | ||
| Zeile 260: | Zeile 292: | ||
| return game; | return game; | ||
| } | } | ||
| - | |||
| </ | </ | ||
| + | \\ | ||
| ### Sicherheit | ### Sicherheit | ||
| Nun können wir auch sicherstellen, | Nun können wir auch sicherstellen, | ||
| - | <code javascript> | + | <code javascript |
| /** Make a play. Only allows the joined players to */ | /** Make a play. Only allows the joined players to */ | ||
| app.get('/: | app.get('/: | ||
| Zeile 286: | Zeile 318: | ||
| </ | </ | ||
| + | <code javascript connect4_server.js> | ||
| + | /* Returns the playerid whose turn it is. */ | ||
| + | export function getCurrentPlayer(game) { | ||
| + | return game.next == 1 ? game.player1 : game.player2; | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | \\ | ||
| ### Wer ist dran? | ### Wer ist dran? | ||
| Damit der Client weiss, ob er an der Reihe ist, müsste er seine eigene User-Id kennen. Wir könnten aber die Information auch einfach in das zurückgegebene JSON einbauen. Eine Möglichkeit wäre, dass wir für jede Zustandsabfrage überprüfen, | Damit der Client weiss, ob er an der Reihe ist, müsste er seine eigene User-Id kennen. Wir könnten aber die Information auch einfach in das zurückgegebene JSON einbauen. Eine Möglichkeit wäre, dass wir für jede Zustandsabfrage überprüfen, | ||
| - | <code javascript> | + | <code javascript |
| export function toJson(game, | export function toJson(game, | ||
| let copy = structuredClone(game); | let copy = structuredClone(game); | ||
| Zeile 305: | Zeile 345: | ||
| } | } | ||
| </ | </ | ||
| - | |||
| ### Polling | ### Polling | ||
| Zeile 312: | Zeile 351: | ||
| Damit wir alle 500ms oder jede Sekunde den Serverzustand abfragen, bietet es sich an, wieder mit `await` zu arbeiten. Mit Promises können wir eine `async` Funktion bauen, die nebenläufig eine bestimme Zeitdauer wartet. Am Ende der `fetchUrl` Funktion rufen wir diese auf, bevor wir den Gamezustand mit einem Selbstaufruf pollen: | Damit wir alle 500ms oder jede Sekunde den Serverzustand abfragen, bietet es sich an, wieder mit `await` zu arbeiten. Mit Promises können wir eine `async` Funktion bauen, die nebenläufig eine bestimme Zeitdauer wartet. Am Ende der `fetchUrl` Funktion rufen wir diese auf, bevor wir den Gamezustand mit einem Selbstaufruf pollen: | ||
| - | <code javascript> | + | <code javascript |
| async function wait(ms) { | async function wait(ms) { | ||
| // Schläft für eine Anzahl Millsekunden, | // Schläft für eine Anzahl Millsekunden, | ||
| Zeile 336: | Zeile 375: | ||
| } | } | ||
| </ | </ | ||
| + | |||
| + | ### Long-Polling | ||
| + | Mit der obigen Lösung fragt jeder Client zweimal pro Sekunde nach einem Update. Polling hat zwei Nachteile: | ||
| + | * es wird relative viel Traffic erzeugt, insbesondere, | ||
| + | * trotzdem ist die Responsivität tief - im Mittel wartet jeder Client die Hälfte des eingestellten Delays, also 250 Millisekunden. Das ist für Vier-Gewinnt zwar ausreichend tief, aber nicht genug für Spiele mit Interaktivität. | ||
| + | |||
| + | Um das zu beheben gibt es zwei Lösungsmöglichkeiten: | ||
| + | - **Long-Polling**: | ||
| + | - **Web-Sockets**: | ||
| + | |||
| + | Hier werden wir Long-Polling anschauen. Als erstes benötigen wir eine Datenstruktur, | ||
| + | <code javascript app.js> | ||
| + | /** All long-poll requests by gameid. For each gameid, a list of | ||
| + | * [response, userid] entries is stored. | ||
| + | let longpolls = {}; | ||
| + | </ | ||
| + | |||
| + | Dazu definieren wir eine neue Route `/< | ||
| + | |||
| + | <code javascript app.js> | ||
| + | /** Serve the game state of any valid game id, but block until | ||
| + | * the client is no longer blocked (long-polling). */ | ||
| + | app.get('/: | ||
| + | const userid = getUserId(req, | ||
| + | const game = games[parseInt(req.params[' | ||
| + | if (game == undefined) { | ||
| + | res.status(404).json(" | ||
| + | } else { | ||
| + | if (isWaiting(game, | ||
| + | console.log(`Game ${game.id} directly joined by ${userid}`) | ||
| + | join(game, userid) | ||
| + | res.json(toJson(game, | ||
| + | res.end() | ||
| + | return | ||
| + | } | ||
| + | // Check if userid needs to wait, then either: | ||
| + | // - stash the [response, userid] pair for later | ||
| + | // - instantly return actionable state | ||
| + | if (shouldBlockRequest(game, | ||
| + | console.log(`Stash long-poll request for ${game.id} by ${userid}`) | ||
| + | longpolls[game.id] ??= [] | ||
| + | longpolls[game.id].push([res, | ||
| + | // do not end here but keep request hanging. | ||
| + | } else { | ||
| + | // User can act on the state, no point in waiting. | ||
| + | res.json(toJson(game, | ||
| + | res.end() | ||
| + | } | ||
| + | } | ||
| + | }) | ||
| + | </ | ||
| + | |||
| + | Im der server-side Game-Logik implementieren wir eine neue Funktion, um zu wissen, ob ein Request blockiert werden soll oder nicht - genau dann, wenn der Zustand `" | ||
| + | |||
| + | <code javascript connect4_server.js> | ||
| + | export function shouldBlockRequest(game, | ||
| + | return game.state == " | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | Nun benötigen wir noch die Logik, um die Long-Poll-Requests zu beantworten, | ||
| + | |||
| + | <code javascript app.js> | ||
| + | /** Sends any pending long-polling responses after a state change. */ | ||
| + | function sendLongPollResponses(game) { | ||
| + | let polls = longpolls[game.id] | ||
| + | delete longpolls[game.id] | ||
| + | if (polls) { | ||
| + | for (let response of polls) { | ||
| + | // a response object is a list with the actual HTTP response and the userid. | ||
| + | let res = response[0] | ||
| + | let userid = response[1] | ||
| + | console.log(`End long-poll request for ${game.id} by ${userid}`) | ||
| + | res.json(toJson(game, | ||
| + | res.end() | ||
| + | } | ||
| + | } | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | Was bleibt zu tun? Der Client soll nicht mehr warten, sondern bei Bedarf das `longpoll` API benützen: | ||
| + | |||
| + | <code javascript connect4_client.js> | ||
| + | async function handleFetch(grid, | ||
| + | let response = await fetch(url); | ||
| + | if (response.ok) { | ||
| + | game = await response.json(); | ||
| + | // Update the HTML view. | ||
| + | updateHtml(grid, | ||
| + | // Update game status area to reflect winner / tie | ||
| + | updateStatus(status, | ||
| + | } | ||
| + | if (!response.ok) { | ||
| + | await wait(500) // wait a little if there is connection trouble | ||
| + | } | ||
| + | if (!response.ok || game.state == " | ||
| + | await handleFetch(grid, | ||
| + | } | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | Nun haben wir viel weniger Datenverkehr, | ||
| + | |||
| ### Hinweise | ### Hinweise | ||