Vai al contenuto principale
Edge Computing

Cloudflare Durable Objects: Costruzione di un Game Server Stateful all'Edge

7 min lettura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
Disponibile anche in: English, Español

Cosa Sono Realmente i Durable Objects

Un Durable Object è un oggetto JavaScript/TypeScript single-threaded che vive all'edge. Può essere pensato come un attore nel modello ad attori, ma il runtime gestisce tutte le parti difficili: routing delle richieste all'istanza giusta, persistenza dello stato, e garanzia di accesso single-threaded.

Ogni Durable Object ha:

  • Un ID globalmente unico
  • Un contesto di esecuzione single-threaded (bug di concorrenza impossibili)
  • Un'API di storage key-value transazionale (strongly consistent)
  • La capacità di accettare connessioni WebSocket
  • Un sistema di allarmi per lavoro programmato

La proprietà critica: esiste una sola istanza di un dato Durable Object in tutto il mondo in qualsiasi momento. La piattaforma lo garantisce.

La Game Room: Modello ad Attori in Pratica

Ecco lo scheletro di un'implementazione di game server. Ogni stanza di gioco è un Durable Object:

export class GameRoom implements DurableObject {
 private state: DurableObjectState;
 private env: Env;
 private gameState: GameState | null = null;
 private sessions: Map<string, WebSocket> = new Map();

 constructor(state: DurableObjectState, env: Env) {
 this.state = state;
 this.env = env;

 // CRITICO: Blocca tutte le richieste finché lo stato non è caricato
 this.state.blockConcurrencyWhile(async () => {
 const stored = await this.state.storage.get<GameState>("gameState");
 this.gameState = stored ?? createEmptyGame();
 });
 }

 async fetch(request: Request): Promise<Response> {
 const url = new URL(request.url);

 if (url.pathname === "/ws") {
 return this.handleWebSocket(request);
 }

 if (url.pathname === "/state") {
 return Response.json(this.gameState);
 }

 return new Response("Not found", { status: 404 });
 }

 private handleWebSocket(request: Request): Response {
 const pair = new WebSocketPair();
 const [client, server] = Object.values(pair);

 const playerId = new URL(request.url).searchParams.get("playerId");
 if (!playerId) {
 return new Response("playerId mancante", { status: 400 });
 }

 this.state.acceptWebSocket(server, [playerId]);
 this.sessions.set(playerId, server);

 server.send(JSON.stringify({
 type: "sync",
 state: this.gameState,
 yourId: playerId,
 }));

 this.broadcast({
 type: "player_joined",
 playerId,
 playerCount: this.sessions.size,
 }, playerId);

 return new Response(null, { status: 101, webSocket: client });
 }

 async webSocketMessage(ws: WebSocket, message: string) {
 const data = JSON.parse(message);
 const tags = this.state.getTags(ws);
 const playerId = tags[0];

 switch (data.type) {
 case "place_tile":
 this.handlePlaceTile(playerId, data);
 break;
 case "draw_tiles":
 this.handleDrawTiles(playerId);
 break;
 case "end_turn":
 this.handleEndTurn(playerId);
 break;
 }
 }

 async webSocketClose(ws: WebSocket) {
 const tags = this.state.getTags(ws);
 const playerId = tags[0];
 this.sessions.delete(playerId);

 this.broadcast({
 type: "player_left",
 playerId,
 playerCount: this.sessions.size,
 });

 if (this.sessions.size === 0) {
 await this.state.storage.setAlarm(Date.now() + 300_000);
 }
 }

 async alarm() {
 if (this.sessions.size === 0) {
 await this.state.storage.deleteAll();
 }
 }
}

La bellezza è l'assenza di complessità. Nessun mutex, nessun lock, nessun CAS loop, nessun controllo di concorrenza ottimistico. Il runtime garantisce che le chiamate webSocketMessage sono serializzate. Quando il Giocatore A piazza una tessera e il Giocatore B pesca tessere simultaneamente, si eseguono una dopo l'altra, mai interleaved.

Il Layer di Routing: Worker come Matchmaker

export default {
 async fetch(request: Request, env: Env): Promise<Response> {
 const url = new URL(request.url);

 if (url.pathname.startsWith("/game/")) {
 const roomId = url.pathname.split("/")[2];
 const id = env.GAME_ROOMS.idFromName(roomId);
 const room = env.GAME_ROOMS.get(id);
 return room.fetch(request);
 }

 if (url.pathname === "/matchmake") {
 return handleMatchmaking(request, env);
 }

 return env.ASSETS.fetch(request);
 }
};

L'intuizione chiave: idFromName() è un hash deterministico. La stringa "waiting:en:1200" mappa sempre allo stesso ID di Durable Object, quindi tutti i giocatori anglofoni con ~1200 di rating finiscono nella stessa stanza di matchmaking senza alcun layer di coordinazione.

Gestione dello Stato: Storage Transazionale

private async handlePlaceTile(playerId: string, data: PlaceTileAction) {
 if (this.gameState!.currentTurn !== playerId) {
 this.sendError(playerId, "Non è il tuo turno");
 return;
 }

 const { x, y, letter } = data;
 const validation = validatePlacement(this.gameState!, x, y, letter, playerId);
 if (!validation.valid) {
 this.sendError(playerId, validation.reason);
 return;
 }

 this.gameState!.board[y][x] = { letter, playerId, timestamp: Date.now() };
 this.gameState!.players[playerId].tiles = this.gameState!.players[playerId].tiles
 .filter(t => t !== letter);

 // Persisti atomicamente
 await this.state.storage.put("gameState", this.gameState);

 this.broadcast({
 type: "tile_placed",
 playerId,
 x, y, letter,
 scores: this.calculateScores(),
 });
}

Per operazioni più complesse, hai le transazioni:

await this.state.storage.transaction(async (txn) => {
 const game = await txn.get<GameState>("gameState");
 const stats = await txn.get<PlayerStats>(`stats:${playerId}`);

 game!.currentTurn = nextPlayer(game!);
 stats!.movesPlayed += 1;
 stats!.lastActive = Date.now();

 await txn.put("gameState", game);
 await txn.put(`stats:${playerId}`, stats);
});

Questo è ACID all'interno di un singolo Durable Object. Le scritture o committano tutte o rollbackano tutte. In un sistema distribuito tradizionale, ottenere questa garanzia attraverso multiple chiavi richiede un protocollo di consenso. Qui è una riga perché il modello single-threaded rende banale l'isolamento serializzabile.

Performance: Misurazioni Reali

Un load test con 500 giocatori simulati in 50 stanze produce questi risultati:

| Metrica | Valore | |---------|--------| | Latenza messaggio WebSocket (stessa regione) | 8-15ms | | Latenza messaggio WebSocket (cross-continente) | 40-90ms | | Latenza persistenza stato | 2-5ms | | Cold start (DO nuovo) | 15-25ms | | Elaborazione messaggio warm | <1ms |

La latenza cross-continente merita una spiegazione. Quando un giocatore a Tokyo si connette a una stanza creata da un giocatore a Berlino, il Durable Object gira vicino a Berlino. I messaggi del giocatore di Tokyo vengono instradati attraverso il backbone di Cloudflare fino a Berlino, processati, e la risposta torna indietro. Sono ~80ms di round trip. Non istantaneo, ma meglio di un server in us-east-1 per entrambi i giocatori.

Considerazioni Operative e Limitazioni

Limiti di memoria. Ogni Durable Object ha 128 MB. Sembra tanto finché non stai tenendo 1000 connessioni WebSocket con stato per-giocatore.

Nessuna primitiva di comunicazione inter-DO. Se la Stanza A deve sapere della Stanza B (es. per un bracket di torneo), devi passare per un Worker fetch. Non c'è pub/sub built-in tra Durable Object.

Ibernazione e costi. I Durable Object con WebSocket Hibernation vengono fatturati solo quando processano messaggi attivamente. Ma ogni operazione di storage costa. Persistere lo stato ad ogni mossa può risultare in $12/giorno in storage write durante i test. Raggruppare le scritture ogni 500ms riduce il costo a $0.80/giorno:

private pendingWrite = false;

private async scheduleWrite() {
 if (this.pendingWrite) return;
 this.pendingWrite = true;

 setTimeout(async () => {
 await this.state.storage.put("gameState", this.gameState);
 this.pendingWrite = false;
 }, 500);
}

Pattern Architetturale: Durable Objects + Vercel

L'architettura finale:

[Giocatori] → [Cloudflare Worker (routing)] → [Durable Objects (game state)]
 ↓
[Vercel (Next.js)] ← [Cloudflare Worker (static proxy)]
 ↓
[Vercel API Routes (account utente, leaderboard)]

Vercel gestisce il frontend Next.js e l'API non-realtime. Cloudflare gestisce il gameplay real-time. La separazione è pulita: tutto ciò che richiede consistenza forte e aggiornamenti in tempo reale va ai Durable Objects; tutto ciò che è eventually consistent e request/response va a Vercel.

Quando NON Usare Durable Objects

Non sono un backend general-purpose. Anti-pattern specifici:

  • API CRUD: Usa un database normale. I DO aggiungono latenza per pattern semplici di lettura/scrittura.
  • Computazione pesante: 128 MB di memoria, 30 secondi di CPU time limit.
  • Aggregazione globale: Se devi contare tutti i giocatori attivi in tutte le stanze, serve un sistema separato.
  • Dati relazionali: Niente join, niente indici, niente query. L'API di storage è un key-value store.

Brillano per: collaborazione in tempo reale, giochi multiplayer, chat room, coordinazione dispositivi IoT, rate limiting, e qualsiasi scenario dove un piccolo gruppo di client ha bisogno di stato condiviso strongly consistent con bassa latenza.

Il Verdetto

Dopo tre mesi in produzione con ~2000 giocatori attivi giornalieri, questo tipo di game server costa circa $34/mese su Cloudflare. L'equivalente su AWS sarebbero almeno due istanze EC2 più un Application Load Balancer più ElastiCache — probabilmente $150+/mese con latenza peggiore fuori da us-east-1.

Ancora più importante, sono necessari zero aggiornamenti infrastrutturali. Nessun patching, nessuna configurazione di scaling, nessun health check. La piattaforma gestisce tutto. I Durable Objects non sono la risposta a tutto. Ma per il problema specifico di "piccoli gruppi che necessitano stato condiviso real-time con consistenza forte" — rappresentano una delle soluzioni più solide disponibili.

cloudflaredurable-objectsedge-computingwebsocketsactor-modelworkers

Strumenti menzionati in questo articolo

CloudflareProva Cloudflare
VercelProva Vercel
Divulgazione: Alcuni link in questo articolo sono link di affiliazione. Se ti registri tramite questi, potrei guadagnare una commissione senza costi aggiuntivi per te. Raccomando solo strumenti che uso e di cui mi fido personalmente.
Compartir
Seguime