resolver.cancel() を使いこなす!Node.js DNS処理のベストプラクティス

2025-05-27

resolver.cancel() は、dns.promises.resolve()dns.promises.lookup() などの非同期DNSルックアップ操作によって返される Resolver オブジェクトが持つメソッドの一つです。このメソッドを呼び出すと、進行中のDNSクエリを強制的にキャンセルします。

より具体的に説明すると、以下のようになります。

  1. 非同期DNSルックアップの開始
    dns.promises.resolve('example.com') のように非同期のDNSルックアップを開始すると、Node.jsはバックグラウンドでDNSサーバーに問い合わせを行います。この操作は時間がかかる可能性があります。

  2. Resolver オブジェクトの取得
    dns.promises.resolve() は、DNSクエリの状態を管理するための Resolver オブジェクトを含む Promise を返します。

  3. キャンセルが必要になった場合
    何らかの理由で、DNSルックアップの結果が不要になったり、タイムアウト処理を行いたい場合などに、この Resolver オブジェクトの cancel() メソッドを呼び出します。

  4. クエリのキャンセル
    resolver.cancel() が呼び出されると、Node.jsは進行中のDNSクエリをできる限り早くキャンセルしようと試みます。

  5. Promise の拒否
    cancel() が呼び出された Resolver オブジェクトに関連付けられた Promise は、通常、DOMException (名前は "AbortError") で拒否されます。これにより、DNSルックアップが正常に完了しなかったことを呼び出し元に通知します。

どのような場合に resolver.cancel() が役立つか?

  • リクエストの中止
    ユーザーが操作をキャンセルした場合など、進行中のDNSリクエストを停止させたい場合。
  • 不要になった処理の停止
    例えば、複数のホスト名のルックアップを並行して行っている場合に、一部の結果が得られた時点で残りのルックアップをキャンセルしたい場合。
  • タイムアウト処理
    一定時間内にDNSルックアップが完了しない場合に、処理を中断してエラー処理を行いたい場合。

簡単なコード例

const dns = require('node:dns').promises;

async function lookupWithTimeout(hostname, timeoutMs) {
  const resolver = new dns.Resolver();
  let timeoutId;

  try {
    const promise = resolver.resolve(hostname);

    await new Promise((resolve, reject) => {
      timeoutId = setTimeout(() => {
        resolver.cancel();
        reject(new Error(`DNS lookup for ${hostname} timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      promise.then(resolve, reject);
    });

    clearTimeout(timeoutId);
    return await promise;
  } catch (error) {
    console.error(`Error looking up ${hostname}:`, error);
    throw error;
  }
}

async function main() {
  try {
    const result = await lookupWithTimeout('example.com', 100); // 意図的に短いタイムアウト
    console.log('Lookup result:', result);
  } catch (error) {
    console.log('Lookup failed:', error.message);
  }
}

main();

この例では、lookupWithTimeout 関数内で Resolver オブジェクトを作成し、タイムアウトを設定しています。指定した時間内に resolver.resolve() が完了しない場合、resolver.cancel() を呼び出してクエリをキャンセルし、Promiseをエラーで拒否しています。



一般的なエラーとトラブルシューティング

    • 原因
      resolver.cancel() を意図せず呼び出してしまっている可能性があります。例えば、複数の非同期処理が競合しており、誤ってキャンセル処理が実行されている場合などです。
    • トラブルシューティング
      resolver.cancel() を呼び出している箇所を特定し、本当にキャンセルが必要な状況かどうかを確認してください。条件分岐や非同期処理の制御フローを見直しましょう。
  1. キャンセル後に Promise が解決 (resolve) される

    • 原因
      resolver.cancel() は進行中のDNSクエリをキャンセルしようと試みますが、タイミングによってはキャンセル前にDNSサーバーから応答が返ってくることがあります。この場合、Promise は通常通り解決されます。
    • トラブルシューティング
      キャンセル後の処理では、Promise が拒否された場合だけでなく、解決された場合も適切に処理できるようにコードを記述する必要があります。例えば、キャンセルフラグのようなものを管理し、解決された場合でもそのフラグを確認して処理をスキップするなどの対策が考えられます。
  2. resolver.cancel() を呼び出しても DNS クエリがすぐに停止しない

    • 原因
      resolver.cancel() はあくまでキャンセルを試みるものであり、即座に DNS クエリが停止することを保証するものではありません。ネットワーク状況や DNS サーバーの応答によっては、キャンセル処理が完了するまでに時間がかかることがあります。
    • トラブルシューティング
      キャンセル処理後にすぐにリソースを解放したり、関連する処理を停止したりする場合は、DNS クエリが実際に終了したことを確認する仕組み(例えば、Promise が拒否されたことを確認するなど)を導入することを検討してください。
  3. TypeError: resolver.cancel is not a function エラー

    • 原因
      使用している Node.js のバージョンが古く、Resolver オブジェクトに cancel() メソッドが存在しない可能性があります。dns.promises.Resolver API は比較的新しい機能です。
    • トラブルシューティング
      Node.js のバージョンを 14.17.0 以降にアップデートしてください。それ以前のバージョンでは、dns.Resolver オブジェクトは cancel() メソッドを持っていません。
  4. キャンセル処理後のリソース管理

    • 原因
      resolver.cancel() を呼び出した後、関連するリソース(例えば、タイマーなど)のクリアを忘れると、メモリリークや予期しない動作を引き起こす可能性があります。
    • トラブルシューティング
      resolver.cancel() を呼び出す際には、関連するタイマーやイベントリスナーなどを適切にクリアするように心がけてください。上記のコード例のように、clearTimeout() を使用するなど、後処理を確実に行うようにしましょう。
  5. 複数の cancel() 呼び出し

    • 原因
      同じ Resolver オブジェクトに対して複数回 cancel() を呼び出すことは、通常は問題ありません。ただし、意図しない複数回の呼び出しは、コードのロジックに問題がある可能性を示唆している場合があります。
    • トラブルシューティング
      なぜ複数回 cancel() が呼び出される可能性があるのかを調査し、コードの設計を見直してください。

トラブルシューティングのヒント

  • 最小限の再現コード
    問題を再現させる最小限のコードを作成し、他の要因を排除した状態でテストを行うことで、問題の切り分けが容易になります。
  • デバッガーの使用
    Node.js のデバッガーを利用して、コードの実行をステップバイステップで確認し、変数の状態や関数の呼び出し順序などを詳しく調べることで、問題の原因を特定できる場合があります。
  • ログ出力の活用
    DNS クエリの開始、キャンセル処理の呼び出し、Promise の結果などをログに出力することで、処理の流れを把握しやすくなります。
  • エラーメッセージをよく読む
    エラーが発生した場合は、表示されたエラーメッセージを注意深く読み、原因の特定に役立ててください。


例1: 基本的なタイムアウト処理

const dns = require('node:dns').promises;

async function lookupWithTimeout(hostname, timeoutMs) {
  const resolver = new dns.Resolver();
  let timeoutId;

  try {
    const promise = resolver.resolve(hostname);

    await new Promise((resolve, reject) => {
      timeoutId = setTimeout(() => {
        resolver.cancel();
        reject(new Error(`DNS lookup for ${hostname} timed out after ${timeoutMs}ms`));
      }, timeoutMs);

      promise.then(resolve, reject);
    });

    clearTimeout(timeoutId);
    return await promise;
  } catch (error) {
    console.error(`Error looking up ${hostname}:`, error);
    throw error;
  }
}

async function main() {
  try {
    const result = await lookupWithTimeout('example.com', 100); // 意図的に短いタイムアウト
    console.log('Lookup result:', result);
  } catch (error) {
    console.log('Lookup failed:', error.message);
  }
}

main();

このコードでは、lookupWithTimeout 関数がホスト名とタイムアウト時間を引数に取り、dns.Resolver() で新しいリゾルバーを作成します。setTimeout を使用して指定時間後に resolver.cancel() を呼び出し、Promise を拒否するように設定しています。Promise.race() を使う方法もありますが、この例ではより明示的にキャンセル処理を行っています。

例2: 複数のホスト名のルックアップを並行処理し、途中でキャンセルする

複数のホスト名のルックアップを同時に開始し、特定の条件が満たされた場合に残りのクエリをキャンセルする例です。

const dns = require('node:dns').promises;

async function lookupMultiple(hostnames, cancelCondition) {
  const resolvers = hostnames.map(() => new dns.Resolver());
  const promises = hostnames.map((hostname, index) => resolvers[index].resolve(hostname));
  const results = [];
  let shouldCancel = false;

  // キャンセル条件を定期的にチェックする(実際にはより複雑な条件になる可能性があります)
  const intervalId = setInterval(() => {
    if (cancelCondition()) {
      shouldCancel = true;
      resolvers.forEach(resolver => resolver.cancel());
      clearInterval(intervalId);
    }
  }, 50);

  try {
    for (let i = 0; i < promises.length; i++) {
      if (shouldCancel) {
        break;
      }
      results.push(await promises[i].catch(error => {
        if (error.name === 'AbortError') {
          return `Lookup for ${hostnames[i]} was cancelled.`;
        }
        throw error;
      }));
    }
    return results;
  } finally {
    clearInterval(intervalId); // 必ずインターバルをクリア
  }
}

async function main() {
  const hostnames = ['example.com', 'google.com', 'invalid-domain-that-will-timeout.verylong'];
  const cancelAfterFirst = () => true; // 最初のルックアップが開始されたらキャンセル

  const results = await lookupMultiple(hostnames, cancelAfterFirst);
  console.log('Lookup results:', results);
}

main();

この例では、複数の Resolver オブジェクトと Promise を作成し、並行してDNSルックアップを開始しています。setInterval を使用して定期的にキャンセル条件をチェックし、条件が満たされたらすべてのリゾルバーの cancel() メソッドを呼び出しています。各 Promise の catch ブロックで AbortError を捕捉し、キャンセルされたことを処理しています。

例3: イベントリスナーと組み合わせてキャンセル処理を行う

特定のイベントが発生した場合にDNSルックアップをキャンセルする例です。

const dns = require('node:dns').promises;
const EventEmitter = require('node:events');

async function lookupWithEventCancel(hostname, cancelEvent) {
  const resolver = new dns.Resolver();
  const promise = resolver.resolve(hostname);

  const cancelListener = () => {
    resolver.cancel();
  };

  cancelEvent.once('cancelLookup', cancelListener);

  try {
    const result = await promise;
    cancelEvent.off('cancelLookup', cancelListener); // 成功したらリスナーを解除
    return result;
  } catch (error) {
    cancelEvent.off('cancelLookup', cancelListener); // エラーが発生した場合もリスナーを解除
    if (error.name === 'AbortError') {
      console.log(`Lookup for ${hostname} was cancelled due to event.`);
      return null;
    }
    throw error;
  }
}

async function main() {
  const cancelEvent = new EventEmitter();

  // 3秒後にキャンセルイベントを発行
  setTimeout(() => {
    cancelEvent.emit('cancelLookup');
  }, 3000);

  try {
    const result = await lookupWithEventCancel('example.com', cancelEvent);
    if (result) {
      console.log('Lookup result:', result);
    }
  } catch (error) {
    console.error('Lookup error:', error);
  }
}

main();

この例では、EventEmitter を使用してキャンセルイベントを管理しています。lookupWithEventCancel 関数内で、cancelEventcancelLookup イベントが発生したら resolver.cancel() を呼び出すリスナーを設定しています。これにより、外部のイベントに基づいてDNSルックアップを制御することができます。



Promiseのタイムアウト処理 (Promise.race を利用)

resolver.resolve()resolver.lookup() が返す Promise と、一定時間後に拒否される別の Promise を Promise.race() で競わせることで、タイムアウトを実現できます。

const dns = require('node:dns').promises;

async function lookupWithTimeoutAlternative(hostname, timeoutMs) {
  const lookupPromise = dns.resolve(hostname);
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`DNS lookup for ${hostname} timed out after ${timeoutMs}ms`));
    }, timeoutMs);
  });

  try {
    return await Promise.race([lookupPromise, timeoutPromise]);
  } catch (error) {
    console.error(`Error looking up ${hostname}:`, error);
    throw error;
  }
}

async function main() {
  try {
    const result = await lookupWithTimeoutAlternative('example.com', 100); // 短いタイムアウト
    console.log('Lookup result:', result);
  } catch (error) {
    console.log('Lookup failed:', error.message);
  }
}

main();

利点

  • Promise ベースであるため、async/await と自然に連携できます。
  • resolver.cancel() が利用できない古い Node.js バージョンでも動作する可能性があります (ただし、dns.promises 自体が比較的新しいAPIです)。

欠点

  • AbortError ではなく、自分で定義したエラーオブジェクトが返されます。
  • 実際にDNSクエリを中断するわけではないため、タイムアウト後もバックグラウンドでクエリが継続する可能性があります。リソースの無駄遣いになる可能性があります。

手動での状態管理と無視

DNSルックアップを開始する前にフラグを設定し、ルックアップが完了した際にそのフラグの状態を確認して結果を処理するかどうかを決定する方法です。

const dns = require('node:dns').promises;

async function lookupWithManualCancel(hostname, shouldCancelRef) {
  try {
    const result = await dns.resolve(hostname);
    if (!shouldCancelRef.current) {
      return result;
    } else {
      console.log(`Lookup for ${hostname} was ignored.`);
      return null;
    }
  } catch (error) {
    console.error(`Error looking up ${hostname}:`, error);
    throw error;
  }
}

async function main() {
  const cancelFlag = { current: false };

  const lookupPromise = lookupWithManualCancel('example.com', cancelFlag);

  setTimeout(() => {
    cancelFlag.current = true;
    console.log('Cancelling lookup...');
  }, 100);

  const result = await lookupPromise;
  if (result) {
    console.log('Lookup result:', result);
  }
}

main();

利点

  • resolver.cancel() が利用できない環境でも実装できます。
  • より細かい制御が可能になる場合があります。

欠点

  • エラー処理や状態管理を慎重に行う必要があります。
  • DNSクエリ自体はバックグラウンドで継続する可能性があります。
  • 実装が複雑になりがちです。

ライブラリの利用

DNSルックアップのタイムアウトやキャンセル機能を抽象化したサードパーティのライブラリを利用することも考えられます。これらのライブラリは、より高レベルなAPIを提供し、エラーハンドリングやリソース管理を容易にする場合があります。

利点

  • 一般的なユースケースに対する機能が既に実装されている場合があります。
  • より簡潔で読みやすいコードになる可能性があります。

欠点

  • 必ずしも resolver.cancel() と同等の低レベルな制御ができるとは限りません。
  • ライブラリのAPIや挙動を理解する必要があります。
  • 外部の依存関係が増えます。

resolver.cancel() は、進行中のDNSクエリをOSレベルで中断しようと試みるため、上記の代替方法よりもリソース効率が良い可能性があります。特に、タイムアウト時間が長く設定されている場合や、不要になったクエリを早期に停止したい場合には、resolver.cancel() を利用できる環境であれば積極的に使用することが推奨されます。