Cloudflare
Durable Objects
Programação
Estado
Concorrência

Le modèle de programmation Durable Objects : ce qui est différent de tout ce que vous avez déjà utilisé

En quoi le modèle de programmation de Durable Objects diffère de tout ce qui concerne le sans serveur conventionnel, avec les API de stockage, de transaction et d'alarme qui changent ce qui est possible.

Le modèle de programmation Durable Objects : ce qui est différent de tout ce que vous avez déjà utilisé

La plupart des ingénieurs qui rencontrent des objets durables pour la première fois les lisent comme des « travailleurs avec [base de données intégrée] » et commencent à écrire du code dans cette direction. Le résultat est des applications qui fonctionnent lors des tests et échouent de manière subtile en production. Le modèle mental est erroné parce que la différence entre un DO et un travailleur ayant accès à une banque n'est pas une question de commodité : c'est l'endroit où vit l'État et qui en contrôle l'accès.

La structure d'une classe Durable Object

Un DO est une classe JavaScript avec trois éléments principaux : le constructeur, la méthode fetch() et l'accès this.ctx.storage. Le constructeur s'exécute une fois lorsque le DO est créé ou sort de l'hibernation. fetch() traite chaque demande reçue. Et this.ctx.storage est l'API de stockage durable qui conserve son état entre les requêtes et entre les hibernations.

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

Le blockConcurrencyWhile() dans le constructeur est nécessaire lorsque vous devez hydrater l'état de stockage avant de traiter une requête. Sans cela, une requête arrivée avant la résolution du stockage await verrait un état incomplet. blockConcurrencyWhile met les requêtes en file d'attente jusqu'à ce que le rappel soit résolu — c'est le moyen d'initialiser le DO en toute sécurité.

Qu'est-ce qui change lorsque l'état vit en mémoire

L'opération « lire depuis la banque, calculer, réécrire » est le modèle le plus courant dans les applications côté serveur et également la source des conditions de concurrence les plus courantes. Entre la lecture et l'écriture, un autre processus peut avoir modifié le même enregistrement. Pour résoudre ce problème avec une banque conventionnelle, vous utilisez des transactions avec des verrous, SELECT FOR UPDATE ou une forme de versioning optimiste.

Dans un DO, ce problème n’existe pas de la même manière. L'état vit en mémoire — this.count a déjà la valeur actuelle. Il n’y a pas d’aller-retour à lire. L'incrémentation et la persistance se produisent au sein de la même exécution, sans qu'une autre requête puisse s'entrelacer. La garantie ne vient pas de la banque : elle provient de l'exécution en série du DO lui-même.

Cela a un coût qui doit être explicite : la cohérence dépend de la persistance de l'état que vous avez modifié en mémoire. Si le DO hiberne avant qu'un put() ne soit appelé, la modification de la mémoire est perdue. Chaque modification importante doit être conservée dans le stockage avant la fin de la demande. Le modèle correct consiste à modifier et à conserver la même opération, sans compter sur un vidage ultérieur.

L'API de stockage et quand utiliser transaction()

L'API de stockage a des opérations directes : get(), put(), delete(), list(). Tous sont individuellement atomiques et durables : ce qui a été écrit est garanti sur le disque même si le DO hiberne immédiatement après. La linéarisabilité s'applique à l'ensemble du stockage d'une instance : toute lecture après put() verra la valeur écrite, sans exception.

Lorsqu'une opération logique modifie plusieurs clés et qu'elles doivent toutes être cohérentes les unes avec les autres, transaction() est le bon outil :

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

Si le rappel transaction() lève une exception, aucune des écritures n'est conservée. L'état de stockage revient à ce qu'il était avant le début de la transaction. Il n’y a pas de validation partielle. Cela remplace le besoin de transactions compensatoires ou de logique de restauration dans l'application pour les opérations qui affectent plusieurs clés.

Un détail opérationnel de list() : renvoie par défaut un maximum de 128 entrées. Pour les ensembles plus grands, utilisez le curseur renvoyé dans la réponse à la page. Ignorer cela, c'est découvrir la limite de la production avec des données réelles.

L'API Alarm : setTimeout qui survit à l'hibernation

Les travailleurs ont setTimeout(), mais le minuteur ne survit pas à l'hibernation isolée. Lorsque le travailleur a fini de traiter la demande, tous les minuteurs en attente disparaissent. Pour un DO qui doit faire quelque chose ultérieurement (purge des sessions expirées, renvoi d'un message ayant échoué, invalidation d'un verrou après un délai d'attente), l'API Alarm est le bon mécanisme :

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() reçoit un horodatage absolu en millisecondes. Lorsque DO dort, Cloudflare maintient l'alarme programmée. Au bon moment, le DO se réveille et la méthode alarm() est invoquée. Le coût est de 0,15 $/million d’appels – la même liste de prix que les demandes normales.

Il n'est possible d'avoir qu'une seule alarme active par DO à la fois. setAlarm() remplace le précédent. Si vous avez besoin de plusieurs minuteries, la stratégie consiste à stocker la liste des événements à venir dans le stockage, à programmer une alarme pour celui le plus proche et, lorsque alarm() s'exécute, à traiter les événements expirés et à reprogrammer le suivant.

Comment fonctionne le routage vers la bonne instance

Les objets durables ont trois stratégies de génération d'ID, et le choix détermine le comportement de routage.

idFromName("room-123") est déterministe : le même nom produit toujours le même identifiant, globalement. C'est la stratégie à adopter lorsque vous souhaitez que tous les clients qui demandent la « chambre 123 » arrivent au même DO. L'ID est dérivé du hachage du nom, et deux DO portant le même nom dans le même espace de noms constituent le même objet.

newUniqueId() génère un ID aléatoire – toujours un nouveau DO. À utiliser lorsque vous créez une entité et que vous stockerez l'ID pour référence future sans avoir besoin de le dériver d'une clé.

idFromString(hexStr) reconstruit un identifiant à partir d'une chaîne hexadécimale que vous avez précédemment stockée. Utile lorsque l'ID a été généré avec newUniqueId(), a persisté dans une base de données externe ou dans le KV, et que vous devez référencer le même DO ultérieurement.

Les indications de localisation vous permettent de suggérer une juridiction géographique (UE, États-Unis), mais ne garantissent pas un PoP spécifique. Le DO est créé dans le PoP le plus proche de la première requête qui l'a instancié. Pour les applications soumises à des exigences de résidence des données dans l'UE, locationHint: 'eu' dirige la création vers un PoP européen, mais Cloudflare choisit lequel.

Comment cela se connecte-t-il au Worker qui achemine

Le Worker qui reçoit la demande du client doit obtenir le stub DO et transmettre la demande :

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

Le talon a le même contrat fetch() qu'un travailleur normal. Le Worker qui achemine n'a pas accès au stockage DO, ne peut pas lire son état interne et ne peut pas appeler de méthodes autres que fetch() et RPC (lorsqu'il est configuré avec WorkerEntrypoint). La communication se fait via des requêtes HTTP normales.

Les objets durables ne sont pas une abstraction de base de données. Il s'agit d'un modèle informatique avec état. Les traiter comme une base de données qui traite également la logique, plutôt que comme un objet avec une identité et un état, est ce qui conduit à la confusion – et à un code qui fonctionne en production par accident et non par conception.

A lire aussi

-Cloudflare Durable Objects : État cohérent à la périphérie – ce qui change vraiment