Promises および async/await の実用的な活用法
1. 学習内容
- fs.promises API の主要なメソッドとユースケース
- Promise.all、race、allSettled、any の行動上の違いと選択
- async/await および try/catch を使ったエラー処理
- 並行制御:同時に実行される非同期タスクの数を制限する
- 順次実行、並列実行、および限定並行処理のユースケース
- プロミスの連鎖呼び出しに関するベストプラクティス
- util.promisify は、コールバック形式のコードを Promise に変換します
2. ストーリー:チャーリーの大量データダウンロード
チャーリーは、100個の外部APIから設定データをダウンロードする担当でした。当初、彼はforを使用してループ内でリクエストを1つずつ送信していました。各リクエストには約30秒かかったため、100件すべてを完了するには50分かかりました。その後、彼はPromise.allに切り替えて並列リクエストを行うようにしたところ、合計時間は30秒に短縮されました。しかし、サーバーから即座に429 Too Many Requestsが返されてしまいました。チャーリーは同時リクエスト数を制限する必要があることに気づき、最終的に5件に制限した結果、合計時間は約10分にまで短縮されました。これは効率的であると同時に、レート制限をトリガーしない解決策となりました。
この話は、非同期プログラミングにおける根本的なトレードオフ、すなわち速度とリソースのバランスを浮き彫りにしています。Promiseやasync/awaitは、このバランスを管理するためのツールです。
3. fs.promises API
バージョン10以降、Node.jsではfs.promisesが提供されており、これはファイルシステム操作をPromiseを返すメソッドとしてラップすることで、コールバック地獄に終止符を打っています。
(1) fsのコールバック vs fs.promises vs promisify
| 機能 | fs コールバック | fs.promises | util.promisify(fs.xxx) |
|---|---|---|---|
| 戻り値 | void; 結果はコールバックを介して渡される | Promise | Promise |
| エラー処理 | コールバックの最初のパラメータ、err |
try/catch または .catch() |
try/catch または .catch() |
| コードスタイル | ネストされたコールバック | フラットな async/await | フラットな async/await |
| 利用可能なバージョン | すべてのバージョン | v10以上 | v8以上 |
| 代表的な使用例 | fs.readFile(path, (err, data) => {}) |
await fs.promises.readFile(path) |
const readFile = promisify(fs.readFile); await readFile(path) |
▶ 例:fs.promises を使ったファイルの読み書き
const fs = require('fs').promises;
async function readAndWrite() {
try {
const data = await fs.readFile('input.txt', 'utf8');
const upper = data.toUpperCase();
await fs.writeFile('output.txt', upper);
console.log('Done');
} catch (err) {
console.error('Error:', err.message);
}
}
readAndWrite();
▶ 例:fs.promises を使用したディレクトリ操作
const fs = require('fs').promises;
async function listFiles(dir) {
try {
await fs.mkdir(dir, { recursive: true });
const files = await fs.readdir(dir);
for (const file of files) {
const stat = await fs.stat(`${dir}/${file}`);
console.log(`${file} - ${stat.size} bytes`);
}
} catch (err) {
console.error(err.message);
}
}
listFiles('./my-dir');
▶ Example: util.promisify converts callback functions
const fs = require('fs');
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
async function copy() {
const data = await readFile('source.txt', 'utf8');
await writeFile('dest.txt', data);
console.log('Copied');
}
copy();
4. プロミス・チェイニングの手法
4つの静的メソッドによって、複数のPromiseが互いにどのように相互作用するかが決まります。メソッドの組み合わせを誤ると、結果が大きく異なってしまう可能性があります。
(1) 4つの組み合わせ手法の比較
| メソッド | すべての操作が成功した場合 | 失敗した場合 | 戻り値 | 代表的な用途 |
|---|---|---|---|---|
Promise.all |
結果配列全体を返す | 最初の失敗でリジェクトする;セット全体をリジェクトする | 結果配列 | すべてのタスクが完了している必要がある |
Promise.race |
最初に完了した結果を返す | 最初の失敗で却下 | 単一値 | タイムアウト制御、最速の応答 |
Promise.allSettled |
すべての結果を返す | 失敗を排除せず、失敗メッセージも含める | {status, value/reason}[] |
成功・失敗にかかわらず、すべての結果を返す |
Promise.any |
最初に成功した結果を返す | すべてが失敗した場合のみ拒否する(AggregateError) | 単一の値 | 複数ソース間の競合、最も速く成功したものを採用 |
▶ 例:Promise.all — すべてのプロミスが成功した場合にのみ成功する
async function fetchAll() {
const urls = [
'https://api.example.com/a',
'https://api.example.com/b',
'https://api.example.com/c',
];
try {
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
console.log('All succeeded:', results.length);
} catch (err) {
console.error('One failed:', err.message);
}
}
▶ 例:Promise.allSettled — 決してリジェクトされない
async function fetchAllSettled() {
const tasks = [
Promise.resolve({ id: 1 }),
Promise.reject(new Error('Server down')),
Promise.resolve({ id: 3 }),
];
const results = await Promise.allSettled(tasks);
const succeeded = results.filter(r => r.status === 'fulfilled');
const failed = results.filter(r => r.status === 'rejected');
console.log(`Succeeded: ${succeeded.length}, Failed: ${failed.length}`);
failed.forEach(r => console.error('Reason:', r.reason.message));
}
▶ 例:Promise.race — タイムアウトの制御
function fetchWithTimeout(url, ms) {
const fetchTask = fetch(url).then(r => r.json());
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
);
return Promise.race([fetchTask, timeout]);
}
async function demo() {
try {
const data = await fetchWithTimeout('https://api.example.com/slow', 3000);
console.log(data);
} catch (err) {
console.error(err.message);
}
}
▶ 例:Promise.any — レースコンディション下で複数のソースから成功結果を取得する
async function fastestMirror() {
const mirrors = [
fetch('https://mirror1.example.com/data').then(r => r.json()),
fetch('https://mirror2.example.com/data').then(r => r.json()),
fetch('https://mirror3.example.com/data').then(r => r.json()),
];
try {
const result = await Promise.any(mirrors);
console.log('Fastest response:', result);
} catch (err) {
console.error('All mirrors failed:', err.errors.length);
}
}
(2) Mermaid:プロミス・チェイニング法の実行比較
flowchart TB
subgraph all["Promise.all"]
A1["Task A ✅"] --- A2["Task B ✅"] --- A3["Task C ✅"]
AR["→ [A, B, C] ✅"]
end
subgraph race["Promise.race"]
R1["Task A ⏱ 1s"] --- R2["Task B ⏱ 3s"] --- R3["Task C ⏱ 2s"]
RR["→ A ✅ (Fastest)"]
end
subgraph settled["Promise.allSettled"]
S1["Task A ✅"] --- S2["Task B ❌"] --- S3["Task C ✅"]
SR["→ [{fulfilled:A}, {rejected:B}, {fulfilled:C}] ✅"]
end
subgraph any["Promise.any"]
N1["Task A ❌"] --- N2["Task B ✅ ⏱ 2s"] --- N3["Task C ✅ ⏱ 3s"]
NR["→ B ✅ (Success as Quickly as Possible)"]
end
all --> AR
race --> RR
settled --> SR
any --> NR
style AR fill:#c8e6c9
style RR fill:#c8e6c9
style SR fill:#fff9c4
style NR fill:#c8e6c9
5. async/await を使ったエラー処理
async/await を使用すると、非同期コードが同期コードのように見えるようになり、エラー処理もそれに倣う try/catch—しかし、いくつかの落とし穴がある。
(1) エラー処理モデルの比較
| パターン | 実装 | 利点 | 欠点 |
|---|---|---|---|
| コールバック | if (err) { handle } |
シンプルで直感的 | ネストが深いため、見落としやすい |
| Promise.catch() | promise.then().catch() |
連鎖可能、再利用可能 | ネストすると依然として複雑 |
| async/await + try/catch | try { await } catch {} |
同期的なスタイルで、可読性が高い | 各 await を必ず囲む必要がある |
| ラッパー関数 | const [err, data] = await to(promise) |
try/catch なし、簡潔 | ヘルパー関数のインポートが必要 |
▶ 例:非同期関数を try/catch で囲む
const fs = require('fs').promises;
async function safeReadFile(path) {
try {
const data = await fs.readFile(path, 'utf8');
return { ok: true, data };
} catch (err) {
return { ok: false, error: err.message };
}
}
async function main() {
const result = await safeReadFile('missing.txt');
if (!result.ok) {
console.error('Failed:', result.error);
return;
}
console.log('Content:', result.data);
}
main();
▶ 例:try/catch を使用しないエラー処理関数
function to(promise) {
return promise
.then(data => [null, data])
.catch(err => [err, null]);
}
async function main() {
const fs = require('fs').promises;
const [err, data] = await to(fs.readFile('config.json', 'utf8'));
if (err) {
console.error('Read failed:', err.message);
return;
}
console.log('Config:', data);
}
main();
6. 並行処理制御
(1) 順次処理、並列処理、制約付き並行処理
| 実行方法 | 総所要時間(N個のタスク、各タスクの所要時間はT) | 利点 | 欠点 | 適したシナリオ |
|---|---|---|---|---|
| 順次実行 | N × T | シンプルでリソース効率が良い | 処理に時間がかかる | 依存関係のあるタスク |
| 完全並列 | ≈ T | 最速 | リソース使用率がピーク時に高くなる;スロットリングされる可能性がある | 独立したタスクが少ない |
| 並行処理数に制限がある | ≈ N/並行処理数 × T | 速度とリソースのバランスが取れている | 実装がやや複雑 | 多数の独立したタスク、APIのレート制限 |
▶ 例:順次実行
const fs = require('fs').promises;
async function sequential() {
const files = ['a.txt', 'b.txt', 'c.txt'];
const results = [];
for (const file of files) {
const data = await fs.readFile(file, 'utf8');
results.push(data);
}
console.log('Results:', results.length);
}
▶ 例:制約付き並行制御関数
async function limitConcurrency(tasks, limit) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = task().then(result => {
executing.delete(p);
return result;
});
executing.add(p);
results.push(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
▶ 例:同時ダウンロード数制限 API の使用方法
async function fetchApi(url) {
const res = await fetch(url);
return res.json();
}
async function batchFetch() {
const urls = Array.from({ length: 100 }, (_, i) =>
`https://api.example.com/item/${i + 1}`
);
const tasks = urls.map(url => () => fetchApi(url));
const results = await limitConcurrency(tasks, 5);
console.log(`Fetched ${results.length} items`);
}
7. プロミスの連鎖に関するベストプラクティス
(1) 連鎖呼び出しに関する規則
| 規則 | 説明 | 反例 |
|---|---|---|
| 常に Promise を返す | チェーン処理を保証する | promise.then(() => { doSomething() }) 戻り値なし |
| エラーは常に処理する | 最後に .catch() を追加する | .catch() を含まない連鎖ブロックでは、エラーが捕捉されない |
| ネストを避ける | .then() チェーンを平坦化する | .then(() => { return p.then(...) }) |
| 長いチェーンを async/await に置き換える | 3回以上の .then() 呼び出しを await に置き換える | 5段階以上ネストされた .then() 呼び出し |
| 注:.then() 内でスローされた例外 | は、次の .catch() によって捕捉されます | なぜなら、例外が処理の流れを中断しないと思っていたからです |
▶ 例:フラットチェーンコール
const fs = require('fs').promises;
function processFile(path) {
return fs.readFile(path, 'utf8')
.then(data => data.trim())
.then(data => data.toUpperCase())
.then(data => fs.writeFile('output.txt', data))
.then(() => console.log('Saved'))
.catch(err => console.error('Error:', err.message));
}
processFile('input.txt');
8. 包括的な例:並行処理に制限のあるバッチファイル処理ツール
ツールを作成する:ディレクトリを読み込む → 同時実行中のファイル処理を3つに制限する → 結果を集約する → 統計情報を出力する。
const fs = require('fs').promises;
const path = require('path');
async function processFile(filePath) {
const stat = await fs.stat(filePath);
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n').length;
const words = content.split(/\s+/).filter(Boolean).length;
return {
file: path.basename(filePath),
size: stat.size,
lines,
words,
};
}
async function limitConcurrency(tasks, limit) {
const results = [];
const executing = new Set();
for (const task of tasks) {
const p = task().then(result => {
executing.delete(p);
return result;
});
executing.add(p);
results.push(p);
if (executing.size >= limit) {
await Promise.race(executing);
}
}
return Promise.all(results);
}
async function batchProcessDir(dirPath, concurrency = 3) {
console.log(`Scanning directory: ${dirPath}`);
const files = await fs.readdir(dirPath);
const filePaths = files
.filter(f => f.endsWith('.txt') || f.endsWith('.md') || f.endsWith('.json'))
.map(f => path.join(dirPath, f));
if (filePaths.length === 0) {
console.log('No matching files found.');
return;
}
const tasks = filePaths.map(fp => () => processFile(fp));
const results = await limitConcurrency(tasks, concurrency);
console.log('\n--- File Statistics ---');
console.log('File'.padEnd(20) + 'Size'.padEnd(10) + 'Lines'.padEnd(8) + 'Words');
console.log('-'.repeat(46));
let totalLines = 0;
let totalWords = 0;
let totalSize = 0;
for (const r of results) {
console.log(
r.file.padEnd(20) +
String(r.size).padEnd(10) +
String(r.lines).padEnd(8) +
String(r.words)
);
totalLines += r.lines;
totalWords += r.words;
totalSize += r.size;
}
console.log('-'.repeat(46));
console.log(
'TOTAL'.padEnd(20) +
String(totalSize).padEnd(10) +
String(totalLines).padEnd(8) +
String(totalWords)
);
console.log(`\nProcessed ${results.length} files (concurrency: ${concurrency})`);
}
batchProcessDir('./data', 3).catch(err => console.error('Fatal:', err.message));
パフォーマンス:
Scanning directory: ./data
--- File Statistics ---
File Size Lines Words
----------------------------------------------
config.json 256 12 42
readme.md 1024 45 312
notes.txt 512 28 178
----------------------------------------------
TOTAL 1792 85 532
Processed 3 files (concurrency: 3)
❓ よくある質問
Promise.all で 1 つのプロミスに失敗した場合、どうなりますか?Promise.allSettled を使用してください。limitConcurrency 関数を実装し、Set を使ってアクティブな Promise を追跡し、Promise.race を使って並行操作の数を制御します。本番環境では、p-limit ライブラリを使用することもできます。await は async 関数内でのみ使用できますか?awaitが導入され、ESモジュールの最上位でawaitを直接使用できるようになりましたが、CommonJSモジュールでは、依然としてasync関数内にラップする必要があります。util.promisify はすべてのコールバック関数を変換できますか?(err, result) => {} 形式のコールバックでのみ機能します。カスタムの多引数コールバックは、手動でラップする必要があります。Promise.allSettled は、入力と同じ順序で結果を返しますか?async 関数は何を返しますか?async 関数は常に Promise を返します。通常の値を返した場合でも、自動的に Promise.resolve(value) でラップされます。📖 まとめ
- 重要な概念とその活用方法
- ストーリー:チャーリーのバッチデータダウンロードの基本概念と使い方
- fs.promises API の基本概念と使い方
- Promiseのチェーンメソッドの基本概念と使い方
- async/await を用いたエラー処理の基本概念と使い方
- 並行処理制御の基本概念と活用方法
- Core Concepts and Best Practices for Chaining Promises
- 包括的な事例:並行処理に制限のあるバッチファイル処理ツールの基本概念と使用方法
📝 練習問題
- このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
- 包括的な例を修正し、独自の拡張機能を追加する
- 公式ドキュメントを確認し、このレッスンで取り上げられていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
- 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
- このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。



