NodeJS 速習チュートリアル

Node.js Async/Await

1. Async/Await 入門

Async/Await は、Node.js における非同期操作(Asynchronous operations)を処理するためのモダンな手法です。Promise(プロミス) をベースに構築されており、さらに可読性の高いコードを作成することができます。

Node.js 7.6 で導入され、ES2017 で標準化された Async/Await を使用すると、非同期コードをあたかも同期コード(Synchronous code)のような見た目と挙動で記述できます。

本質的に Async/Await は、Promise をより読みやすい構文にしたものです。これによりコードはクリーンになり、メンテナンス性(Maintainability)が向上します。メインスレッド(Main thread)をブロックすることなく、追跡や理解が容易な非同期処理を実現します。

2. 構文と基本的な使い方

この構文は、2つのキーワードで構成されます。

  • async: Promise を返す非同期関数(Asynchronous function)を宣言するために使用します。
  • await: Promise が解決(Resolve)されるまで実行を一時停止します。async 関数の中でのみ使用可能です。

2.1 例:基本的な Async/Await

async function getData() {
  console.log('開始...');
  // 非同期操作が完了するまで待機
  const result = await someAsyncOperation();
  console.log(`結果: ${result}`);
  return result;
}

function someAsyncOperation() {
  return new Promise(resolve => {
    // 1秒のディレイをシミュレート
    setTimeout(() => resolve('操作完了'), 1000);
  });
}

// 非同期関数を呼び出す
getData().then(data => console.log('最終データ:', data));

2.2 例:Async/Await を使用したファイルの読み込み

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

async function readFile() {
  try {
    // ファイルの読み込みを await
    const data = await fs.readFile('myfile.txt', 'utf8');
    console.log(data);
  } catch (error) {
    console.error('ファイル読み込みエラー:', error);
  }
}

readFile();

3. try/catch によるエラーハンドリング

Async/Await の大きな利点の一つは、従来通りの try/catch ブロックを使用してエラーハンドリング(Error handling)ができる点です。これにより、コードの可読性が大幅に向上します。

3.1 例:Async/Await でのエラーハンドリング

async function fetchUserData() {
  try {
    const response = await fetch('https://api.example.com/users/1');
    if (!response.ok) {
      // HTTPエラーをスロー
      throw new Error(`HTTPエラー: ${response.status}`);
    }
    const user = await response.json();
    console.log('ユーザーデータ:', user);
    return user;
  } catch (error) {
    console.error('ユーザーデータ取得エラー:', error);
    throw error; // 必要に応じてエラーを再スロー
  }
}

また、シナリオに応じて Async/Await と Promise の .catch() を組み合わせることも可能です。

// 非同期関数の外側で catch を使用する
fetchUserData().catch(error => {
  console.log('非同期関数の外でキャッチされました:', error.message);
});

4. Promise の並列実行

Async/Await はコードを同期的に見せますが、パフォーマンスを向上させるために複数の操作を並列(Parallel)で実行しなければならない場面もあります。

4.1 例:直列処理 vs 並列処理

// APIコールをシミュレートするヘルパー関数
function fetchData(id) {
  return new Promise(resolve => {
    setTimeout(() => resolve(`ID ${id} のデータ`), 1000);
  });
}

// 直列処理 (Sequential) - 約3秒かかる
async function fetchSequential() {
  console.time('sequential');
  const data1 = await fetchData(1);
  const data2 = await fetchData(2);
  const data3 = await fetchData(3);
  console.timeEnd('sequential');
  return [data1, data2, data3];
}

// 並列処理 (Parallel) - 約1秒で完了
async function fetchParallel() {
  console.time('parallel');
  const results = await Promise.all([
    fetchData(1),
    fetchData(2),
    fetchData(3)
  ]);
  console.timeEnd('parallel');
  return results;
}

// デモの実行
async function runDemo() {
  console.log('直列処理を実行中...');
  const seqResults = await fetchSequential();
  console.log(seqResults);
  
  console.log('\n並列処理を実行中...');
  const parResults = await fetchParallel();
  console.log(parResults);
}

runDemo();

5. Async/Await vs Promises vs Callbacks

同じタスクを異なる非同期パターンでどのように処理するか比較してみましょう。

5.1 Callbacks(コールバック)の場合

function getUser(userId, callback) {
  setTimeout(() => {
    callback(null, { id: userId, name: 'John' });
  }, 1000);
}

function getUserPosts(user, callback) {
  setTimeout(() => {
    callback(null, ['Post 1', 'Post 2']);
  }, 1000);
}

// コールバックを使用
getUser(1, (error, user) => {
  if (error) {
    console.error(error);
    return;
  }
  console.log('User:', user);
  
  getUserPosts(user, (error, posts) => {
    if (error) {
      console.error(error);
      return;
    }
    console.log('Posts:', posts);
  });
});

5.2 Promises(プロミス)の場合

function getUserPromise(userId) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ id: userId, name: 'John' });
    }, 1000);
  });
}

function getUserPostsPromise(user) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(['Post 1', 'Post 2']);
    }, 1000);
  });
}

// プロミスを使用
getUserPromise(1)
  .then(user => {
    console.log('User:', user);
    return getUserPostsPromise(user);
  })
  .then(posts => {
    console.log('Posts:', posts);
  })
  .catch(error => {
    console.error(error);
  });

5.3 Async/Await の場合

// Async/Await を使用
async function getUserAndPosts() {
  try {
    const user = await getUserPromise(1);
    console.log('User:', user);
    
    const posts = await getUserPostsPromise(user);
    console.log('Posts:', posts);
  } catch (error) {
    console.error(error);
  }
}

getUserAndPosts();

5.4 パターンの比較まとめ

パターン長所 (Pros)短所 (Cons)
Callbacks理解が単純、広くサポートされているコールバック地獄、エラーハンドリングが複雑
Promises.then() によるチェーン、エラーハンドリングの改善複雑なフローではネストが発生、可読性は Async/Await に劣る
Async/Awaitクリーンで同期的、try/catch が使いやすいPromise の理解が必要、不用意に実行をブロックさせやすい

6. ベストプラクティス

Node.js で Async/Await を扱う際は、以下のベストプラクティスに従ってください。

  • Async 関数は常に Promise を返すことを忘れない
async function myFunction() {
  return 'Hello';
}

// これは 'Hello' という文字列ではなく、'Hello' を解決する Promise を返す
const result = myFunction();
console.log(result); // Promise { 'Hello' }

// await するか .then() を使う必要がある
myFunction().then(message => console.log(message)); // Hello
  • 並列操作には Promise.all を使用する
    • 操作が互いに独立している場合は、Promise.all を使用してパフォーマンスを最適化(Optimization)しましょう。
  • 常にエラーをハンドリングする
    • try/catch ブロックを使用するか、非同期関数の呼び出しに .catch() をチェーンさせてください。
  • Async/Await とコールバックを混ぜない
    • コールバックベースの関数は、util.promisify やカスタムラッパー(Wrapper)を使用して Promise ベースに変換してから使用しましょう。
const util = require('util');
const fs = require('fs');

// コールバックベースの関数を Promise ベースに変換
const readFile = util.promisify(fs.readFile);

async function readFileContents() {
  const data = await readFile('file.txt', 'utf8');
  return data;
}
  • 単一責任の原則を守る
    • 非同期関数(Async functions)は、一つの責任(Responsibility)に集中するように設計しましょう。
トップレベル Await: Node.js 14.8.0 以降の ECMAScript モジュール(ESM)では、関数の外(モジュールレベル)で await を直接使用できる「トップレベル Await」機能が利用可能です。

シングルスレッドの Node.js において、Async/Await はノンブロッキング(Non-blocking)な挙動を維持しつつ、コードの明快さを保つための強力な武器となります。