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-09 11:14] – 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 65: | Zeile 65: | ||
}) | }) | ||
</ | </ | ||
+ | |||
### Statische Dateien | ### Statische Dateien | ||
Die bisherigen HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner `static` anlegen, und die client-side Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern: | Die bisherigen HTML und CSS Dateien bleiben unverändert - der Server soll diese bitte direkt zum Browser übertragen. Das erreichen wir, indem wir einen Ordner `static` anlegen, und die client-side Dateien dorthin verschieben. Node wird instruiert, diese Dateien unverändert auszuliefern: | ||
Zeile 76: | Zeile 77: | ||
Ausprobieren: | Ausprobieren: | ||
+ | |||
### 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 85: | 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 | + | <code javascript |
+ | /* Returns a fresh game. */ | ||
export function newGame() { | export function newGame() { | ||
+ | return { | ||
+ | " | ||
+ | " | ||
+ | 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, | ||
+ | ], | ||
+ | " | ||
+ | " | ||
+ | " | ||
+ | } | ||
+ | } | ||
+ | |||
+ | /* Drops a piece into given column. */ | ||
+ | export function dropPiece(game, | ||
... | ... | ||
} | } | ||
Zeile 115: | Zeile 136: | ||
* Wie weiss der Browser, ob er gelb oder rot spielt? | * Wie weiss der Browser, ob er gelb oder rot spielt? | ||
* Wie können mehrere Spiele betreut werden (Hinweis: schau dir die `gameid` oben an...!) | * Wie können mehrere Spiele betreut werden (Hinweis: schau dir die `gameid` oben an...!) | ||
- | |||
### Client-Side Javascript | ### Client-Side Javascript | ||
- | Der Browser benötigt | + | Der Browser benötigt |
Weil alle JSON Schnittstellen so konzipiert sind, dass sie im Pfad unterhalb des Seiten-URLs stehen, muss der Client gar nicht wissen, was seine Game-Id ist. Stattdessen ruft er eine **relative** URL auf: | Weil alle JSON Schnittstellen so konzipiert sind, dass sie im Pfad unterhalb des Seiten-URLs stehen, muss der Client gar nicht wissen, was seine Game-Id ist. Stattdessen ruft er eine **relative** URL auf: | ||
Zeile 126: | Zeile 146: | ||
* Schnittstelle für Spielzug: `http:// | * Schnittstelle für Spielzug: `http:// | ||
- | Wir können also aus dem client-side Javascript einfach | + | Wir können also aus dem client-side Javascript einfach |
Zeile 175: | Zeile 195: | ||
init(); | init(); | ||
</ | </ | ||
- | ### Sicherheit, | + | |
+ | \\ | ||
+ | ### User Ids & Cookies | ||
Um sicherzustellen, | Um sicherzustellen, | ||
Zeile 221: | Zeile 243: | ||
</ | </ | ||
+ | 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 app.js> | ||
+ | /** Serve the game state of any valid game id. */ | ||
+ | app.get('/: | ||
+ | const userid = getUserId(req, | ||
+ | const game = games[parseInt(req.params[' | ||
+ | if (game == undefined) { | ||
+ | res.status(404).json(" | ||
+ | } else { | ||
+ | if (isWaiting(game, | ||
+ | // Game is still waiting for a second player - join it! | ||
+ | console.log(`Game ${game.id} directly joined by ${userid}`) | ||
+ | joinGame(game, | ||
+ | } | ||
+ | res.json(toJson(game, | ||
+ | } | ||
+ | }) | ||
+ | </ | ||
+ | |||
+ | Damit obiger Code funktioniert, | ||
+ | |||
+ | <code javascript connect4_server.js> | ||
+ | /* Returns true if the game is waiting and userid | ||
+ | * has not joined, false otherwise */ | ||
+ | export function isWaiting(game, | ||
+ | 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, | ||
+ | // TODO: create a copy of game using structuredClone and | ||
+ | // set game.myturn=true if the current player is userid. | ||
+ | return game; | ||
+ | } | ||
+ | </ | ||
+ | \\ | ||
+ | |||
+ | ### Sicherheit | ||
+ | Nun können wir auch sicherstellen, | ||
+ | |||
+ | <code javascript app.js> | ||
+ | /** Make a play. Only allows the joined players to */ | ||
+ | app.get('/: | ||
+ | const userid = getUserId(req, | ||
+ | const game = games[parseInt(req.params[' | ||
+ | if (game == undefined) { | ||
+ | res.status(404).json(" | ||
+ | } else if (game.state != " | ||
+ | res.status(403).json(" | ||
+ | } else if (getCurrentPlayer(game) == userid) { | ||
+ | let column = parseInt(req.params[' | ||
+ | dropPiece(game, | ||
+ | res.json(toJson(game, | ||
+ | } else { | ||
+ | res.status(403); | ||
+ | res.json(" | ||
+ | } | ||
+ | }) | ||
+ | </ | ||
+ | |||
+ | <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? | ||
+ | |||
+ | 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 connect4_server.js> | ||
+ | export function toJson(game, | ||
+ | let copy = structuredClone(game); | ||
+ | if (game.state == " | ||
+ | if (copy.player1 == userid && copy.next == 1 || copy.player2 == userid && copy.next == 2) { | ||
+ | copy[" | ||
+ | } | ||
+ | } else if (game.state == " | ||
+ | if (copy.player1 == userid && copy.next == 1 || copy.player2 == userid && copy.next == 2) { | ||
+ | copy[" | ||
+ | } | ||
+ | } | ||
+ | return copy; | ||
+ | } | ||
+ | </ | ||
### Polling | ### Polling | ||
Zeile 228: | 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 253: | Zeile 376: | ||
</ | </ | ||
+ | ### 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 | ||
- | * Die ganze Web-App mit JS & Node: https:// | + | * Die ganze Web-App mit JS & Node: https:// |