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 [2026-08-31 15:13] (aktuell) – [Server-Side Vier-Gewinnt] hof
Zeile 5: Zeile 5:
 Dazu muss Folgendes geschehen: Dazu muss Folgendes geschehen:
   * Die Spiel-Logik muss vom Browser auf den Server wandern.   * Die Spiel-Logik muss vom Browser auf den Server wandern.
-    * Dazu muss ein Server-Prozess gestartet werden, wir verwenden dafür [[https://nodejs.org/|nodejs]] und [[https://expressjs.com/|express]].+    * Dazu muss ein Server-Prozess gestartet werden, der HTTP spricht. 
 +      * Wir könnten dafür Python (mit Flask, Quart, FastAPI) verwenden. 
 +      * Wir wählen Javascript und verwenden [[https://nodejs.org/|nodejs]] und [[https://expressjs.com/|express]].
     * Der Server muss die folgenden zwei Web-APIs zur Verfügung stellen:     * Der Server muss die folgenden zwei Web-APIs zur Verfügung stellen:
       * `/<gameid>/game`: gibt den Zustand des Games mit Id `<gameid>` im JSON-Format zurück.       * `/<gameid>/game`: gibt den Zustand des Games mit Id `<gameid>` im JSON-Format zurück.
Zeile 18: Zeile 20:
   * Das Javascript im Browser muss sich nur noch darum kümmern, die richtigen Web-APIs auf dem Server aufzurufen, und den Zustand des Spiels im HTML darzustellen.   * Das Javascript im Browser muss sich nur noch darum kümmern, die richtigen Web-APIs auf dem Server aufzurufen, und den Zustand des Spiels im HTML darzustellen.
  
 +{{ :talit:web:webapps:connect4_component_diagram.svg?600&nolink |}}
 ### Installation von node.js und express ### Installation von node.js und express
  
   * Node.js installieren:   * Node.js installieren:
-    * https://nodejs.org/en/download/prebuilt-installer+    * https://nodejs.org/en/download
   * Projekt initialisieren:   * Projekt initialisieren:
     * Eine Eingabeaufforderung **im Ordner der Webapp** öffnen.     * Eine Eingabeaufforderung **im Ordner der Webapp** öffnen.
Zeile 48: Zeile 50:
  
 // Listen on the given port // Listen on the given port
-app.listen(port, () => { +const server = app.listen(port, () => { 
-    console.log(`Example app listening on port ${port}`)+    console.log(`Example app listening on http://localhost:${server.address().port}/`)
 }) })
 </code> </code>
Zeile 55: Zeile 57:
 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>
 app.get('/hello/:name', (req, res) => { app.get('/hello/:name', (req, res) => {
     let name = req.params['name']     let name = req.params['name']
-    res.send(`Hello ${name}!``)+    res.send(`Hello ${name}!`)
 }) })
 </code> </code>
Zeile 77: Zeile 79:
  
 Ausprobieren: Node neu starten und den URL http://localhost:3001/0/connect4.html laden - unser HTML + CSS sollte korrekt angezeigt werden! Allenfalls ist der Port 3001 bereits besetzt, dann muss ein anderer Port gewählt werden. Ausprobieren: Node neu starten und den URL http://localhost:3001/0/connect4.html laden - unser HTML + CSS sollte korrekt angezeigt werden! Allenfalls ist der Port 3001 bereits besetzt, dann muss ein anderer Port gewählt werden.
- 
 ### 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 88:
  
  
-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 104: Zeile 121:
  
 app.get('/:gameid/game', (req, res) => { app.get('/:gameid/game', (req, res) => {
-    return the_game;+    res.json(the_game);
 }) })
  
Zeile 181: Zeile 198:
  
 \\ \\
- 
 ### 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 246:
 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 264:
 </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 293:
   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 319:
 </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 346:
 } }
 </code> </code>
- 
 ### Polling ### Polling
  
Zeile 312: Zeile 352:
 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 376:
 } }
 </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