Wie erfährt ein Client davon, dass der Gegenspieler ein Zug gemacht hat? Mit HTTP ist es nicht so einfach, den Client über eine Änderung des Serverzustands zu informieren. Am einfachsten ist es, wenn der Client polling verwendet. Polling bedeutet, dass der Client periodisch den Server-Zustand abfrägt.
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:
async function wait(ms) { // Schläft für eine Anzahl Millsekunden, bevor die Promise settlet. return new Promise(resolve => setTimeout(resolve, ms)) } async function handleFetch(grid, url) { let response = await fetch(url) if (response.ok) { game = await response.json() // Update the HTML view. updateHtml(grid, game) } // POLLING: Falls wir nicht am Zug sind, holen wir uns nach 500ms // den neuen Gamezustand. Die Rekursion (Selbstaufruf) führt dazu, dass // jeder Aufruf von handleFetch wieder einen nächsten Aufruf erzeugt. if (game.state == "waiting" || game.state == "playing" && !game.myturn) { await wait(500) await handleFetch(grid, "game") } }
Beobachte den Netzwerkverkehr dieser Lösung in den Developer Tools!
Mit der obigen Lösung fragt jeder Client zweimal pro Sekunde nach einem Update. Polling hat zwei Nachteile:
Um das zu beheben gibt es zwei Lösungsmöglichkeiten:
Hier werden wir Long-Polling anschauen. Als erstes benötigen wir eine Datenstruktur, um die Requests für später zu speichern.
/** 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 /<gameid>/longpoll, die ganz ähnlich wie /<gameid>/game funktioniert:
/** 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() } } })
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):
export function shouldBlockRequest(game, userid) { return game.state == "waiting" || game.state == "playing" && getCurrentPlayer(game) != userid }
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.
/** 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() } } }
Was bleibt zu tun? Der Client soll nicht mehr warten, sondern bei Bedarf das longpoll API benützen:
async function handleFetch(grid, url) { let response = await fetch(url) if (response.ok) { game = await response.json() // Update the HTML view. updateHtml(grid, 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, `longpoll`) // longpoll to wait for state change } }
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.