Polski

Opanuj nowe Jawne Zarządzanie Zasobami w JavaScript za pomocą `using` i `await using`. Naucz się automatyzować czyszczenie, zapobiegać wyciekom zasobów i pisać czystszy, bardziej niezawodny kod.

Nowa Supermoc JavaScript: Dogłębne Zanurzenie w Jawne Zarządzanie Zasobami

W dynamicznym świecie tworzenia oprogramowania, efektywne zarządzanie zasobami jest kamieniem węgielnym budowy solidnych, niezawodnych i wydajnych aplikacji. Przez dziesięciolecia programiści JavaScript polegali na manualnych wzorcach, takich jak try...catch...finally, aby zapewnić, że krytyczne zasoby – takie jak uchwyty plików, połączenia sieciowe lub sesje baz danych – są prawidłowo zwalniane. Chociaż funkcjonalne, to podejście jest często rozwlekłe, podatne na błędy i może szybko stać się nieporęczne, wzorzec czasami określany jako "piramida zagłady" w złożonych scenariuszach.

Wejdź w paradygmat zmiany dla języka: Jawne Zarządzanie Zasobami (ERM). Sfinalizowane w standardzie ECMAScript 2024 (ES2024), ta potężna funkcja, inspirowana podobnymi konstrukcjami w językach takich jak C#, Python i Java, wprowadza deklaratywny i automatyczny sposób obsługi czyszczenia zasobów. Wykorzystując nowe słowa kluczowe using i await using, JavaScript zapewnia teraz znacznie bardziej eleganckie i bezpieczniejsze rozwiązanie odwiecznego wyzwania programistycznego.

Ten kompleksowy przewodnik zabierze Cię w podróż po Jawnym Zarządzaniu Zasobami w JavaScript. Zbadamy problemy, które rozwiązuje, przeanalizujemy jego podstawowe koncepcje, przejdziemy przez praktyczne przykłady i odkryjemy zaawansowane wzorce, które pozwolą Ci pisać czystszy, bardziej odporny kod, bez względu na to, gdzie na świecie programujesz.

Stara Gwardia: Wyzwania Manualnego Czyszczenia Zasobów

Zanim będziemy mogli docenić elegancję nowego systemu, musimy najpierw zrozumieć bolączki starego. Klasycznym wzorcem zarządzania zasobami w JavaScript jest blok try...finally.

Logika jest prosta: pozyskujesz zasób w bloku try i zwalniasz go w bloku finally. Blok finally gwarantuje wykonanie, niezależnie od tego, czy kod w bloku try zakończy się sukcesem, niepowodzeniem, czy też zwróci przedwcześnie.

Rozważmy typowy scenariusz po stronie serwera: otwarcie pliku, zapisanie do niego danych, a następnie upewnienie się, że plik jest zamknięty.

Przykład: Prosta Operacja na Pliku z Użyciem try...finally


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

async function processFile(filePath, data) {
  let fileHandle;
  try {
    console.log('Otwieranie pliku...');
    fileHandle = await fs.open(filePath, 'w');
    console.log('Zapisywanie do pliku...');
    await fileHandle.write(data);
    console.log('Dane zapisane pomyślnie.');
  } catch (error) {
    console.error('Wystąpił błąd podczas przetwarzania pliku:', error);
  } finally {
    if (fileHandle) {
      console.log('Zamykanie pliku...');
      await fileHandle.close();
    }
  }
}

Ten kod działa, ale ujawnia kilka słabości:

Teraz wyobraź sobie zarządzanie wieloma zasobami, takimi jak połączenie z bazą danych i uchwyt pliku. Kod szybko staje się zagnieżdżonym bałaganem:


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

To zagnieżdżenie jest trudne do utrzymania i skalowania. Jest to wyraźny sygnał, że potrzebna jest lepsza abstrakcja. To jest dokładnie problem, który Jawne Zarządzanie Zasobami miało rozwiązać.

Zmiana Paradygmatu: Zasady Jawnego Zarządzania Zasobami

Jawne Zarządzanie Zasobami (ERM) wprowadza kontrakt między obiektem zasobu a środowiskiem uruchomieniowym JavaScript. Podstawowa idea jest prosta: obiekt może zadeklarować, jak powinien być wyczyszczony, a język zapewnia składnię do automatycznego wykonania tego czyszczenia, gdy obiekt wychodzi poza zakres.

Osiąga się to za pomocą dwóch głównych komponentów:

  1. Protokół Usuwania: Standardowy sposób definiowania przez obiekty własnej logiki czyszczenia za pomocą specjalnych symboli: Symbol.dispose dla synchronicznego czyszczenia i Symbol.asyncDispose dla asynchronicznego czyszczenia.
  2. Deklaracje using i await using: Nowe słowa kluczowe, które wiążą zasób z zakresem bloku. Kiedy blok jest opuszczany, metoda czyszczenia zasobu jest automatycznie wywoływana.

Podstawowe Koncepcje: Symbol.dispose i Symbol.asyncDispose

W sercu ERM znajdują się dwa nowe, dobrze znane symbole. Obiekt, który ma metodę z jednym z tych symboli jako kluczem, jest uważany za "zasób do usunięcia".

Synchroniczne Usuwanie za Pomocą Symbol.dispose

Symbol Symbol.dispose określa synchroniczną metodę czyszczenia. Jest to odpowiednie dla zasobów, w których czyszczenie nie wymaga żadnych asynchronicznych operacji, takich jak synchroniczne zamknięcie uchwytu pliku lub zwolnienie blokady w pamięci.

Stwórzmy wrapper dla pliku tymczasowego, który sam się czyści.


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(`Utworzono plik tymczasowy: ${this.path}`);
  }

  // To jest synchroniczna metoda usuwania
  [Symbol.dispose]() {
    console.log(`Usuwanie pliku tymczasowego: ${this.path}`);
    try {
      fs.unlinkSync(this.path);
      console.log('Plik usunięty pomyślnie.');
    } catch (error) {
      console.error(`Nie udało się usunąć pliku: ${this.path}`, error);
      // Ważne jest, aby obsługiwać błędy również wewnątrz dispose!
    }
  }
}

Każda instancja TempFile jest teraz zasobem do usunięcia. Ma metodę kluczowaną przez Symbol.dispose, która zawiera logikę usuwania pliku z dysku.

Asynchroniczne Usuwanie za Pomocą Symbol.asyncDispose

Wiele nowoczesnych operacji czyszczenia jest asynchronicznych. Zamknięcie połączenia z bazą danych może obejmować wysłanie polecenia QUIT przez sieć, lub klient kolejki komunikatów może potrzebować opróżnienia swojego bufora wychodzącego. W tych scenariuszach używamy Symbol.asyncDispose.

Metoda powiązana z Symbol.asyncDispose musi zwracać Promise (lub być funkcją async).

Zamodelujmy połączenie z mockową bazą danych, które musi zostać asynchronicznie zwolnione z powrotem do puli.


// Mockowa pula połączeń z bazą danych
const mockDbPool = {
  getConnection: () => {
    console.log('Połączenie z DB pozyskane.');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`Wykonywanie zapytania: ${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // To jest asynchroniczna metoda usuwania
  async [Symbol.asyncDispose]() {
    console.log('Zwalnianie połączenia z DB z powrotem do puli...');
    // Symulacja opóźnienia sieciowego podczas zwalniania połączenia
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('Połączenie z DB zwolnione.');
  }
}

Teraz każda instancja MockDbConnection jest asynchronicznym zasobem do usunięcia. Wie, jak zwolnić się asynchronicznie, gdy nie jest już potrzebna.

Nowa Składnia: using i await using w Akcji

Mając zdefiniowane nasze klasy do usunięcia, możemy teraz użyć nowych słów kluczowych do automatycznego zarządzania nimi. Te słowa kluczowe tworzą deklaracje o zakresie bloku, tak jak let i const.

Synchroniczne Czyszczenie za Pomocą using

Słowo kluczowe using jest używane dla zasobów, które implementują Symbol.dispose. Kiedy wykonanie kodu opuszcza blok, w którym dokonano deklaracji using, metoda [Symbol.dispose]() jest automatycznie wywoływana.

Użyjmy naszej klasy TempFile:


function processDataWithTempFile() {
  console.log('Wchodzenie do bloku...');
  using tempFile = new TempFile('To są ważne dane.');

  // Możesz tutaj pracować z tempFile
  const content = fs.readFileSync(tempFile.path, 'utf8');
  console.log(`Odczytano z pliku tymczasowego: "${content}"`);

  // Nie jest potrzebny żaden kod czyszczący!
  console.log('...wykonywanie dalszej pracy...');
} // <-- tempFile.[Symbol.dispose]() jest wywoływane automatycznie właśnie tutaj!

processDataWithTempFile();
console.log('Blok został opuszczony.');

Wynik byłby następujący:

Wchodzenie do bloku...
Utworzono plik tymczasowy: /path/to/temp_1678886400000.txt
Odczytano z pliku tymczasowego: "To są ważne dane."
...wykonywanie dalszej pracy...
Usuwanie pliku tymczasowego: /path/to/temp_1678886400000.txt
Plik usunięty pomyślnie.
Blok został opuszczony.

Spójrz, jakie to czyste! Cały cykl życia zasobu jest zawarty w bloku. Deklarujemy go, używamy go i zapominamy o nim. Język obsługuje czyszczenie. To ogromna poprawa czytelności i bezpieczeństwa.

Zarządzanie Wieloma Zasobami

Możesz mieć wiele deklaracji using w tym samym bloku. Zostaną one usunięte w odwrotnej kolejności ich tworzenia (zachowanie LIFO lub "podobne do stosu").


{
  using resourceA = new MyDisposable('A'); // Utworzone jako pierwsze
  using resourceB = new MyDisposable('B'); // Utworzone jako drugie
  console.log('Wewnątrz bloku, używanie zasobów...');
} // resourceB jest usuwane jako pierwsze, a następnie resourceA

Asynchroniczne Czyszczenie za Pomocą await using

Słowo kluczowe await using jest asynchronicznym odpowiednikiem using. Jest używane dla zasobów, które implementują Symbol.asyncDispose. Ponieważ czyszczenie jest asynchroniczne, tego słowa kluczowego można używać tylko wewnątrz funkcji async lub na najwyższym poziomie modułu (jeśli obsługiwane jest await najwyższego poziomu).

Użyjmy naszej klasy MockDbConnection:


async function performDatabaseOperation() {
  console.log('Wchodzenie do funkcji async...');
  await using db = mockDbPool.getConnection();

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

  console.log('Operacja na bazie danych zakończona.');
} // <-- await db.[Symbol.asyncDispose]() jest wywoływane automatycznie tutaj!

(async () => {
  await performDatabaseOperation();
  console.log('Funkcja async została zakończona.');
})();

Wynik demonstruje asynchroniczne czyszczenie:

Wchodzenie do funkcji async...
Połączenie z DB pozyskane.
Wykonywanie zapytania: SELECT * FROM users
Operacja na bazie danych zakończona.
Zwalnianie połączenia z DB z powrotem do puli...
(czeka 50ms)
Połączenie z DB zwolnione.
Funkcja async została zakończona.

Podobnie jak w przypadku using, składnia await using obsługuje cały cykl życia, ale poprawnie `oczekuje` na asynchroniczny proces czyszczenia. Może nawet obsługiwać zasoby, które są tylko synchronicznie usuwalne – po prostu nie będzie na nie czekać.

Zaawansowane Wzorce: DisposableStack i AsyncDisposableStack

Czasami proste zakresy blokowe using nie są wystarczająco elastyczne. Co jeśli musisz zarządzać grupą zasobów z czasem życia, który nie jest powiązany z pojedynczym blokiem leksykalnym? Lub co, jeśli integrujesz się ze starszą biblioteką, która nie tworzy obiektów z Symbol.dispose?

W tych scenariuszach JavaScript zapewnia dwie klasy pomocnicze: DisposableStack i AsyncDisposableStack.

DisposableStack: Elastyczny Menedżer Czyszczenia

DisposableStack to obiekt, który zarządza kolekcją operacji czyszczenia. Sam jest zasobem do usunięcia, więc możesz zarządzać całym jego cyklem życia za pomocą bloku using.

Ma kilka przydatnych metod:

Przykład: Warunkowe Zarządzanie Zasobami

Wyobraź sobie funkcję, która otwiera plik dziennika tylko wtedy, gdy spełniony jest określony warunek, ale chcesz, aby całe czyszczenie odbywało się w jednym miejscu na końcu.


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

  const db = stack.use(getDbConnection()); // Zawsze używaj DB

  if (shouldLog) {
    const logFileStream = fs.createWriteStream('app.log');
    // Odłóż czyszczenie dla strumienia
    stack.defer(() => {
      console.log('Zamykanie strumienia pliku dziennika...');
      logFileStream.end();
    });
    db.logTo(logFileStream);
  }

  db.doWork();

} // <-- Stos jest usuwany, wywołując wszystkie zarejestrowane funkcje czyszczące w kolejności LIFO.

AsyncDisposableStack: Dla Asynchronicznego Świata

Jak możesz się domyślić, AsyncDisposableStack jest wersją asynchroniczną. Może zarządzać zarówno synchronicznymi, jak i asynchronicznymi elementami do usunięcia. Jego podstawowa metoda czyszczenia to .disposeAsync(), która zwraca Promise, który jest rozwiązywany, gdy wszystkie asynchroniczne operacje czyszczenia zostaną zakończone.

Przykład: Zarządzanie Mieszanką Zasobów

Stwórzmy obsługę żądań serwera WWW, która potrzebuje połączenia z bazą danych (asynchroniczne czyszczenie) i pliku tymczasowego (synchroniczne czyszczenie).


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

  // Zarządzaj asynchronicznym zasobem do usunięcia
  const dbConnection = await stack.use(getAsyncDbConnection());

  // Zarządzaj synchronicznym zasobem do usunięcia
  const tempFile = stack.use(new TempFile('dane żądania'));

  // Zaadoptuj zasób ze starego API
  const legacyResource = getLegacyResource();
  stack.adopt(legacyResource, () => legacyResource.shutdown());

  console.log('Przetwarzanie żądania...');
  await doWork(dbConnection, tempFile.path);

} // <-- stack.disposeAsync() jest wywoływane. Poprawnie zaczeka na asynchroniczne czyszczenie.

AsyncDisposableStack to potężne narzędzie do orkiestracji złożonej logiki konfiguracji i demontażu w czysty, przewidywalny sposób.

Solidna Obsługa Błędów za Pomocą SuppressedError

Jednym z najbardziej subtelnych, ale znaczących ulepszeń ERM jest sposób, w jaki obsługuje błędy. Co się stanie, jeśli błąd zostanie wyrzucony wewnątrz bloku using, a *inny* błąd zostanie wyrzucony podczas późniejszego automatycznego usuwania?

W starym świecie try...finally błąd z bloku finally zazwyczaj nadpisywałby lub "tłumił" oryginalny, ważniejszy błąd z bloku try. To często sprawiało, że debugowanie było niezwykle trudne.

ERM rozwiązuje to za pomocą nowego globalnego typu błędu: SuppressedError. Jeśli podczas usuwania wystąpi błąd, gdy inny błąd jest już propagowany, błąd usuwania jest "tłumiony". Wyrzucany jest oryginalny błąd, ale ma teraz właściwość suppressed zawierającą błąd usuwania.


class FaultyResource {
  [Symbol.dispose]() {
    throw new Error('Błąd podczas usuwania!');
  }
}

try {
  using resource = new FaultyResource();
  throw new Error('Błąd podczas operacji!');
} catch (e) {
  console.log(`Złapany błąd: ${e.message}`); // Błąd podczas operacji!
  if (e.suppressed) {
    console.log(`Stłumiony błąd: ${e.suppressed.message}`); // Błąd podczas usuwania!
    console.log(e instanceof SuppressedError); // false
    console.log(e.suppressed instanceof Error); // true
  }
}

To zachowanie zapewnia, że nigdy nie stracisz kontekstu oryginalnej awarii, co prowadzi do znacznie bardziej niezawodnych i łatwiejszych do debugowania systemów.

Praktyczne Przypadki Użycia w Całym Ekosystemie JavaScript

Zastosowania Jawnego Zarządzania Zasobami są ogromne i istotne dla programistów na całym świecie, niezależnie od tego, czy pracują po stronie back-endu, front-endu, czy w testach.

Obsługa Przez Przeglądarki i Środowiska Uruchomieniowe

Jako nowoczesna funkcja, ważne jest, aby wiedzieć, gdzie możesz używać Jawnego Zarządzania Zasobami. Pod koniec 2023 / na początku 2024 r. obsługa jest powszechna w najnowszych wersjach głównych środowisk JavaScript:

W starszych środowiskach będziesz musiał polegać na transpilatorach, takich jak Babel, z odpowiednimi wtyczkami, aby przekształcić składnię using i wypełnić niezbędne symbole i klasy stosu.

Wnioski: Nowa Era Bezpieczeństwa i Jasności

Jawne Zarządzanie Zasobami w JavaScript to więcej niż tylko lukier składniowy; to fundamentalne ulepszenie języka, które promuje bezpieczeństwo, jasność i łatwość utrzymania. Automatyzując żmudny i podatny na błędy proces czyszczenia zasobów, uwalnia programistów, aby mogli skupić się na swojej podstawowej logice biznesowej.

Kluczowe wnioski to:

Rozpoczynając nowe projekty lub refaktoryzując istniejący kod, rozważ przyjęcie tego potężnego nowego wzorca. Uczyni on twój JavaScript czystszym, twoje aplikacje bardziej niezawodnymi, a twoje życie jako programisty trochę łatwiejsze. To prawdziwie globalny standard pisania nowoczesnego, profesjonalnego JavaScript.