Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Oggetti durevoli e WebSocket: multiplayer senza server dedicato

Come i Sustainable Objects risolvono il problema dello stato condiviso tra connessioni WebSocket in tempo reale, con l'Hibernation API che elimina il costo delle connessioni inattive.

Oggetti durevoli e WebSocket: multiplayer senza server dedicato

Il presupposto che interrompe la maggior parte delle implementazioni WebSocket in serverless è che avere più Worker che accettano connessioni risolve il problema di ridimensionamento. Non lo risolve: crea diversi mondi isolati in cui ogni cliente parla solo con istanze del proprio Worker, senza visibilità di chi è connesso agli altri. Due client che aprono una connessione allo stesso endpoint possono trovarsi su Worker completamente diversi, senza alcun canale per lo scambio di messaggi tra di loro. Questo isolamento è esattamente ciò che rende Workers scalabile ed è esattamente ciò che rende irrealizzabile qualsiasi presenza in tempo reale o funzionalità collaborativa senza un livello di coordinamento esterno.

Perché i lavoratori isolati non sono sufficienti per il multiplayer

Immagina una chat in camera. Dieci client connessi. La stanza esiste come concetto nell'applicazione, ma non esiste come oggetto in memoria in nessuna parte del Worker. Ogni connessione WebSocket viene accettata da un Worker che non conosce gli altri. Quando il client A invia un messaggio, il Worker che riceve quel messaggio non ha modo di raggiungere gli altri nove client connessi ad altri Worker.

La soluzione convenzionale è aggiungere un livello esterno: Pub/Sub (Redis, Upstash), database per messaggi persistenti e polling o un servizio WebSocket dedicato come Ably o Pusher. Tutte queste soluzioni funzionano, ma aggiungono un salto di latenza, un servizio da gestire e un costo che aumenta con le connessioni attive, non con l'utilizzo effettivo.

Un oggetto durevole modifica il punto di coordinamento. La stanza non è implicita nel concetto di applicazione: diventa una DO identificata dal nome. Tutti i client che desiderano accedere a "room-456" vengono instradati allo stesso DO, che mantiene in memoria l'elenco delle connessioni WebSocket e può consegnare messaggi uno a tutti senza round trip esterni. Il coordinamento è locale rispetto al DO.

Il modello base e il costo senza ibernazione

L'implementazione diretta della trasmissione all'interno di un DO è semplice. Il DO mantiene in memoria Set dei WebSocket oggetti, accetta nuove connessioni e esegue un'iterazione su tutti per consegnare messaggi:

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 }); } }

Questo codice funziona. Il problema dei costi emerge quando si analizza il modello di fatturazione: mentre questo DO ha le connessioni WebSocket aperte e sta elaborando i listener di eventi, è sveglio. Anche se nessun client invia messaggi, il DO è ancora attivo e consuma GB-secondi. Per una stanza con cinque utenti inattivi per otto ore, DO è attivo per otto ore e paghi per ogni secondo di elaborazione durante tale periodo.

L'API di ibernazione e cosa cambia nel modello di costo

L'API di ibernazione WebSocket inverte questo costo. Invece di mantenere le connessioni attive in memoria, passi il controllo a Cloudflare usando this.ctx.acceptWebSocket(ws) invece di ws.accept(). Da lì, Cloudflare mantiene aperte le connessioni WebSocket anche mentre il DO dorme. Quando un client invia un messaggio, Cloudflare sveglia il DO, consegna il messaggio tramite il metodo webSocketMessage() e il DO può tornare a dormire una volta terminata l'elaborazione.

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); } }

Con l'ibernazione, il costo di calcolo DO è proporzionale al tempo di elaborazione dei messaggi, non al tempo di apertura delle connessioni. Una stanza con cinque utenti in silenzio per otto ore costa praticamente zero in termini di calcolo. Il costo reale appare quando gli utenti si scambiano attivamente messaggi. Per le applicazioni collaborative in cui sono comuni periodi di inattività (un documento condiviso che la maggior parte dei collaboratori apre ma non modifica continuamente) la differenza di costo tra il modello diretto e quello ibernato può essere di uno o due ordini di grandezza.

this.ctx.getWebSockets() restituisce tutte le connessioni attive gestite dal framework di sospensione, equivalenti a Set che manterresti manualmente, ma mantenute dalla piattaforma tra le fasi di sospensione. Ciò significa che non è necessario ricostruire l'elenco delle sessioni al risveglio del DO: è già disponibile.

Stato persistente tra le ibernazioni

Un dettaglio che coglie chi proviene dal modello diretto: quando il DO dorme e si sveglia, il costruttore viene chiamato nuovamente, ma lo stato in memoria (this.sessions, this.roomName, qualsiasi variabile di istanza) viene perso. Sopravvivono solo le connessioni di archiviazione e WebSocket gestite dal framework di ibernazione.

Per gli stati che devono sopravvivere all'ibernazione (metadati della stanza, cronologia dei messaggi, presenza dell'utente), l'archiviazione è il posto giusto:

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); } }

Il modello che funziona: mantenere in memoria solo ciò che è derivabile dall'archiviazione e può essere scartato tra i letargo. Conservare in deposito tutto ciò che serve per sopravvivere. Avviare da blockConcurrencyWhile() nel costruttore quando necessario.

Cosa ottieni con questo modello

La combinazione di serializzazione delle richieste, ibernazione WebSocket e API Alarm risolve una serie specifica di problemi senza infrastrutture aggiuntive. Modifica collaborativa in tempo reale in cui più clienti modificano lo stesso documento e necessitano di vedere aggiornamenti con bassa latenza. Presenza di utenti – sapere chi è online in una stanza – dove l’elenco deve essere coerente anche con entrate e uscite simultanee. Giochi multiplayer con stato di sessione semplice in cui la frequenza degli aggiornamenti giustifica il coordinamento centralizzato. Timer condivisi tra i partecipanti, come i conti alla rovescia in una riunione, che devono essere coerenti per tutti.

Il limite di throughput esiste ancora: un DO che elabora ogni messaggio in 2 ms può gestire 500 messaggi al secondo da client diversi. Per stanze con qualche decina di utenti attivi contemporaneamente questo tetto non viene mai raggiunto. Nei casi in cui centinaia di client inviano messaggi continuamente allo stesso DO, diventa necessario lo sharding per stanza o gruppo di stanze.

Ciò che questo modello non risolve

La persistenza della cronologia dei messaggi per gli utenti che arrivano offline è un problema separato. L'archiviazione DO memorizza la cronologia mentre il DO esiste, ma non è una banca di query. Per cercare messaggi di un periodo, filtrare per utente o eseguire qualsiasi operazione che sfrutti SQL, è necessario D1 come spazio di archiviazione complementare che DO popola con ciascun messaggio.

Anche la scala geografica presenta dei limiti. Un DO esiste in un singolo PoP. Per le applicazioni con utenti in regioni molto distanti che collaborano nella stessa stanza, la latenza del messaggio include il viaggio di andata e ritorno al PoP dove si trova il DO, che potrebbe essere Francoforte per un utente a San Paolo. Per la maggior parte delle applicazioni collaborative, questa latenza è accettabile. Per i giochi che richiedono una latenza inferiore a 50 ms per tutti i giocatori, l'architettura deve essere diversa.

Gli oggetti durevoli con ibernazione WebSocket risolvono il multiplayer senza server dedicato per un insieme reale di casi d'uso, con un modello di costo che privilegia le applicazioni in cui gli utenti trascorrono molto più tempo a leggere che a scrivere. Al di fuori di questo involucro, le limitazioni diventano rapidamente evidenti.

Leggi anche