Опануйте нове явне керування ресурсами в 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();
}
}
}
Цей код працює, але він має кілька слабких місць:
- Багатослівність: Основна логіка (відкриття та запис) оточена значною кількістю шаблонного коду для очищення та обробки помилок.
- Розділення відповідальностей: Отримання ресурсу (
fs.open
) знаходиться далеко від відповідного очищення (fileHandle.close
), що ускладнює читання та розуміння коду. - Схильність до помилок: Легко забути перевірку
if (fileHandle)
, що призвело б до збою, якби початковий викликfs.open
зазнав невдачі. Крім того, помилка під час самого викликуfileHandle.close()
не обробляється і може замаскувати початкову помилку з блокуtry
.
А тепер уявіть, що ви керуєте кількома ресурсами, наприклад, з'єднанням з базою даних та файловим дескриптором. Код швидко перетворюється на вкладений безлад:
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. Основна ідея проста: об'єкт може оголосити, як його слід очистити, а мова надає синтаксис для автоматичного виконання цього очищення, коли об'єкт виходить за межі своєї області видимості.
Це досягається за допомогою двох основних компонентів:
- Протокол утилізації (Disposable Protocol): Стандартний спосіб для об'єктів визначати власну логіку очищення за допомогою спеціальних символів:
Symbol.dispose
для синхронного очищення таSymbol.asyncDispose
для асинхронного. - Декларації
using
таawait using
: Нові ключові слова, що прив'язують ресурс до блочної області видимості. Коли виконання виходить з блоку, метод очищення ресурсу викликається автоматично.
Основні концепції: `Symbol.dispose` та `Symbol.asyncDispose`
В основі ERM лежать два нові добре відомі символи. Об'єкт, що має метод з одним із цих символів як ключ, вважається «ресурсом, що утилізується» (disposable resource).
Синхронна утилізація з `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('This is some important data.');
// Тут можна працювати з tempFile
const content = fs.readFileSync(tempFile.path, 'utf8');
console.log(`Прочитано з тимчасового файлу: "${content}"`);
// Тут не потрібен код для очищення!
console.log('...виконується інша робота...');
} // <-- tempFile.[Symbol.dispose]() викликається автоматично саме тут!
processDataWithTempFile();
console.log('Вихід із блоку завершено.');
Вивід буде таким:
Вхід у блок... Створено тимчасовий файл: /path/to/temp_1678886400000.txt Прочитано з тимчасового файлу: "This is some important data." ...виконується інша робота... Утилізація тимчасового файлу: /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` функції або на верхньому рівні модуля (якщо підтримується top-level 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` керує всім життєвим циклом, але він коректно `awaits` завершення асинхронного процесу очищення. Він може навіть обробляти ресурси, які утилізуються лише синхронно — він просто не буде їх очікувати.
Просунуті патерни: `DisposableStack` та `AsyncDisposableStack`
Іноді проста блочна область видимості `using` недостатньо гнучка. Що, якщо вам потрібно керувати групою ресурсів з життєвим циклом, не прив'язаним до одного лексичного блоку? Або якщо ви інтегруєтеся зі старою бібліотекою, яка не створює об'єкти з `Symbol.dispose`?
Для таких сценаріїв JavaScript надає два допоміжні класи: `DisposableStack` та `AsyncDisposableStack`.
`DisposableStack`: гнучкий менеджер очищення
A `DisposableStack` — це об'єкт, який керує колекцією операцій очищення. Сам по собі він є ресурсом, що утилізується, тому ви можете керувати всім його життєвим циклом за допомогою блоку `using`.
Він має кілька корисних методів:
.use(resource)
: Додає до стека об'єкт, що має метод[Symbol.dispose]
. Повертає ресурс, що дозволяє створювати ланцюжки викликів..defer(callback)
: Додає до стека довільну функцію очищення. Це неймовірно корисно для спеціальних завдань очищення..adopt(value, callback)
: Додає значення та функцію очищення для цього значення. Ідеально підходить для обгортання ресурсів з бібліотек, які не підтримують протокол утилізації..move()
: Передає володіння ресурсами новому стеку, очищуючи поточний.
Приклад: умовне керування ресурсами
Уявіть функцію, яка відкриває файл логу лише за виконання певної умови, але ви хочете, щоб усе очищення відбувалося в одному місці в кінці.
function processWithConditionalLogging(shouldLog) {
using stack = new DisposableStack();
const db = stack.use(getDbConnection()); // Завжди використовувати БД
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('request data'));
// Адаптація ресурсу зі старого 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
Застосування явного керування ресурсами є широким і актуальним для розробників по всьому світу, незалежно від того, чи працюють вони на бекенді, фронтенді чи в тестуванні.
- Бекенд (Node.js, Deno, Bun): Найбільш очевидні випадки використання знаходяться тут. Керування з'єднаннями з базами даних, файловими дескрипторами, мережевими сокетами та клієнтами черг повідомлень стає тривіальним та безпечним.
- Фронтенд (Веб-браузери): ERM також є цінним у браузері. Ви можете керувати з'єднаннями `WebSocket`, звільняти блокування з Web Locks API або очищувати складні з'єднання WebRTC.
- Фреймворки для тестування (Jest, Mocha, тощо): Використовуйте `DisposableStack` у `beforeEach` або всередині тестів для автоматичного демонтажу моків, шпигунів, тестових серверів або станів баз даних, забезпечуючи чисту ізоляцію тестів.
- UI фреймворки (React, Svelte, Vue): Хоча ці фреймворки мають власні методи життєвого циклу, ви можете використовувати `DisposableStack` всередині компонента для керування ресурсами, що не належать до фреймворку, такими як слухачі подій або підписки на сторонні бібліотеки, гарантуючи, що всі вони будуть очищені при розмонтуванні.
Підтримка браузерами та середовищами виконання
Оскільки це сучасна функція, важливо знати, де ви можете використовувати явне керування ресурсами. Станом на кінець 2023 / початок 2024 року, підтримка є широкою в останніх версіях основних середовищ JavaScript:
- Node.js: Версія 20+ (за прапором у раніших версіях)
- Deno: Версія 1.32+
- Bun: Версія 1.0+
- Браузери: Chrome 119+, Firefox 121+, Safari 17.2+
Для старіших середовищ вам доведеться покладатися на транспілятори, такі як Babel, з відповідними плагінами для перетворення синтаксису `using` та поліфілів для необхідних символів та класів стеків.
Висновок: нова ера безпеки та ясності
Явне керування ресурсами в JavaScript — це більше, ніж просто синтаксичний цукор; це фундаментальне вдосконалення мови, що сприяє безпеці, ясності та зручності супроводу. Автоматизуючи нудний та схильний до помилок процес очищення ресурсів, воно дозволяє розробникам зосередитися на своїй основній бізнес-логіці.
Ключові висновки:
- Автоматизуйте очищення: Використовуйте
using
таawait using
, щоб усунути ручний шаблонний кодtry...finally
. - Покращуйте читабельність: Тримайте отримання ресурсу та його життєвий цикл тісно пов'язаними та видимими.
- Запобігайте витокам: Гарантуйте, що логіка очищення виконується, запобігаючи дорогим витокам ресурсів у ваших застосунках.
- Обробляйте помилки надійно: Скористайтеся перевагами нового механізму
SuppressedError
, щоб ніколи не втрачати критичний контекст помилки.
Коли ви починаєте нові проєкти або рефакторите існуючий код, розгляньте можливість впровадження цього потужного нового патерну. Це зробить ваш JavaScript чистішим, ваші застосунки надійнішими, а ваше життя як розробника — трохи легшим. Це справді глобальний стандарт для написання сучасного, професійного JavaScript.