Unterschiede

Hier werden die Unterschiede zwischen zwei Versionen der Seite angezeigt.

Link zu der Vergleichsansicht

Beide Seiten, vorherige Überarbeitung Vorherige Überarbeitung
Nächste Überarbeitung
Vorherige Überarbeitung
talit:web:webapps:server [2025-04-10 05:06] hoftalit: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://localhost:3001/hello können wir auf die oben definierte API zugreifen. Mit dem Kommandozeilenaufruf `node app.js` wird der Node-Server gestartet. Auf der Adresse http://localhost:3001/hello können wir auf die oben definierte API zugreifen.
  
-Der Code bedeutet: Wann immer eine Anfrage für den Pfad `/hello` ankommt, so sende als _Response_ (_res_) den String `Hello World` zurück.+Der Code bedeutet: Wann immer eine _Request_ (_res_) für den Pfad `/hello` ankommt, so sende als _Response_ (_res_) den String `Hello World` zurück.
  
-Es ist auch möglich, Teile der im Request (_req_) verlangten Adresse als Parameter zu erhalten:+Es ist auch möglich, Teile des im Request verlangten Pfades als Parameter zu erhalten:
  
 <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, müssen wir alle Funktionen oder Klassen in `connect4.js` mit `export` markieren und in `app.js` importieren:+In `app.js` erstellen wir ein Spiel, z.B. mit `let game = newGame()`. Damit das funktioniert, müssen wir alle Funktionen oder Klassen in `connect4_server.js` mit `export` markieren und in `app.js` importieren:
  
 <code javascript connect4_server.js> <code javascript connect4_server.js>
 +/* Returns a fresh game. */
 export function newGame() { export function newGame() {
-  ...+  return { 
 +    "state": "waiting",  // or "waiting" or "won" or "tie" 
 +    "board":
 +      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, 
 +    ], 
 +    "next": 1,  // 1 or 2, the player whose turn it is, the winner if state is "won" 
 +    "player1": undefined, 
 +    "player2": undefined, 
 +  }
 } }
-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, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird: Um sicherzustellen, dass jede:r Benutzer:in nur in einem Spiel ist, und kein böswilliger Akteur einen falschen Spielzug ausführen kann, wollen wir eine Benutzer-Id für jede Verbindung erstellen. Dafür nützen wir Cookies. Das Cookie wird gesetzt wenn zum ersten Mal einem Spiel beigetreten wird:
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 app.js>
 /** Serve the game state of any valid game id. */ /** Serve the game state of any valid game id. */
 app.get('/:gameid/game', (req, res) => { app.get('/:gameid/game', (req, res) => {
Zeile 248: Zeile 263:
 </code> </code>
  
-Damit obiger Code funktioniert, benötigt die Game-Logik auf der server-side zwei Funktionen `isWaiting(game, userid)` und `toJson(game, userid)`:+Damit obiger Code funktioniert, benötigt die Game-Logik auf der server-side die Funktionen `isWaiting(game, userid)` und `joinGame(game, userid)` und `toJson(game, userid)`:
  
 <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, userid) { export function isWaiting(game, userid) {
   return game.state == "waiting" && game.player1 != userid;   return game.state == "waiting" && game.player1 != userid;
 } }
  
 +/* Lets userid join the given game, throws an error if
 + * the game cannot be joined. */
 +export function joinGame(game, userid) {
 +  if (!isWaiting(game, userid)) {
 +    throw Error("Not waiting!");
 +  }
 +  if (game.player1 == undefined) {
 +    game.player1 = userid;
 +  } else {
 +    game.player2 = userid;
 +    game.state = "playing";
 +  }
 +}
 +
 +/* Returns the game state ready to be sent to the client. */
 export function toJson(game, userid) { export function toJson(game, userid) {
   // 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;
 } }
- 
 </code> </code>
 +\\
  
 ### Sicherheit ### Sicherheit
 Nun können wir auch sicherstellen, dass nur ein berechtigter Spieler einen Stein setzen kann. In der `/:gameid/set/` Schnittstelle wird die Benutzer-Id ausgelesen und überprüft, ob der Spieler überhaupt im Spiel mitspielt, und ob er an der Reihe ist. Andernfalls wird ein HTTP Status-Code aus dem [[wp>List_of_HTTP_status_codes#4xx_client_errors|400er-Bereich]] zurückgegeben, z.B. `403` (_Forbidden_). Nun können wir auch sicherstellen, dass nur ein berechtigter Spieler einen Stein setzen kann. In der `/:gameid/set/` Schnittstelle wird die Benutzer-Id ausgelesen und überprüft, ob der Spieler überhaupt im Spiel mitspielt, und ob er an der Reihe ist. Andernfalls wird ein HTTP Status-Code aus dem [[wp>List_of_HTTP_status_codes#4xx_client_errors|400er-Bereich]] zurückgegeben, z.B. `403` (_Forbidden_).
  
-<code javascript>+<code javascript app.js>
 /** Make a play. Only allows the joined players to  */ /** Make a play. Only allows the joined players to  */
 app.get('/:gameid/set/:column', (req, res) => { app.get('/:gameid/set/:column', (req, res) => {
Zeile 286: Zeile 318:
 </code> </code>
  
 +<code javascript connect4_server.js>
 +/* Returns the playerid whose turn it is. */
 +export function getCurrentPlayer(game) {
 +  return game.next == 1 ? game.player1 : game.player2;
 +}
 +</code>
 +
 +\\
 ### 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, ob der UserId des Requests gerade am Zug ist. Damit wir die Eigenschaft nicht auf der Server-Seite speichern, erstellen wir eine Kopie des JSON mit `structuredClone` ([[https://developer.mozilla.org/de/docs/Web/API/Window/structuredClone|MDM]]). 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, ob der UserId des Requests gerade am Zug ist. Damit wir die Eigenschaft nicht auf der Server-Seite speichern, erstellen wir eine Kopie des JSON mit `structuredClone` ([[https://developer.mozilla.org/de/docs/Web/API/Window/structuredClone|MDM]]).
  
-<code javascript>+<code javascript connect4_server.js>
 export function toJson(game, userid) { export function toJson(game, userid) {
   let copy = structuredClone(game); // make a copy   let copy = structuredClone(game); // make a copy
Zeile 305: Zeile 345:
 } }
 </code> </code>
- 
 ### 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 connect4_client.js>
 async function wait(ms) { async function wait(ms) {
   // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet.   // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet.
Zeile 336: Zeile 375:
 } }
 </code> </code>
 +
 +### 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, wenn ausser der zwei Spieler noch zahlreiche Beobachter auf Änderungen warten.
 +  * 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**: Die Requests für den Spielzustand werden vom Server nicht sofort beantwortet, sondern erst, wenn sich der Zustand geändert hat.
 +  - **Web-Sockets**: Das Websockets-Protokoll erlaubt bidirektionale Verbindungen zwischen Client und Server. Über eine solche Verbindung werden Nachrichten über den Spielzustand ausgetauscht.
 +
 +Hier werden wir Long-Polling anschauen. Als erstes benötigen wir eine Datenstruktur, um die Requests für später zu speichern.
 +<code javascript app.js>
 +/** All long-poll requests by gameid. For each gameid, a list of 
 +  * [response, userid] entries is stored.  */
 +let longpolls = {};
 +</code>
 +
 +Dazu definieren wir eine neue Route `/<gameid>/longpoll`, die ganz ähnlich wie `/<gameid>/game` funktioniert:
 +
 +<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('/:gameid/longpoll', (req, res) => {
 +    const userid = getUserId(req, res);
 +    const game = games[parseInt(req.params['gameid'])]
 +    if (game == undefined) {
 +        res.status(404).json("no such game");
 +    } else {
 +        if (isWaiting(game, userid)) {
 +            console.log(`Game ${game.id} directly joined by ${userid}`)
 +            join(game, userid)
 +            res.json(toJson(game, userid))
 +            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, userid)) {
 +            console.log(`Stash long-poll request for ${game.id} by ${userid}`)
 +            longpolls[game.id] ??= []
 +            longpolls[game.id].push([res, userid])
 +            // do not end here but keep request hanging.
 +        } else {
 +            // User can act on the state, no point in waiting.
 +            res.json(toJson(game, userid))
 +            res.end()
 +        }
 +    }
 +})
 +</code>
 +
 +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 `"waiting"` ist oder wenn der User gerade nicht am Zug ist (User hat als letztes gespielt, oder User ist ein Beobachter):
 +
 +<code javascript connect4_server.js>
 +export function shouldBlockRequest(game, userid) {
 +  return game.state == "waiting" || game.state == "playing" && getCurrentPlayer(game) != userid;
 +}
 +</code>
 +
 +Nun benötigen wir noch die Logik, um die Long-Poll-Requests zu beantworten, sobald sich der Spielzustand geändert hat. Die Funktion wird aufgerufen, wenn ein Spiel gejoint wird, oder wenn ein Spielzug gemacht wird.
 +
 +<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]  // remove
 +    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, userid))
 +            res.end()
 +        }
 +    }
 +}
 +</code>
 +
 +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, status, url) {
 +  let response = await fetch(url);
 +  if (response.ok) {
 +    game = await response.json();
 +    // Update the HTML view.
 +    updateHtml(grid, game);
 +    // Update game status area to reflect winner / tie
 +    updateStatus(status, game);
 +  }
 +  if (!response.ok) {
 +    await wait(500) // wait a little if there is connection trouble
 +  }
 +  if (!response.ok || game.state == "waiting" || game.state == "playing" && !game.myturn) {
 +    await handleFetch(grid, status, `longpoll`);  // longpoll to wait for state change
 +  }
 +}
 +</code>
 +
 +Nun haben wir viel weniger Datenverkehr, der Server wird entlastet - dafür muss er sich mehr Informationen speichern. Ein Vorteil ist auch, dass Änderungen schneller beim long-polling Client ankommen.
 +
 ### Hinweise ### Hinweise
  
  • talit/web/webapps/server.1744261613.txt.gz
  • Zuletzt geändert: 2025-04-10 05:06
  • von hof