Maîtrisez la nouvelle gestion explicite des ressources de JavaScript avec `using` et `await using`. Apprenez à automatiser le nettoyage, à éviter les fuites de ressources et à écrire du code plus propre et plus robuste.
La nouvelle superpuissance de JavaScript : une plongée en profondeur dans la gestion explicite des ressources
Dans le monde dynamique du développement logiciel, la gestion efficace des ressources est une pierre angulaire de la création d’applications robustes, fiables et performantes. Pendant des décennies, les développeurs JavaScript se sont appuyés sur des schémas manuels comme try...catch...finally
pour s’assurer que les ressources critiques — telles que les descripteurs de fichiers, les connexions réseau ou les sessions de base de données — sont correctement libérées. Bien que fonctionnelle, cette approche est souvent verbeuse, sujette aux erreurs et peut rapidement devenir difficile à gérer, un schéma parfois appelé « pyramide de la mort » dans les scénarios complexes.
Entrez dans un changement de paradigme pour le langage : la Gestion explicite des ressources (ERM). Finalisée dans la norme ECMAScript 2024 (ES2024), cette fonctionnalité puissante, inspirée de constructions similaires dans des langages comme C#, Python et Java, introduit une manière déclarative et automatisée de gérer le nettoyage des ressources. En tirant parti des nouveaux mots-clés using
et await using
, JavaScript offre désormais une solution beaucoup plus élégante et sûre à un défi de programmation intemporel.
Ce guide complet vous emmènera dans un voyage à travers la gestion explicite des ressources de JavaScript. Nous explorerons les problèmes qu’elle résout, disséquerons ses concepts de base, passerons en revue des exemples pratiques et découvrirons des schémas avancés qui vous permettront d’écrire du code plus propre et plus résistant, où que vous développiez dans le monde.
L’ancienne garde : les défis du nettoyage manuel des ressources
Avant de pouvoir apprécier l’élégance du nouveau système, nous devons d’abord comprendre les points sensibles de l’ancien. Le modèle classique de gestion des ressources en JavaScript est le bloc try...finally
.
La logique est simple : vous acquérez une ressource dans le bloc try
, et vous la libérez dans le bloc finally
. Le bloc finally
garantit l’exécution, que le code du bloc try
réussisse, échoue ou retourne prématurément.
Considérons un scénario courant côté serveur : ouvrir un fichier, y écrire des données, puis s’assurer que le fichier est fermé.
Exemple : une simple opération de fichier avec try...finally
const fs = require('fs/promises');
async function processFile(filePath, data) {
let fileHandle;
try {
console.log('Opening file...');
fileHandle = await fs.open(filePath, 'w');
console.log('Writing to file...');
await fileHandle.write(data);
console.log('Data written successfully.');
} catch (error) {
console.error('An error occurred during file processing:', error);
} finally {
if (fileHandle) {
console.log('Closing file...');
await fileHandle.close();
}
}
}
Ce code fonctionne, mais il révèle plusieurs faiblesses :
- Verbosity : La logique de base (ouverture et écriture) est entourée d’une quantité importante de code réutilisable pour le nettoyage et la gestion des erreurs.
- Séparation des préoccupations : L’acquisition des ressources (
fs.open
) est très éloignée de son nettoyage correspondant (fileHandle.close
), ce qui rend le code plus difficile à lire et à comprendre. - Sujet aux erreurs : Il est facile d’oublier le contrôle
if (fileHandle)
, ce qui provoquerait un plantage si l’appel initialfs.open
échouait. De plus, une erreur lors de l’appelfileHandle.close()
lui-même n’est pas traitée et pourrait masquer l’erreur d’origine du bloctry
.
Maintenant, imaginez gérer plusieurs ressources, comme une connexion de base de données et un descripteur de fichier. Le code devient rapidement un désordre imbriqué :
async function logQueryResultToFile(query, filePath) {
let dbConnection;
try {
dbConnection = await getDbConnection();
const result = await dbConnection.query(query);
let fileHandle;
try {
fileHandle = await fs.open(filePath, 'w');
await fileHandle.write(JSON.stringify(result));
} finally {
if (fileHandle) {
await fileHandle.close();
}
}
} finally {
if (dbConnection) {
await dbConnection.release();
}
}
}
Cet imbrication est difficile à maintenir et à mettre à l’échelle. C’est un signe clair qu’une meilleure abstraction est nécessaire. C’est précisément le problème que la gestion explicite des ressources a été conçue pour résoudre.
Un changement de paradigme : les principes de la gestion explicite des ressources
La gestion explicite des ressources (ERM) introduit un contrat entre un objet de ressource et le runtime JavaScript. L’idée de base est simple : un objet peut déclarer comment il doit être nettoyé, et le langage fournit une syntaxe pour effectuer automatiquement ce nettoyage lorsque l’objet sort du champ d’application.
Cela se fait par le biais de deux composants principaux :
- Le protocole jetable : Une manière standard pour les objets de définir leur propre logique de nettoyage en utilisant des symboles spéciaux :
Symbol.dispose
pour le nettoyage synchrone etSymbol.asyncDispose
pour le nettoyage asynchrone. - Les déclarations
using
etawait using
: Nouveaux mots-clés qui lient une ressource à un bloc de portée. Lorsque le bloc est quitté, la méthode de nettoyage de la ressource est automatiquement appelée.
Les concepts de base : Symbol.dispose
et Symbol.asyncDispose
Au cœur de l’ERM se trouvent deux nouveaux symboles bien connus. Un objet qui possède une méthode avec l’un de ces symboles comme clé est considéré comme une « ressource jetable ».
Rejet synchrone avec Symbol.dispose
Le symbole Symbol.dispose
spécifie une méthode de nettoyage synchrone. Cela convient aux ressources dont le nettoyage ne nécessite aucune opération asynchrone, comme la fermeture synchrone d’un descripteur de fichier ou la libération d’un verrou en mémoire.
Créons un wrapper pour un fichier temporaire qui se nettoie lui-même.
const fs = require('fs');
const path = require('path');
class TempFile {
constructor(content) {
this.path = path.join(__dirname, `temp_${Date.now()}.txt`);
fs.writeFileSync(this.path, content);
console.log(`Created temp file: ${this.path}`);
}
// This is the synchronous disposable method
[Symbol.dispose]() {
console.log(`Disposing temp file: ${this.path}`);
try {
fs.unlinkSync(this.path);
console.log('File deleted successfully.');
} catch (error) {
console.error(`Failed to delete file: ${this.path}`, error);
// It's important to handle errors within dispose, too!
}
}
}
Toute instance de TempFile
est désormais une ressource jetable. Elle possède une méthode identifiée par Symbol.dispose
qui contient la logique permettant de supprimer le fichier du disque.
Rejet asynchrone avec Symbol.asyncDispose
De nombreuses opérations de nettoyage modernes sont asynchrones. La fermeture d’une connexion de base de données peut impliquer l’envoi d’une commande QUIT
sur le réseau, ou un client de file d’attente de messages peut avoir besoin de vider sa mémoire tampon sortante. Pour ces scénarios, nous utilisons Symbol.asyncDispose
.
La méthode associée à Symbol.asyncDispose
doit retourner une Promise
(ou être une fonction async
).
Modélisons une connexion de base de données fictive qui doit être renvoyée à un pool de manière asynchrone.
// A mock database pool
const mockDbPool = {
getConnection: () => {
console.log('DB connection acquired.');
return new MockDbConnection();
}
};
class MockDbConnection {
query(sql) {
console.log(`Executing query: ${sql}`);
return Promise.resolve({ success: true, rows: [] });
}
// This is the asynchronous disposable method
async [Symbol.asyncDispose]() {
console.log('Releasing DB connection back to the pool...');
// Simulate a network delay for releasing the connection
await new Promise(resolve => setTimeout(resolve, 50));
console.log('DB connection released.');
}
}
Désormais, toute instance de MockDbConnection
est une ressource jetable asynchrone. Elle sait comment se libérer de manière asynchrone lorsqu’elle n’est plus nécessaire.
La nouvelle syntaxe : using
et await using
en action
Avec nos classes jetables définies, nous pouvons désormais utiliser les nouveaux mots-clés pour les gérer automatiquement. Ces mots-clés créent des déclarations à portée de bloc, tout comme let
et const
.
Nettoyage synchrone avec using
Le mot-clé using
est utilisé pour les ressources qui implémentent Symbol.dispose
. Lorsque l’exécution du code quitte le bloc où la déclaration using
a été faite, la méthode [Symbol.dispose]()
est automatiquement appelée.
Utilisons notre classe TempFile
:
function processDataWithTempFile() {
console.log('Entering block...');
using tempFile = new TempFile('This is some important data.');
// You can work with tempFile here
const content = fs.readFileSync(tempFile.path, 'utf8');
console.log(`Read from temp file: "${content}"`);
// No cleanup code needed here!
console.log('...doing more work...');
} // <-- tempFile.[Symbol.dispose]() is called automatically right here!
processDataWithTempFile();
console.log('Block has been exited.');
La sortie serait :
Entering block... Created temp file: /path/to/temp_1678886400000.txt Read from temp file: "This is some important data." ...doing more work... Disposing temp file: /path/to/temp_1678886400000.txt File deleted successfully. Block has been exited.
Regardez à quel point c’est propre ! L’intégralité du cycle de vie de la ressource est contenue dans le bloc. Nous la déclarons, nous l’utilisons et nous l’oublions. Le langage gère le nettoyage. Il s’agit d’une amélioration massive de la lisibilité et de la sécurité.
Gestion de plusieurs ressources
Vous pouvez avoir plusieurs déclarations using
dans le même bloc. Elles seront rejetées dans l’ordre inverse de leur création (un comportement LIFO ou « de type pile »).
{
using resourceA = new MyDisposable('A'); // Created first
using resourceB = new MyDisposable('B'); // Created second
console.log('Inside block, using resources...');
} // resourceB is disposed of first, then resourceA
Nettoyage asynchrone avec await using
Le mot-clé await using
est l’équivalent asynchrone de using
. Il est utilisé pour les ressources qui implémentent Symbol.asyncDispose
. Le nettoyage étant asynchrone, ce mot-clé ne peut être utilisé qu’à l’intérieur d’une fonction async
ou au niveau supérieur d’un module (si l’attente de niveau supérieur est prise en charge).
Utilisons notre classe MockDbConnection
:
async function performDatabaseOperation() {
console.log('Entering async function...');
await using db = mockDbPool.getConnection();
await db.query('SELECT * FROM users');
console.log('Database operation complete.');
} // <-- await db.[Symbol.asyncDispose]() is called automatically here!
(async () => {
await performDatabaseOperation();
console.log('Async function has completed.');
})();
La sortie démontre le nettoyage asynchrone :
Entering async function... DB connection acquired. Executing query: SELECT * FROM users Database operation complete. Releasing DB connection back to the pool... (waits 50ms) DB connection released. Async function has completed.
Tout comme avec using
, la syntaxe await using
gère l’ensemble du cycle de vie, mais elle await
correctement le processus de nettoyage asynchrone. Il peut même gérer des ressources qui ne sont que jetables de manière synchrone — il ne les attendra tout simplement pas.
Schémas avancés : DisposableStack
et AsyncDisposableStack
Parfois, la simple portée de bloc de using
n’est pas assez flexible. Et si vous devez gérer un groupe de ressources avec une durée de vie qui n’est pas liée à un seul bloc lexical ? Ou que se passe-t-il si vous intégrez une ancienne bibliothèque qui ne produit pas d’objets avec Symbol.dispose
?
Pour ces scénarios, JavaScript fournit deux classes d’assistance : DisposableStack
et AsyncDisposableStack
.
DisposableStack
: le gestionnaire de nettoyage flexible
Un DisposableStack
est un objet qui gère une collection d’opérations de nettoyage. Il est lui-même une ressource jetable, vous pouvez donc gérer l’ensemble de sa durée de vie avec un bloc using
.
Il possède plusieurs méthodes utiles :
.use(resource)
: ajoute un objet qui possède une méthode[Symbol.dispose]
à la pile. Retourne la ressource, vous pouvez donc l’enchaîner..defer(callback)
: ajoute une fonction de nettoyage arbitraire à la pile. Ceci est incroyablement utile pour le nettoyage ad hoc..adopt(value, callback)
: ajoute une valeur et une fonction de nettoyage pour cette valeur. C’est parfait pour encapsuler les ressources des bibliothèques qui ne prennent pas en charge le protocole jetable..move()
: transfère la propriété des ressources vers une nouvelle pile, effaçant la pile actuelle.
Exemple : gestion conditionnelle des ressources
Imaginez une fonction qui ouvre un fichier journal uniquement si une certaine condition est remplie, mais vous souhaitez que tout le nettoyage se produise au même endroit à la fin.
function processWithConditionalLogging(shouldLog) {
using stack = new DisposableStack();
const db = stack.use(getDbConnection()); // Always use the DB
if (shouldLog) {
const logFileStream = fs.createWriteStream('app.log');
// Defer the cleanup for the stream
stack.defer(() => {
console.log('Closing log file stream...');
logFileStream.end();
});
db.logTo(logFileStream);
}
db.doWork();
} // <-- The stack is disposed, calling all registered cleanup functions in LIFO order.
AsyncDisposableStack
: pour le monde asynchrone
Comme vous l’avez peut-être deviné, AsyncDisposableStack
est la version asynchrone. Il peut gérer les ressources jetables synchrones et asynchrones. Sa principale méthode de nettoyage est .disposeAsync()
, qui retourne une Promise
qui se résout lorsque toutes les opérations de nettoyage asynchrones sont terminées.
Exemple : gestion d’un mélange de ressources
Créons un gestionnaire de requêtes de serveur Web qui a besoin d’une connexion de base de données (nettoyage asynchrone) et d’un fichier temporaire (nettoyage synchrone).
async function handleRequest() {
await using stack = new AsyncDisposableStack();
// Manage an async disposable resource
const dbConnection = await stack.use(getAsyncDbConnection());
// Manage a sync disposable resource
const tempFile = stack.use(new TempFile('request data'));
// Adopt a resource from an old API
const legacyResource = getLegacyResource();
stack.adopt(legacyResource, () => legacyResource.shutdown());
console.log('Processing request...');
await doWork(dbConnection, tempFile.path);
} // <-- stack.disposeAsync() is called. It will correctly await async cleanup.
Le AsyncDisposableStack
est un outil puissant pour orchestrer une logique de configuration et de démontage complexe de manière propre et prévisible.
Gestion robuste des erreurs avec SuppressedError
L’une des améliorations les plus subtiles mais significatives de l’ERM est la façon dont elle gère les erreurs. Que se passe-t-il si une erreur est levée à l’intérieur du bloc using
, et qu’une autre erreur est levée lors du rejet automatique ultérieur ?
Dans l’ancien monde try...finally
, l’erreur du bloc finally
remplacerait ou « supprimerait » généralement l’erreur d’origine, la plus importante, du bloc try
. Cela rendait souvent le débogage incroyablement difficile.
ERM résout ce problème avec un nouveau type d’erreur globale : SuppressedError
. Si une erreur se produit lors du rejet alors qu’une autre erreur se propage déjà, l’erreur de rejet est « supprimée ». L’erreur d’origine est levée, mais elle possède désormais une propriété suppressed
contenant l’erreur de rejet.
class FaultyResource {
[Symbol.dispose]() {
throw new Error('Error during disposal!');
}
}
try {
using resource = new FaultyResource();
throw new Error('Error during operation!');
} catch (e) {
console.log(`Caught error: ${e.message}`); // Error during operation!
if (e.suppressed) {
console.log(`Suppressed error: ${e.suppressed.message}`); // Error during disposal!
console.log(e instanceof SuppressedError); // false
console.log(e.suppressed instanceof Error); // true
}
}
Ce comportement garantit que vous ne perdez jamais le contexte de l’échec d’origine, ce qui conduit à des systèmes beaucoup plus robustes et débogables.
Cas d’utilisation pratiques dans tout l’écosystème JavaScript
Les applications de la gestion explicite des ressources sont vastes et pertinentes pour les développeurs du monde entier, qu’ils travaillent sur le back-end, le front-end ou dans les tests.
- Back-End (Node.js, Deno, Bun) : Les cas d’utilisation les plus évidents vivent ici. La gestion des connexions de base de données, des descripteurs de fichiers, des sockets réseau et des clients de file d’attente de messages devient triviale et sûre.
- Front-End (navigateurs Web) : L’ERM est également précieuse dans le navigateur. Vous pouvez gérer les connexions
WebSocket
, libérer des verrous de l’API Web Locks ou nettoyer des connexions WebRTC complexes. - Frameworks de test (Jest, Mocha, etc.) : Utilisez
DisposableStack
dansbeforeEach
ou dans les tests pour démanteler automatiquement les mocks, les espions, les serveurs de test ou les états de base de données, garantissant ainsi une isolation propre des tests. - Frameworks d’interface utilisateur (React, Svelte, Vue) : Bien que ces frameworks disposent de leurs propres méthodes de cycle de vie, vous pouvez utiliser
DisposableStack
dans un composant pour gérer les ressources non liées au framework, telles que les écouteurs d’événements ou les abonnements à des bibliothèques tierces, en vous assurant qu’ils sont tous nettoyés lors du démontage.
Prise en charge du navigateur et du runtime
En tant que fonctionnalité moderne, il est important de savoir où vous pouvez utiliser la gestion explicite des ressources. À la fin de 2023 / début 2024, la prise en charge est généralisée dans les dernières versions des principaux environnements JavaScript :
- Node.js : Version 20+ (derrière un indicateur dans les versions antérieures)
- Deno : Version 1.32+
- Bun : Version 1.0+
- Navigateurs : Chrome 119+, Firefox 121+, Safari 17.2+
Pour les anciens environnements, vous devrez vous appuyer sur des transpileurs comme Babel avec les plugins appropriés pour transformer la syntaxe using
et polyfiller les symboles et classes de pile nécessaires.
Conclusion : une nouvelle ère de sécurité et de clarté
La gestion explicite des ressources de JavaScript est plus qu’un simple sucre syntaxique ; il s’agit d’une amélioration fondamentale du langage qui favorise la sécurité, la clarté et la maintenabilité. En automatisant le processus fastidieux et sujet aux erreurs de nettoyage des ressources, il libère les développeurs pour qu’ils puissent se concentrer sur leur logique métier principale.
Les principaux points à retenir sont les suivants :
- Automatiser le nettoyage : Utilisez
using
etawait using
pour éliminer le code réutilisable manueltry...finally
. - Améliorer la lisibilité : Conservez l’acquisition des ressources et sa portée de cycle de vie étroitement couplées et visibles.
- Prévenir les fuites : Garantir que la logique de nettoyage est exécutée, ce qui empêche les fuites de ressources coûteuses dans vos applications.
- Gérer les erreurs de manière robuste : Bénéficiez du nouveau mécanisme
SuppressedError
pour ne jamais perdre le contexte d’erreur critique.
Lorsque vous commencez de nouveaux projets ou que vous refactorisez du code existant, envisagez d’adopter ce nouveau modèle puissant. Il rendra votre JavaScript plus propre, vos applications plus fiables et votre vie de développeur un peu plus facile. C’est une norme véritablement mondiale pour l’écriture de JavaScript moderne et professionnel.