日本語

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ランタイムの間のコントラクトを導入します。中心となるアイデアは単純です。オブジェクトはクリーンアップの方法を宣言でき、言語はオブジェクトがスコープ外になったときにそのクリーンアップを自動的に実行するための構文を提供します。

これは、次の2つの主要なコンポーネントによって実現されます。

  1. 使い捨てプロトコル:特別なシンボルSymbol.dispose(同期クリーンアップ用)とSymbol.asyncDispose(非同期クリーンアップ用)を使用して、オブジェクトが独自のクリーンアップロジックを定義するための標準的な方法。
  2. usingおよびawait using宣言:リソースをブロックスコープにバインドする新しいキーワード。ブロックが終了すると、リソースのクリーンアップメソッドが自動的に呼び出されます。

中心となる概念:`Symbol.dispose`と`Symbol.asyncDispose`

ERMの中心には、2つの新しい既知のシンボルがあります。これらのシンボルのいずれかをキーとするメソッドを持つオブジェクトは、「使い捨てリソース」と見なされます。

`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);
      // 破棄内でもエラーを処理することが重要です!
    }
  }
}

`TempFile`のインスタンスはすべて、使い捨てリソースになりました。ディスクからファイルを削除するロジックを含む`Symbol.dispose`をキーとするメソッドがあります。

`Symbol.asyncDispose`を使用した非同期的な破棄

多くの最新のクリーンアップ操作は非同期です。データベース接続を閉じると、ネットワーク経由で`QUIT`コマンドを送信したり、メッセージキュークライアントが送信バッファーをフラッシュしたりする必要がある場合があります。これらのシナリオでは、`Symbol.asyncDispose`を使用します。

`Symbol.asyncDispose`に関連付けられたメソッドは、`Promise`を返す必要があります(または、`async`関数である必要があります)。

プールに非同期的にリリースする必要があるモックデータベース接続をモデル化しましょう。


// モックデータベースプール
const mockDbPool = {
  getConnection: () => {
    console.log('DB接続を取得しました。');
    return new MockDbConnection();
  }
};

class MockDbConnection {
  query(sql) {
    console.log(`クエリを実行しています:${sql}`);
    return Promise.resolve({ success: true, rows: [] });
  }

  // これは非同期的な破棄メソッドです
  async [Symbol.asyncDispose]() {
    console.log('DB接続をプールにリリースしています...');
    // 接続のリリースにネットワーク遅延をシミュレートします
    await new Promise(resolve => setTimeout(resolve, 50));
    console.log('DB接続をリリースしました。');
  }
}

これで、`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'); // 2番目に作成
  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('非同期関数が完了しました。');
})();

出力は非同期的なクリーンアップを示しています。

非同期関数に入っています...
DB接続を取得しました。
クエリを実行しています:SELECT * FROM users
データベース操作が完了しました。
DB接続をプールにリリースしています...
(50ms待機)
DB接続をリリースしました。
非同期関数が完了しました。

`using`と同様に、`await using`構文はライフサイクル全体を処理しますが、非同期的なクリーンアッププロセスを正しく`await`します。同期的にのみ使い捨て可能なリソースも処理できます。つまり、それらを待機しません。

高度なパターン:`DisposableStack`と`AsyncDisposableStack`

場合によっては、`using`の単純なブロックスコープだけでは十分に柔軟ではありません。単一の字句ブロックに関連付けられていないライフサイクルを持つリソースのグループを管理する必要がある場合はどうすればよいでしょうか?または、`Symbol.dispose`を持つオブジェクトを生成しない古いライブラリと統合している場合はどうでしょうか?

これらのシナリオのために、JavaScriptは2つのヘルパークラス`DisposableStack`と`AsyncDisposableStack`を提供します。

`DisposableStack`:柔軟なクリーンアップマネージャー

`DisposableStack`は、クリーンアップ操作のコレクションを管理するオブジェクトです。それ自体が使い捨てリソースであるため、`using`ブロックを使用してそのライフサイクル全体を管理できます。

これには、いくつかの便利なメソッドがあります。

例:条件付きリソース管理

特定の条件が満たされた場合にのみログファイルを開く関数を想像してください。ただし、すべてのクリーンアップを最後に1か所で行うようにします。


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`を返します。

例:リソースの混合の管理

データベース接続(非同期クリーンアップ)と一時ファイル(同期クリーンアップ)を必要とするWebサーバーリクエストハンドラーを作成しましょう。


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の最も微妙でありながら重要な改善点の1つは、エラーの処理方法です。`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環境の最新バージョンではサポートが広まっています。

古い環境では、`using`構文を変換し、必要なシンボルとスタッククラスをポリフィルするために、適切なプラグインを備えたBabelのようなトランスパイラーに依存する必要があります。

結論:安全性と明確さの新時代

JavaScriptの明示的なリソース管理は、単なるシンタックスシュガーではありません。安全性、明確さ、および保守性を促進する言語への根本的な改善です。リソースクリーンアップの退屈でエラーが発生しやすいプロセスを自動化することにより、開発者は主要なビジネスロジックに集中できるようになります。

主なポイントは次のとおりです。

新しいプロジェクトを開始したり、既存のコードをリファクタリングしたりする場合は、この強力な新しいパターンを採用することを検討してください。JavaScriptがよりクリーンになり、アプリケーションがより信頼性が高まり、開発者としての生活が少し楽になります。これは、最新のプロフェッショナルなJavaScriptを作成するための真にグローバルな標準です。