JavaScript
Node.js
非同期処理

Node.jsの非同期処理のコールバック地獄を Promise、Generator、async/await を使って解決する

同期処理と非同期処理

この記事ではテキストファイルを読み込んでターミナルに出力するというプログラムを通してコールバック地獄の問題点とその解決策を記述します。
実行にはNode.jsのインストールが必要です。

同期的にファイルを読み込む
// Node.jsのFileSystemモジュールを読み込む
const fs = require('fs');
// 同期的にファイルを読み込む
const data = fs.readFileSync(path = 'foo.txt', options = {
    encoding: 'utf8'
});
console.log(data);

非同期処理で処理するには読み込みが完了したときに呼び出される関数を用意します。
非同期に処理を行うことで重い処理がほかの処理をブロックすることがなくなり、全体のパフォーマンスが上昇します。

非同期にファイルを読み込む
const fs = require('fs');
// 読み込みが完了したときの処理
const listener = (err, data) => {
    console.log(data);
}
// 非同期で読み込む
fs.readFile(path = 'foo.txt', options = {
    encoding: 'utf8'
}, listener);

無名関数とコールバック地獄

無名関数を使えば引数に関数オブジェクトを直接指定できるので、同期処理と同じように簡潔に記述できます。

無名関数を使って簡潔に書く
const fs = require('fs');
// 非同期で読み込む
fs.readFile(path = 'a.txt', options = {
    encoding: 'utf8'
}, (err, data) => {
    console.log(data);
});

非同期で直列処理を行う場合、コールバック関数を連続して記述します。
しかし、そのまま書くとネストがどんどん深くなり、コールバック地獄と呼ばれる読みにくいプログラムになります。

コールバック地獄
const fs = require('fs');
fs.readFile(path = 'foo.txt', options = {
    encoding: 'utf8'
}, (err, data) => {
    console.log('foo.txtを読み込みました', data);
    fs.readFile(path = 'bar.txt', options = {
        encoding: 'utf8'
    }, (err, data) => {
        console.log('bar.txtを読み込みました', data);
        fs.readFile(path = 'buz.txt', options = {
            encoding: 'utf8'
        }, (err, data) => {
            console.log('buz.txtを読み込みました', data);
            ・・・以下略
        });
    });
});

このコールバック地獄を回避するために以下のような方法があります。

  • Promiseを使う方法(EcmaScript2015~)
  • Generatorを使う方法(EcmaScript2015~)
  • async/awaitを使う方法(EcmaScript2017~)

Promiseを使った連続した非同期処理

非同期処理によって得られる値の代わりに、Promiseオブジェクトを返します。
非同期処理が完了した時点で、Promiseオブジェクトから実際の値が得られます。

Promiseを使った非同期処理
const fs = require('fs');

// Promiseを返す関数
const readFilePromise = (filePath) => {
    return new Promise((resolve) => {
        fs.readFile(path = filePath, options = {
            encoding: 'utf8'
        }, (err, data) => {
            resolve(data);
        });
    });
}

// 非同期でテキストファイルを読み込む
readFilePromise('foo.txt')
    .then((data) => { // 非同期処理が完了するたびにthen()メソッドの中に書いた関数が実行される
        console.log('foo.txtを読み込みました', data);
        return readFilePromise('bar.txt');
    })
    .then((data) => {
        console.log('bar.txtを読み込みました', data);
        return readFilePromise('buz.txt');
    })
    .then((data) => {
        console.log('buz.txtを読み込みました', data);
    });

Generatorを使った非同期処理

Generatorを使用すると反復処理を行うためにのイテレータを簡単に定義にできます。
Generator関数を.next()メソッドを使って実行すると yieldから次のyieldまで実行され、もう一度.next()メソッドを実行すると、次のyieldから処理が再開されます。
非同期処理を一時停止し、コールバックが終了したら再開するという処理をすることで、直列処理を実現できます。

Generatorを使った非同期処理
const fs = require('fs');
// 非同期処理の完了を待って関数の続きを呼ぶ関数
const readFileGenerator = (generator, filePath) => {
    fs.readFile(path = filePath, options = {
        encoding: 'utf8'
    }, (err, data) => {
        generator.next(data);
    });
}
// 逐次実行する非同期処理
const generatorFunc = (function* () {
    const a = yield readFileGenerator(generatorFunc, 'a.txt');
    console.log('a.txtを読み込みました', a);
    const b = yield readFileGenerator(generatorFunc, 'b.txt');
    console.log('b.txtを読み込みました', b);
    const c = yield readFileGenerator(generatorFunc, 'c.txt');
    console.log('c.txtを読み込みました', c);
})();
generatorFunc.next();

async/awaitを使った非同期処理

EcmaScript2017からはasync/awaitを使用できるのでさらに簡潔にコーディングすることができます。
非同期で呼び出したい関数をawaitを付けて実行します。
awaitを使う関数はasync functionとして定義する必要があります。

readfile_async.js
const fs = require('fs');
// 非同期でファイルを読み込みPromiseを返す関数を定義
const readFileEx = (filePath) => {
    return new Promise((resolve, reject) => {
        fs.readFile(path = filePath, options = {
            encoding: 'utf8'
        }, (err, data) => {
            resolve(data);
        });
    });
}
// すべてのファイルを非同期に読み込むasync関数を定義
const readAll = async() => {
    const foo = await readFileEx('foo.txt');
    console.log('foo.txtを読み込みました', foo);
    const bar = await readFileEx('bar.txt');
    console.log('bar.txtを読み込みました', bar);
    const buz = await readFileEx('buz.txt');
    console.log('buz.txtを読み込みました', buz);
}
readAll();

実際にターミナルで実行してみます。

PS C:\> node readfile_async.js
foo.txtを読み込みました *** foo.txt ***
bar.txtを読み込みました *** bar.txt ***
buz.txtを読み込みました *** buz.txt ***

Promise .then()Generator functionを使うよりもスッキリかけるため、今後はasync/awaitが主流になっていくようです。この構文はNode v7以降にサポートされています。

参考