Français

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 :

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 :

  1. 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 et Symbol.asyncDispose pour le nettoyage asynchrone.
  2. Les déclarations using et await 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 :

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.

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 :

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 :

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.