Durable Objects — Stateful Ứng Dụng Real-time Trên Workers
Xây dựng ứng dụng stateful với Durable Objects: WebSocket, game server, coordination — và SQLite storage built-in.
Durable Objects (DO) là giải pháp stateful cho Cloudflare Workers. Mỗi DO có một instance duy nhất — state tồn tại, consistency mạnh.
flowchart LR
subgraph EdgeWorkers["Stateless Workers (Toàn cầu)"]
direction TB
W_Tokyo["Worker Tokyo"]
W_London["Worker London"]
W_US["Worker US"]
end
subgraph DO_Instance["Durable Object (Instance Duy Nhất Toàn Cầu)"]
direction TB
ID["ID: 'user-room-42'"]
subgraph Memory["RAM (In-Memory State)"]
State["Active Connections & State"]
end
subgraph Storage["Persistent Storage"]
SQL[("SQLite / KV Storage")]
end
ID --> Memory
Memory <--> Storage
end
W_Tokyo -->|"Tất cả request cùng ID<br/>được điều hướng về 1 điểm"| DO_Instance
W_London -->|"Strong Consistency<br/>(Không bị race condition)"| DO_Instance
W_US -->|"Tồn tại state liên tục"| DO_Instance
Workers vs Durable Objects#
flowchart TB
subgraph Clients["Clients & Browsers"]
C1["User A (WebSocket)"]
C2["User B (WebSocket)"]
C3["User C (HTTP REST)"]
end
subgraph Edge["Cloudflare Global Edge (Stateless Workers)"]
W1["Worker (Edge Location 1)"]
W2["Worker (Edge Location 2)"]
end
subgraph DO_Cluster["Durable Objects (Single Coordinated Instance per ID)"]
subgraph DO1["Chat Room DO (ID: room-123)"]
Mem["In-Memory State & Active WebSockets"]
SQL[("Built-in SQLite / KV Storage")]
Alarm["Alarms / Timers"]
Mem <--> SQL
Mem <--> Alarm
end
subgraph DO2["Chat Room DO (ID: room-456)"]
Mem2["In-Memory State"]
SQL2[("SQLite Storage")]
end
end
C1 <-->|"Edge Connection"| W1
C2 <-->|"Edge Connection"| W2
C3 -->|"Request"| W1
W1 <-->|"Route by ID: room-123"| DO1
W2 <-->|"Route by ID: room-123"| DO1
W1 -.->|"Route by ID: room-456"| DO2
| Workers | Durable Objects |
|---|---|
| Stateless | Stateful |
| Nhiều request - nhiều instance | Mỗi ID - một instance duy nhất |
| Không giữ state | Giữ state trong memory + storage |
| Scale vô hạn | Scale by sharding (nhiều DO) |
| 330+ locations | Một location (có migration) |
Chat Room — Ví Dụ Cơ Bản#
sequenceDiagram
autonumber
actor ClientA as User Alice
actor ClientB as User Bob
participant Worker as Stateless Worker Router
participant DO as ChatRoom [Durable Object]
Note over ClientA, DO: Khởi tạo kết nối WebSocket
ClientA->>Worker: GET /?room=general&name=Alice (Upgrade: WebSocket)
Worker->>DO: stub.fetch(request) [idFromName("general")]
DO->>DO: server.accept(), lưu session "Alice"
DO-->>ClientA: 101 Switching Protocols (WebSocket Connected)
DO--)ClientB: Broadcast "Alice joined the chat"
Note over ClientA, DO: Gửi tin nhắn Real-time
ClientA->>DO: WS Message: "Hello room!"
DO->>DO: broadcast("Alice: Hello room!", sender=Alice)
DO--)ClientB: WS Message: "Alice: Hello room!"
import { DurableObject } from 'cloudflare:workers';
interface Env {
CHAT_ROOM: DurableObjectNamespace;
}
export class ChatRoom extends DurableObject {
private sessions = new Map<string, WebSocket>();
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const name = url.searchParams.get('name') || 'anonymous';
// WebSocket upgrade
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.set(name, server);
// Broadcast welcome
this.broadcast(`${name} joined the chat`);
server.addEventListener('message', (event) => {
this.broadcast(`${name}: ${event.data}`, server);
});
server.addEventListener('close', () => {
this.sessions.delete(name);
this.broadcast(`${name} left`);
});
return new Response(null, { status: 101, webSocket: client });
}
private broadcast(message: string, sender?: WebSocket) {
const data = JSON.stringify({ message, timestamp: Date.now() });
this.sessions.forEach((ws, name) => {
if (ws !== sender && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
}
}
// Worker — router đến DO
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const roomId = url.searchParams.get('room') || 'default';
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
return stub.fetch(request);
},
};typescriptSQLite Storage — D1 Built-in#
Mỗi Durable Object có SQLite storage riêng:
flowchart TD
Req["Request: GET /increment /get /reset"] --> Match{url.pathname}
Match -->|"/increment"| Inc["storage.get('count')<br/>storage.put('count', count + 1)"]
Match -->|"/get"| Get["storage.get('count')"]
Match -->|"/reset"| Res["storage.put('count', 0)"]
Match -->|Khác| Err["404 Not Found"]
Inc --> Resp["Return JSON count"]
Get --> Resp
Res --> Resp
export class Counter extends DurableObject {
private storage: DurableObjectStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.storage = ctx.storage;
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
switch (url.pathname) {
case '/increment':
const count = (await this.storage.get<number>('count')) || 0;
await this.storage.put('count', count + 1);
return Response.json({ count: count + 1 });
case '/get':
const current = (await this.storage.get<number>('count')) || 0;
return Response.json({ count: current });
case '/reset':
await this.storage.put('count', 0);
return Response.json({ count: 0 });
default:
return new Response('Not found', { status: 404 });
}
}
}typescriptSQL Queries Với storage.sql#
Dùng SQL trực tiếp với Durable Objects:
flowchart TD
Req["HTTP Request"] --> Init["Tạo bảng nếu chưa có: CREATE TABLE IF NOT EXISTS todos (...)"]
Init --> Router{HTTP Method}
Router -->|"GET"| Q1["SELECT * FROM todos ORDER BY id DESC"]
Router -->|"POST"| Q2["INSERT INTO todos (title) VALUES (?)"]
Router -->|"PUT"| Q3["UPDATE todos SET completed = ? WHERE id = ?"]
Router -->|"DELETE"| Q4["DELETE FROM todos WHERE id = ?"]
Router -->|Other| Q5["405 Method Not Allowed"]
Q1 --> Out1["JSON: List of todos"]
Q2 --> Out2["201 Created: new todo"]
Q3 --> Out3["JSON: { success: true }"]
Q4 --> Out4["JSON: { success: true }"]
export class TodoApp extends DurableObject {
async fetch(request: Request): Promise<Response> {
const sql = this.ctx.storage.sql;
// Create table
sql.exec(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0
)
`);
const url = new URL(request.url);
// List
if (request.method === 'GET') {
const result = sql.exec('SELECT * FROM todos ORDER BY id DESC');
return Response.json(result.toArray());
}
// Create
if (request.method === 'POST') {
const { title } = await request.json();
const result = sql.exec('INSERT INTO todos (title) VALUES (?)', title);
return Response.json({ id: result.lastRowId, title, completed: false }, { status: 201 });
}
// Update
if (request.method === 'PUT') {
const { id, completed } = await request.json();
sql.exec('UPDATE todos SET completed = ? WHERE id = ?', completed, id);
return Response.json({ success: true });
}
// Delete
if (request.method === 'DELETE') {
const id = url.searchParams.get('id');
sql.exec('DELETE FROM todos WHERE id = ?', id);
return Response.json({ success: true });
}
return new Response('Method not allowed', { status: 405 });
}
}typescriptAlarm — Cron Cho DO#
sequenceDiagram
autonumber
actor User as User / Client
participant DO as Reminder DO
participant Storage as ctx.storage [Alarm Queue]
participant Webhook as External Webhook
Note over DO, Storage: 1. Đăng ký Timer / Cron
User->>DO: GET /?delay=5000
DO->>Storage: setAlarm(futureTimestamp)
DO-->>User: {"message": "Alarm set for 5000ms"}
Note over Storage, Webhook: 2. Kích hoạt khi hết giờ (Wake up DO)
Storage-->>DO: Kích hoạt handler alarm()
DO->>Webhook: POST https://hooks.example.com/notify { event: 'alarm_fired' }
DO->>Storage: setAlarm(nextHourTimestamp) [Đặt lịch lặp lại]
export class Reminder extends DurableObject {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm) {
console.log('Recovered alarm:', new Date(alarm).toISOString());
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const delayMs = parseInt(url.searchParams.get('delay') || '5000');
// Set alarm sau delayMs
await this.ctx.storage.setAlarm(Date.now() + delayMs);
return Response.json({ message: `Alarm set for ${delayMs}ms` });
}
async alarm() {
// Được gọi khi alarm hết giờ
console.log('⏰ Alarm fired!');
// Gửi notification qua WebSocket nếu có
// Hoặc call webhook
await fetch('https://hooks.example.com/notify', {
method: 'POST',
body: JSON.stringify({ event: 'alarm_fired', time: Date.now() }),
});
// Set alarm lại nếu cần
await this.ctx.storage.setAlarm(Date.now() + 3600000);
}
}typescriptMultiplayer Game Server#
flowchart TD
WS["WebSocket Message Event"] --> EventType{msg.type}
EventType -->|"join"| Join["1. Lưu session player vào Map<br/>2. Thêm Player state: x=0, y=0<br/>3. Broadcast: type: 'players'"]
EventType -->|"move"| Move["1. Cập nhật player.x += dx, player.y += dy<br/>2. Broadcast: type: 'move'"]
EventType -->|"shoot"| Shoot["1. Tính góc & tọa độ bắn<br/>2. Broadcast: type: 'shoot'"]
Close["WebSocket Close Event"] --> Leave["1. Xóa player khỏi Player Map & State<br/>2. Broadcast: type: 'leave'"]
Join --> BroadcastAll["Gửi State cập nhật tới toàn bộ người chơi trong phòng"]
Move --> BroadcastAll
Shoot --> BroadcastAll
Leave --> BroadcastAll
interface Player {
id: string;
x: number;
y: number;
score: number;
}
export class GameRoom extends DurableObject {
private players = new Map<string, WebSocket>();
private state: Player[] = [];
async fetch(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
server.addEventListener('message', (event) => {
const msg = JSON.parse(event.data as string);
switch (msg.type) {
case 'join':
this.players.set(msg.playerId, server);
this.state.push({ id: msg.playerId, x: 0, y: 0, score: 0 });
this.broadcast({ type: 'players', data: this.state });
break;
case 'move':
const player = this.state.find(p => p.id === msg.playerId);
if (player) {
player.x += msg.dx;
player.y += msg.dy;
this.broadcast({ type: 'move', playerId: msg.playerId, x: player.x, y: player.y });
}
break;
case 'shoot':
this.broadcast({
type: 'shoot',
playerId: msg.playerId,
x: msg.x,
y: msg.y,
angle: msg.angle,
});
break;
}
});
server.addEventListener('close', () => {
this.state = this.state.filter(p => !this.players.has(p.id));
this.players.delete(msg.playerId);
this.broadcast({ type: 'leave', playerId: msg.playerId });
});
return new Response(null, { status: 101, webSocket: client });
}
private broadcast(message: object) {
const data = JSON.stringify(message);
this.players.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
}
}typescriptMigration — Di Chuyển DO#
DO có thể migrate giữa các region:
// wrangler.jsonc
{
"durable_objects": {
"bindings": [
{
"name": "COUNTER",
"class_name": "Counter",
"migration": "new_tag"
}
]
}
}typescriptMigration types:
new_tag— Class mớinew_classes— Thêm nhiều classrename— Đổi tên classtransfer— Chuyển dữ liệu
Pattern — Sharding#
Một DO chỉ chạy ở một location. Để scale, shard theo key:
flowchart LR
Req["Request: userId"] --> Formula["shardId = floor(userId / 1000)"]
Formula --> ShardMap{Shard Router}
ShardMap -->|"userId: 0 - 999"| S0["DO Instance: shard-0"]
ShardMap -->|"userId: 1000 - 1999"| S1["DO Instance: shard-1"]
ShardMap -->|"userId: 2000 - 2999"| S2["DO Instance: shard-2"]
ShardMap -->|"userId: N - N+999"| Sn["DO Instance: shard-N"]
// Shard user data theo userId
function getUserStub(userId: number, env: Env): DurableObjectStub {
const shardId = Math.floor(userId / 1000); // 1000 user mỗi shard
const id = env.USER_STORE.idFromName(`shard-${shardId}`);
return env.USER_STORE.get(id);
}
// Handler
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const userId = parseInt(url.searchParams.get('userId') || '0');
const stub = getUserStub(userId, env);
return stub.fetch(request);
},
};typescriptKết Luận#
Durable Objects giải quyết vấn đề state trong serverless:
- Real-time — WebSocket với state central consistency
- Game server — multiplayer coordination
- Coordination — distributed lock, rate limiting
- SQLite storage — không cần DB riêng
Kiến trúc: Workers stateless cho request thông thường, DO cho những gì cần state. Shard DO theo key để scale ngang.