Русский

Освойте новое явное управление ресурсами JavaScript с помощью `using` и `await using`. Научитесь автоматизировать очистку, предотвращать утечки ресурсов и писать более чистый и надежный код.

Новая суперсила JavaScript: глубокое погружение в явное управление ресурсами

В динамичном мире разработки программного обеспечения эффективное управление ресурсами является краеугольным камнем построения надежных, устойчивых и производительных приложений. На протяжении десятилетий разработчики JavaScript полагались на ручные шаблоны, такие как try...catch...finally, чтобы гарантировать надлежащее освобождение критически важных ресурсов, таких как файловые дескрипторы, сетевые подключения или сеансы баз данных. Хотя этот подход функционален, он часто многословен, подвержен ошибкам и может быстро стать громоздким, что иногда называют "пирамидой гибели" в сложных сценариях.

Представляем парадигмальный сдвиг для языка: Явное управление ресурсами (ERM). Эта мощная функция, финализированная в стандарте ECMAScript 2024 (ES2024) и вдохновленная аналогичными конструкциями в таких языках, как C#, Python и Java, представляет декларативный и автоматизированный способ обработки очистки ресурсов. Используя новые ключевые слова using и await using, JavaScript теперь предоставляет гораздо более элегантное и безопасное решение вечной проблемы программирования.

Это всеобъемлющее руководство проведет вас по пути явного управления ресурсами в JavaScript. Мы изучим проблемы, которые оно решает, разберем его основные концепции, рассмотрим практические примеры и раскроем передовые шаблоны, которые позволят вам писать более чистый и устойчивый код, независимо от того, где в мире вы разрабатываете.

Старая гвардия: проблемы ручной очистки ресурсов

Прежде чем мы сможем оценить элегантность новой системы, мы должны сначала понять болевые точки старой. Классическим шаблоном управления ресурсами в JavaScript является блок try...finally.

Логика проста: вы получаете ресурс в блоке try и освобождаете его в блоке finally. Блок finally гарантирует выполнение, независимо от того, успешно ли выполнен код в блоке try, завершился ли он с ошибкой или вернулся преждевременно.

Рассмотрим распространенный сценарий на стороне сервера: открытие файла, запись в него некоторых данных и последующее обеспечение закрытия файла.

Пример: простая файловая операция с try...finally


const fs = require('fs/promises');

async function processFile(filePath, data) {
  let fileHandle;
  try {
    console.log('Открытие файла...');
    fileHandle = await fs.open(filePath, 'w');
    console.log('Запись в файл...');
    await fileHandle.write(data);
    console.log('Данные успешно записаны.');
  } catch (error) {
    console.error('Произошла ошибка во время обработки файла:', error);
  } finally {
    if (fileHandle) {
      console.log('Закрытие файла...');
      await fileHandle.close();
    }
  }
}

Этот код работает, но он выявляет несколько слабых мест:

Теперь представьте себе управление несколькими ресурсами, такими как подключение к базе данных и файловый дескриптор. Код быстро превращается во вложенный беспорядок:


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

Эту вложенность трудно поддерживать и масштабировать. Это явный сигнал о том, что необходима лучшая абстракция. Именно эту проблему и призвано решить явное управление ресурсами.

Парадигмальный сдвиг: принципы явного управления ресурсами

Явное управление ресурсами (ERM) вводит контракт между объектом ресурса и средой выполнения JavaScript. Основная идея проста: объект может объявить, как его следует очистить, а язык предоставляет синтаксис для автоматического выполнения этой очистки, когда объект выходит из области видимости.

Это достигается за счет двух основных компонентов:

  1. Протокол утилизации: Стандартный способ для объектов определять свою собственную логику очистки с использованием специальных символов: Symbol.dispose для синхронной очистки и Symbol.asyncDispose для асинхронной очистки.
  2. Декларации `using` и `await using`: Новые ключевые слова, которые связывают ресурс с блочной областью видимости. Когда блок завершается, метод очистки ресурса вызывается автоматически.

Основные концепции: `Symbol.dispose` и `Symbol.asyncDispose`

В основе ERM лежат два новых хорошо известных символа. Объект, у которого есть метод с одним из этих символов в качестве ключа, считается "утилизируемым ресурсом".

Синхронная утилизация с помощью `Symbol.dispose`

Символ Symbol.dispose указывает синхронный метод очистки. Он подходит для ресурсов, где очистка не требует каких-либо асинхронных операций, например, синхронное закрытие файлового дескриптора или освобождение блокировки в памяти.

Давайте создадим обертку для временного файла, который сам себя очищает.


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(`Создан временный файл: ${this.path}`);
  }

  // Это синхронный метод утилизации
  [Symbol.dispose]() {
    console.log(`Удаление временного файла: ${this.path}`);
    try {
      fs.unlinkSync(this.path);
      console.log('Файл успешно удален.');
    } catch (error) {
      console.error(`Не удалось удалить файл: ${this.path}`, error);
      // Важно также обрабатывать ошибки в пределах dispose!
    }
  }
}

Любой экземпляр `TempFile` теперь является утилизируемым ресурсом. У него есть метод, ключ которого - `Symbol.dispose`, который содержит логику удаления файла с диска.

Асинхронная утилизация с помощью `Symbol.asyncDispose`

Многие современные операции очистки являются асинхронными. Закрытие подключения к базе данных может включать отправку команды `QUIT` по сети, или клиенту очереди сообщений может потребоваться сбросить свой исходящий буфер. Для этих сценариев мы используем `Symbol.asyncDispose`.

Метод, связанный с `Symbol.asyncDispose`, должен возвращать `Promise` (или быть `async` функцией).

Давайте смоделируем фиктивное подключение к базе данных, которое необходимо асинхронно вернуть в пул.


// Фиктивный пул базы данных
const mockDbPool = {
  getConnection: () => {
    console.log('Получено соединение с БД.');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`Выполнение запроса: ${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // Это асинхронный метод утилизации
  async [Symbol.asyncDispose]() {
    console.log('Возврат соединения с БД в пул...');
    // Имитация сетевой задержки для освобождения соединения
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('Соединение с БД освобождено.');
  }
}

Теперь любой экземпляр `MockDbConnection` является асинхронно утилизируемым ресурсом. Он знает, как асинхронно освободить себя, когда он больше не нужен.

Новый синтаксис: `using` и `await using` в действии

Определив наши утилизируемые классы, мы теперь можем использовать новые ключевые слова для автоматического управления ими. Эти ключевые слова создают объявления с областью видимости блока, так же как `let` и `const`.

Синхронная очистка с помощью `using`

Ключевое слово `using` используется для ресурсов, которые реализуют `Symbol.dispose`. Когда выполнение кода покидает блок, в котором было сделано объявление `using`, метод `[Symbol.dispose]()` вызывается автоматически.

Давайте используем наш класс `TempFile`:


function processDataWithTempFile() {
  console.log('Вход в блок...');
  using tempFile = new TempFile('Это какие-то важные данные.');

  // Здесь вы можете работать с tempFile
  const content = fs.readFileSync(tempFile.path, 'utf8');
  console.log(`Чтение из временного файла: "${content}"`);

  // Здесь не нужен код очистки!
  console.log('...выполнение дальнейшей работы...');
} // <-- tempFile.[Symbol.dispose]() вызывается автоматически прямо здесь!

processDataWithTempFile();
console.log('Блок завершен.');

Вывод будет следующим:

Вход в блок...
Создан временный файл: /path/to/temp_1678886400000.txt
Чтение из временного файла: "Это какие-то важные данные."
...выполнение дальнейшей работы...
Удаление временного файла: /path/to/temp_1678886400000.txt
Файл успешно удален.
Блок завершен.

Посмотрите, как чисто! Весь жизненный цикл ресурса содержится в блоке. Мы объявляем его, мы используем его, и мы забываем о нем. Язык обрабатывает очистку. Это огромное улучшение в удобочитаемости и безопасности.

Управление несколькими ресурсами

Вы можете иметь несколько объявлений `using` в одном и том же блоке. Они будут утилизированы в обратном порядке их создания (поведение LIFO или "стековое").


{
  using resourceA = new MyDisposable('A'); // Создан первым
  using resourceB = new MyDisposable('B'); // Создан вторым
  console.log('Внутри блока, использование ресурсов...');
} // resourceB утилизируется первым, затем resourceA

Асинхронная очистка с помощью `await using`

Ключевое слово `await using` является асинхронным аналогом `using`. Оно используется для ресурсов, которые реализуют `Symbol.asyncDispose`. Поскольку очистка является асинхронной, это ключевое слово можно использовать только внутри `async` функции или на верхнем уровне модуля (если поддерживается await верхнего уровня).

Давайте используем наш класс `MockDbConnection`:


async function performDatabaseOperation() {
  console.log('Вход в асинхронную функцию...');
  await using db = mockDbPool.getConnection();

  await db.query('SELECT * FROM users');

  console.log('Операция с базой данных завершена.');
} // <-- await db.[Symbol.asyncDispose]() вызывается автоматически здесь!

(async () => {
  await performDatabaseOperation();
  console.log('Асинхронная функция завершена.');
})();

Вывод демонстрирует асинхронную очистку:

Вход в асинхронную функцию...
Получено соединение с БД.
Выполнение запроса: SELECT * FROM users
Операция с базой данных завершена.
Возврат соединения с БД в пул...
(ожидание 50 мс)
Соединение с БД освобождено.
Асинхронная функция завершена.

Как и в случае с `using`, синтаксис `await using` обрабатывает весь жизненный цикл, но он правильно `ожидает` асинхронный процесс очистки. Он может даже обрабатывать ресурсы, которые являются только синхронно утилизируемыми — он просто не будет их ожидать.

Расширенные шаблоны: `DisposableStack` и `AsyncDisposableStack`

Иногда простой области видимости блока `using` недостаточно. Что, если вам нужно управлять группой ресурсов с временем жизни, не связанным с одним лексическим блоком? Или что, если вы интегрируетесь со старой библиотекой, которая не создает объекты с `Symbol.dispose`?

Для этих сценариев JavaScript предоставляет два вспомогательных класса: `DisposableStack` и `AsyncDisposableStack`.

`DisposableStack`: гибкий менеджер очистки

`DisposableStack` — это объект, который управляет коллекцией операций очистки. Он сам является утилизируемым ресурсом, поэтому вы можете управлять всем его жизненным циклом с помощью блока `using`.

У него есть несколько полезных методов:

Пример: условное управление ресурсами

Представьте себе функцию, которая открывает файл журнала только при выполнении определенного условия, но вы хотите, чтобы вся очистка происходила в одном месте в конце.


function processWithConditionalLogging(shouldLog) {
  using stack = new DisposableStack();

  const db = stack.use(getDbConnection()); // Всегда используйте DB

  if (shouldLog) {
    const logFileStream = fs.createWriteStream('app.log');
    // Отложите очистку для потока
    stack.defer(() => {
      console.log('Закрытие потока файла журнала...');
      logFileStream.end();
    });
    db.logTo(logFileStream);
  }

  db.doWork();

} // <-- Стек утилизируется, вызывая все зарегистрированные функции очистки в порядке LIFO.

`AsyncDisposableStack`: для асинхронного мира

Как вы могли догадаться, `AsyncDisposableStack` — это асинхронная версия. Он может управлять как синхронными, так и асинхронными утилизируемыми объектами. Его основной метод очистки — `.disposeAsync()`, который возвращает `Promise`, который разрешается, когда все асинхронные операции очистки завершены.

Пример: управление смесью ресурсов

Давайте создадим обработчик веб-сервера, которому требуется подключение к базе данных (асинхронная очистка) и временный файл (синхронная очистка).


async function handleRequest() {
  await using stack = new AsyncDisposableStack();

  // Управление асинхронно утилизируемым ресурсом
  const dbConnection = await stack.use(getAsyncDbConnection());

  // Управление синхронно утилизируемым ресурсом
  const tempFile = stack.use(new TempFile('данные запроса'));

  // Принятие ресурса из старого API
  const legacyResource = getLegacyResource();
  stack.adopt(legacyResource, () => legacyResource.shutdown());

  console.log('Обработка запроса...');
  await doWork(dbConnection, tempFile.path);

} // <-- вызывается stack.disposeAsync(). Он правильно ожидает асинхронную очистку.

`AsyncDisposableStack` — это мощный инструмент для организации сложной логики настройки и завершения в чистой и предсказуемой манере.

Надежная обработка ошибок с помощью `SuppressedError`

Одним из самых тонких, но значительных улучшений ERM является то, как он обрабатывает ошибки. Что произойдет, если внутри блока `using` будет выброшена ошибка, а во время последующей автоматической утилизации будет выброшена *другая* ошибка?

В старом мире `try...finally` ошибка из блока `finally`, как правило, перезаписывала или "подавляла" исходную, более важную ошибку из блока `try`. Это часто делало отладку невероятно сложной.

ERM решает эту проблему с помощью нового глобального типа ошибки: `SuppressedError`. Если во время утилизации возникает ошибка, в то время как другая ошибка уже распространяется, ошибка утилизации "подавляется". Выбрасывается исходная ошибка, но теперь у нее есть свойство `suppressed`, содержащее ошибку утилизации.


class FaultyResource {
  [Symbol.dispose]() {
    throw new Error('Ошибка во время утилизации!');
  }
}

try {
  using resource = new FaultyResource();
  throw new Error('Ошибка во время операции!');
} catch (e) {
  console.log(`Перехвачена ошибка: ${e.message}`); // Ошибка во время операции!
  if (e.suppressed) {
    console.log(`Подавленная ошибка: ${e.suppressed.message}`); // Ошибка во время утилизации!
    console.log(e instanceof SuppressedError); // false
    console.log(e.suppressed instanceof Error); // true
  }
}

Такое поведение гарантирует, что вы никогда не потеряете контекст первоначального сбоя, что приведет к гораздо более надежным и отлаживаемым системам.

Практические варианты использования в экосистеме JavaScript

Приложения явного управления ресурсами обширны и актуальны для разработчиков по всему миру, независимо от того, работают ли они на сервере, во внешнем интерфейсе или в тестировании.

Поддержка браузера и среды выполнения

Как современную функцию, важно знать, где можно использовать явное управление ресурсами. По состоянию на конец 2023 г. / начало 2024 г. поддержка широко распространена в последних версиях основных сред JavaScript:

Для более старых сред вам нужно будет полагаться на транспиляторы, такие как Babel, с соответствующими плагинами для преобразования синтаксиса `using` и полифилов необходимых символов и классов стека.

Вывод: новая эра безопасности и ясности

Явное управление ресурсами в JavaScript — это больше, чем просто синтаксический сахар; это фундаментальное улучшение языка, которое повышает безопасность, ясность и удобство сопровождения. Автоматизируя утомительный и подверженный ошибкам процесс очистки ресурсов, он освобождает разработчиков, позволяя им сосредоточиться на своей основной бизнес-логике.

Основные выводы:

Приступая к новым проектам или рефакторингу существующего кода, рассмотрите возможность принятия этого мощного нового шаблона. Это сделает ваш JavaScript чище, ваши приложения надежнее, а вашу жизнь разработчика немного проще. Это действительно глобальный стандарт для написания современного профессионального JavaScript.