Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Langlebige Objekte und WebSockets: Multiplayer ohne dedizierten Server

Wie Durable Objects das Problem des gemeinsamen Status zwischen WebSocket-Verbindungen in Echtzeit löst, mit der Hibernation-API, die die Kosten für inaktive Verbindungen eliminiert.

Langlebige Objekte und WebSockets: Multiplayer ohne dedizierten Server

Die Annahme, die die meisten WebSocket-Implementierungen in serverlos zum Scheitern bringt, ist, dass das Skalierungsproblem gelöst wird, wenn mehrere Worker Verbindungen akzeptieren. Es löst das Problem nicht: Es entstehen mehrere isolierte Welten, in denen jeder Client nur mit Instanzen seines eigenen Workers kommuniziert, ohne dass ersichtlich ist, wer mit den anderen verbunden ist. Zwei Clients, die eine Verbindung zum selben Endpunkt herstellen, können sich auf völlig unterschiedlichen Workern befinden, ohne dass es einen Kanal für den Nachrichtenaustausch zwischen ihnen gibt. Diese Isolation ist genau das, was Workers skalierbar macht – und genau das ist es, was jegliche Echtzeitpräsenz oder Zusammenarbeitsfunktionalität ohne eine externe Koordinationsebene unmöglich macht.

Warum isolierte Arbeiter für den Mehrspielermodus nicht ausreichen

Stellen Sie sich einen Chat im Raum vor. Zehn verbundene Clients. Der Raum existiert als Konzept in der Anwendung, aber er existiert nirgendwo im Worker als Objekt im Speicher. Jede WebSocket-Verbindung wird von einem Worker akzeptiert, der nichts von den anderen weiß. Wenn Client A eine Nachricht sendet, hat der Worker, der diese Nachricht empfängt, keine Möglichkeit, die anderen neun Clients zu erreichen, die mit anderen Workern verbunden sind.

Die herkömmliche Lösung besteht darin, eine externe Ebene hinzuzufügen: Pub/Sub (Redis, Upstash), Datenbank für die Speicherung von Nachrichten und Abfragen oder einen dedizierten WebSocket-Dienst wie Ably oder Pusher. Alle diese Lösungen funktionieren, aber sie verursachen eine gewisse Latenzzeit, einen zu verwaltenden Dienst und Kosten, die mit aktiven Verbindungen skalieren – nicht mit der tatsächlichen Nutzung.

Ein langlebiges Objekt verändert den Koordinationspunkt. Der Raum ist nicht implizit im Anwendungskonzept enthalten: Er wird zu einem namentlich identifizierten DO. Alle Clients, die „Raum-456“ betreten möchten, werden an denselben DO weitergeleitet, der die Liste der WebSocket-Verbindungen im Speicher behält und One-to-All-Nachrichten ohne externes Roundtripping übermitteln kann. Die Koordination erfolgt lokal beim DO.

Das Basismodell und kostengünstig ohne Ruhezustand

Die direkte Implementierung von Broadcast innerhalb eines DO ist einfach. Der DO behält Set von WebSocket Objekten im Speicher, akzeptiert neue Verbindungen und iteriert über sie alle, um Nachrichten zu übermitteln:

export class Room implements DurableObject { private sessions: Set<WebSocket> = new Set(); async fetch(request: Request): Promise<Response> { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); server.accept(); this.sessions.add(server); server.addEventListener('message', (event) => { for (const session of this.sessions) { if (session !== server) { session.send(event.data as string); } } }); server.addEventListener('close', () => { this.sessions.delete(server); }); return new Response(null, { status: 101, webSocket: client }); } }

Dieser Code funktioniert. Das Kostenproblem tritt auf, wenn Sie das Abrechnungsmodell analysieren: Während dieser DO offene WebSocket-Verbindungen hat und Ereignis-Listener verarbeitet, ist er aktiv. Auch wenn kein Client Nachrichten sendet, ist das DO immer noch aktiv und verbraucht GB-Sekunden. Bei einem Raum mit fünf Benutzern, die acht Stunden lang inaktiv sind, ist der DO acht Stunden lang aktiv – und Sie zahlen für jede Rechensekunde in diesem Zeitraum.

Die Hibernation API und was sie am Kostenmodell ändert

Die WebSocket Hibernation API kehrt diese Kosten um. Anstatt aktive Verbindungen im Speicher zu behalten, übergeben Sie die Kontrolle an Cloudflare, indem Sie this.ctx.acceptWebSocket(ws) statt ws.accept() verwenden. Von dort aus hält Cloudflare WebSocket-Verbindungen offen, auch wenn der DO schläft. Wenn ein Client eine Nachricht sendet, weckt Cloudflare den DO, übermittelt die Nachricht über die Methode webSocketMessage() und der DO kann wieder in den Ruhezustand versetzt werden, wenn die Verarbeitung abgeschlossen ist.

export class Room implements DurableObject { constructor(private ctx: DurableObjectState, private env: Env) {} async fetch(request: Request): Promise<Response> { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { const sockets = this.ctx.getWebSockets(); for (const session of sockets) { if (session !== ws) { session.send(message as string); } } } async webSocketClose(ws: WebSocket, code: number): Promise<void> { ws.close(code); } }

Im Ruhezustand sind die DO-Rechenkosten proportional zur Zeit für die Verarbeitung von Nachrichten und nicht zur Zeit, die Verbindungen geöffnet sind. Ein Raum mit fünf Benutzern, die acht Stunden lang still sind, kostet praktisch null Rechenleistung. Die tatsächlichen Kosten entstehen, wenn Benutzer sich aktiv gegenseitig Nachrichten senden. Bei kollaborativen Anwendungen, bei denen es häufig zu Inaktivitätsphasen kommt – ein gemeinsames Dokument, das die meisten Mitarbeiter öffnen, aber nicht kontinuierlich bearbeiten – kann der Kostenunterschied zwischen dem direkten und dem Ruhezustandsmodell ein oder zwei Größenordnungen betragen.

this.ctx.getWebSockets() gibt alle aktiven Verbindungen zurück, die vom Sleep-Framework verwaltet werden – äquivalent zu Set, die Sie manuell verwalten würden, die jedoch von der Plattform zwischen den Ruhephasen beibehalten werden. Dies bedeutet, dass Sie die Sitzungsliste nicht neu erstellen müssen, wenn der DO aufwacht: Sie ist bereits verfügbar.

Dauerhafter Zustand zwischen Ruhezustand

Ein Detail, das diejenigen auffällt, die vom direkten Modell kommen: Wenn das DO schläft und aufwacht, wird der Konstruktor erneut aufgerufen, aber der Zustand im Speicher (this.sessions, this.roomName, jede Instanzvariable) geht verloren. Nur die vom Ruhezustands-Framework verwalteten Speicher- und WebSocket-Verbindungen bleiben erhalten.

Für Zustände, die den Ruhezustand überstehen müssen – Raummetadaten, Nachrichtenverlauf, Benutzeranwesenheit – ist der Speicher der richtige Ort:

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { const data = JSON.parse(message as string); if (data.type === 'join') { const users = await this.ctx.storage.get<string[]>('users') ?? []; users.push(data.userId); await this.ctx.storage.put('users', users); } const sockets = this.ctx.getWebSockets(); for (const session of sockets) { session.send(message as string); } }

Das Muster, das funktioniert: Behalten Sie nur das im Speicher, was aus dem Speicher abgeleitet werden kann und zwischen dem Ruhezustand verworfen werden kann. Bewahren Sie alles auf, was Sie zum Überleben brauchen. Booten Sie bei Bedarf von blockConcurrencyWhile() im Konstruktor.

Was Sie mit diesem Modell erhalten

Die Kombination aus Anforderungsserialisierung, WebSocket-Ruhezustand und Alarm-API löst eine Reihe spezifischer Probleme ohne zusätzliche Infrastruktur. Kollaborative Bearbeitung in Echtzeit, bei der mehrere Clients dasselbe Dokument ändern und Aktualisierungen mit geringer Latenz sehen müssen. Anwesenheit von Benutzern – wissen, wer in einem Raum online ist – wobei die Liste auch bei gleichzeitigen Ein- und Ausgängen konsistent sein muss. Multiplayer-Spiele mit einfachem Sitzungsstatus, bei denen die Häufigkeit der Aktualisierungen eine zentralisierte Koordination rechtfertigt. Von den Teilnehmern gemeinsam genutzte Timer, wie Countdowns in einer Besprechung, die für alle konsistent sein müssen.

Die Durchsatzobergrenze besteht weiterhin: Ein DO, der jede Nachricht in 2 ms verarbeitet, kann 500 Nachrichten pro Sekunde von verschiedenen Clients verarbeiten. Bei Räumen mit einigen Dutzend aktiven Nutzern gleichzeitig wird diese Obergrenze nie erreicht. In Fällen, in denen Hunderte von Clients kontinuierlich Nachrichten an denselben DO senden, ist eine Aufteilung nach Raum oder Raumgruppe erforderlich.

Was dieses Modell nicht löst

Die Persistenz des Nachrichtenverlaufs für Benutzer, die offline ankommen, ist ein separates Problem. Der DO-Speicher speichert den Verlauf, solange der DO existiert, es handelt sich jedoch nicht um eine Abfragebank. Um nach Nachrichten aus einem Zeitraum zu suchen, nach Benutzern zu filtern oder eine Operation auszuführen, die SQL nutzt, benötigen Sie D1 als ergänzenden Speicher, den der DO mit jeder Nachricht füllt.

Auch der geografische Maßstab weist Einschränkungen auf. Ein DO existiert in einem einzelnen PoP. Bei Anwendungen mit Benutzern in weit entfernten Regionen, die im selben Raum zusammenarbeiten, umfasst die Nachrichtenlatenz den Hin- und Rückweg zum PoP, an dem sich der DO befindet – bei einem Benutzer in São Paulo könnte es sich um Frankfurt handeln. Für die meisten kollaborativen Anwendungen ist diese Latenz akzeptabel. Für Spiele, die eine Latenz von weniger als 50 ms für alle Spieler erfordern, muss die Architektur anders sein.

Langlebige Objekte mit WebSocket-Ruhezustand lösen Multiplayer ohne einen dedizierten Server für eine Reihe realer Anwendungsfälle, mit einem Kostenmodell, das Anwendungen bevorzugt, bei denen Benutzer viel mehr Zeit mit Lesen als mit Schreiben verbringen. Außerhalb dieses Rahmens werden Grenzen schnell deutlich.

Lesen Sie auch