Cloudflare
Durable Objects
Programação
Estado
Concorrência

Il modello di programmazione degli oggetti durevoli: cosa c'è di diverso da qualsiasi cosa tu abbia mai usato

In che modo il modello di programmazione di Sustainable Objects differisce da qualsiasi altro modello serverless convenzionale, con le API di archiviazione, transazione e allarme che cambiano ciò che è possibile.

Il modello di programmazione degli oggetti durevoli: cosa c'è di diverso da qualsiasi cosa tu abbia mai usato

La maggior parte degli ingegneri che incontrano gli oggetti durevoli per la prima volta li leggono come "Lavoratori con [database incorporato]" e iniziano a scrivere codice in quella direzione. Il risultato sono applicazioni che funzionano in fase di test e falliscono in modo subdolo in produzione. Il modello mentale è sbagliato perché la differenza tra un DO e un lavoratore con accesso a una banca non è di convenienza: è dove vive lo stato e chi controlla l'accesso ad esso.

La struttura di una classe di oggetti durevoli

Un DO è una classe JavaScript con tre elementi fondamentali: il costruttore, il metodo fetch() e l'accesso this.ctx.storage. Il costruttore viene eseguito una volta quando viene creato il DO o si riattiva dalla modalità di ibernazione. fetch() elabora ogni richiesta ricevuta. E this.ctx.storage è l'API di archiviazione durevole che mantiene lo stato tra le richieste e tra le ibernazioni.

export class Counter implements DurableObject { private count: number = 0; constructor(private ctx: DurableObjectState, private env: Env) { this.ctx.blockConcurrencyWhile(async () => { this.count = (await this.ctx.storage.get<number>('count')) ?? 0; }); } async fetch(request: Request): Promise<Response> { this.count++; await this.ctx.storage.put('count', this.count); return new Response(String(this.count)); } }

Il blockConcurrencyWhile() nel costruttore è necessario quando è necessario idratare lo stato di archiviazione prima di elaborare qualsiasi richiesta. Senza di essa, una richiesta arrivata prima che lo spazio di archiviazione await fosse risolto vedrebbe uno stato incompleto. blockConcurrencyWhile mette in coda le richieste finché la richiamata non viene risolta: questo è il modo per inizializzare il DO in modo sicuro.

Cosa cambia quando lo Stato vive nella memoria

L'operazione "lettura da banca, elaborazione, riscrittura" è il modello più comune nelle applicazioni lato server e anche l'origine delle condizioni di competizione più comuni. Tra la lettura e la scrittura, un altro processo potrebbe aver modificato lo stesso record. Per risolvere questo problema con una banca convenzionale, si utilizzano transazioni con blocchi, SELEZIONA PER AGGIORNAMENTO o qualche forma di controllo delle versioni ottimistico.

In un DO, questo problema non esiste allo stesso modo. Lo stato vive nella memoria: this.count ha già il valore attuale. Non c'è andata e ritorno da leggere. L'incremento e la persistenza avvengono all'interno della stessa esecuzione, senza che un'altra richiesta possa interlacciarsi. La garanzia non è della banca: è dell'esecuzione seriale della DO stessa.

Ciò ha un costo che deve essere esplicito: la coerenza dipende dalla persistenza dello stato modificato in memoria. Se il DO entra in ibernazione prima che venga chiamato un put(), la modifica della memoria viene persa. Ogni modifica importante deve essere mantenuta nell'archivio prima che la richiesta termini. Lo schema corretto consiste nel modificare e rendere persistente la stessa operazione, senza fare affidamento su uno svuotamento successivo.

L'API di archiviazione e quando utilizzare la transazione()

L'API di archiviazione ha operazioni dirette: get(), put(), delete(), list(). Tutti sono individualmente atomici e durevoli: è garantito che ciò che è stato scritto sarà su disco anche se il DO va in letargo immediatamente dopo. La linearizzabilità si applica all'intera memorizzazione di un'istanza: qualsiasi lettura dopo put() vedrà il valore scritto, senza eccezioni.

Quando un'operazione logica modifica più chiavi e tutte devono essere coerenti tra loro, transaction() è lo strumento corretto:

await this.ctx.storage.transaction(async (txn) => { const balance = await txn.get<number>('balance') ?? 0; await txn.put('balance', balance - amount); await txn.put('last_debit', Date.now()); await txn.put('debit_count', (await txn.get<number>('debit_count') ?? 0) + 1); });

Se la richiamata transaction() genera un'eccezione, nessuna delle scritture viene mantenuta. Lo stato di archiviazione ritorna a quello che era prima dell'inizio della transazione. Non esiste un commit parziale. Ciò sostituisce la necessità di compensare le transazioni o la logica di rollback nell'applicazione per le operazioni che interessano più chiavi.

Un dettaglio operativo di list(): per impostazione predefinita restituisce un massimo di 128 voci. Per insiemi più grandi, utilizzare il cursore restituito nella risposta a page. Ignorare questo significa scoprire il limite della produzione con dati reali.

L'API Alarm: setTimeout che sopravvive all'ibernazione

I lavoratori hanno setTimeout(), ma il timer non sopravvive all'ibernazione isolata. Quando il lavoratore termina l'elaborazione della richiesta, tutti i timer in sospeso scompaiono. Per un DO che deve fare qualcosa in futuro (eliminare sessioni scadute, inviare nuovamente un messaggio non riuscito, invalidare un blocco dopo un timeout), l'API Alarm è il meccanismo giusto:

async fetch(request: Request): Promise<Response> { const body = await request.json() as { lockKey: string; ttlMs: number }; await this.ctx.storage.put(`lock:${body.lockKey}`, true); await this.ctx.storage.setAlarm(Date.now() + body.ttlMs); return new Response('lock acquired'); } async alarm(): Promise<void> { const keys = await this.ctx.storage.list({ prefix: 'lock:' }); for (const key of keys.keys()) { await this.ctx.storage.delete(key); } }

setAlarm() riceve un timestamp assoluto in millisecondi. Quando DO dorme, Cloudflare mantiene l'allarme programmato. Al momento giusto il DO si sveglia e viene richiamato il metodo alarm(). Il costo è di 0,15 dollari per milione di invocazioni, lo stesso listino prezzi delle normali richieste.

È possibile avere un solo allarme attivo per DO alla volta. setAlarm() sostituisce il precedente. Se sono necessari più timer, la strategia consiste nel memorizzare l'elenco degli eventi imminenti nella memoria, pianificare un allarme per quello più vicino e, quando viene eseguito alarm(), elaborare gli eventi scaduti e riprogrammarli per quello successivo.

Come funziona il routing all'istanza giusta

Gli oggetti durevoli hanno tre strategie di generazione degli ID e la scelta determina il comportamento di instradamento.

idFromName("room-123") è deterministico: lo stesso nome produce sempre lo stesso ID, a livello globale. È la strategia da adottare quando si desidera che tutti i clienti che richiedono "stanza-123" arrivino allo stesso DO. L'ID viene derivato eseguendo l'hashing del nome e due DO con lo stesso nome nello stesso spazio dei nomi rappresentano lo stesso oggetto.

newUniqueId() genera un ID casuale, sempre un nuovo DO. Da utilizzare quando crei un'entità e memorizzerai l'ID per riferimento futuro senza doverlo derivare da una chiave.

idFromString(hexStr) ricostruisce un ID da una stringa esadecimale precedentemente memorizzata. Utile quando l'ID è stato generato con newUniqueId(), persiste in un database esterno o nel KV ed è necessario fare riferimento allo stesso DO in seguito.

I suggerimenti sulla posizione ti consentono di suggerire una giurisdizione geografica (UE, USA), ma non garantiscono un PoP specifico. Il DO viene creato nel PoP più vicino alla prima richiesta che lo ha istanziato. Per le applicazioni con requisiti di residenza dei dati UE, locationHint: 'eu' indirizza la creazione a un PoP europeo, ma Cloudflare sceglie quale.

Come si connette al lavoratore che instrada

Il Lavoratore che riceve la richiesta del cliente deve ottenere lo stub DO e inoltrare la richiesta:

export default { async fetch(request: Request, env: Env): Promise<Response> { const roomId = new URL(request.url).searchParams.get('room') ?? 'default'; const id = env.ROOMS.idFromName(roomId); const stub = env.ROOMS.get(id); return stub.fetch(request); } };

La matrice ha lo stesso contratto fetch() di un normale Lavoratore. Il lavoratore che instrada non ha accesso alla memoria DO, non può leggere il suo stato interno e non può chiamare metodi diversi da fetch() e RPC (se configurato con WorkerEntrypoint). La comunicazione avviene tramite normali richieste HTTP.

Gli oggetti durevoli non sono un'astrazione del database. Sono un modello di elaborazione con stato. Trattarli come un database che elabora anche la logica, piuttosto che come un oggetto con identità e stato, è ciò che porta alla confusione e al codice che funziona in produzione per caso, non per progettazione.

Leggi anche