ほんじゃらねっと

ダイエット中プログラマのブログ

node.jsがやたら非同期化しようとするのをasync/awaitでどうにか同期化する

Node.jsは入出力関連の処理を非同期で行ないます。入出力イベントが非同期で実行されることで、遅い入出力処理の合間に他の処理を挟んで効率的にプログラムを実行することができます。この特長を活かすため、ライブラリに含まれる関数も非同期的に実行されるものがとても多いです。

簡単に処理を非同期化できるようになっているというのはうまく活用できれば大変ありがたいもので、node.jsの最大の特長だと言えます。

ところがただ「簡単に導入できて」「実行速度が速くて」「Javascriptで書ける」ツールとして手軽にnode.jsを使いたい、という場合にこの非同期至上主義なところが邪魔をすることがあります。

ちょっとした処理を行うコマンドラインスクリプトをサクッと作りたいだけなのに、思ったような順番で処理が実行されなかったり、コールバック地獄になったり。

同期的に動かそうとするだけでなぜこんなに分かりにくい書き方をしなければならないのか...。プログラミング初心者がnode.jsを使おうとした時に頭を悩ませる大変の要因はこの非同期処理の同期化によるものではないでしょうか。

せっかく簡単に導入できてJavascriptで書けて実行速度も速い最高のツールなのに、自分には合わない、とnode.jsを捨ててしまうのはもったいないです。

Node.jsのv7.6から導入された「async/await」を使うことで同期化がしやすくなり、慣れ親しんだものに近い形でプログラムが書けるようになりました。一度使ってみるとこれ無しではやってられなくなるくらいすっきりとしたコードが書けます。

今回はこの「async/awaitを用いた非同期処理の同期化」の方法について紹介します。

「async/await」を使用すると呼び出し側のコードがスッキリする

async/awaitはどういう機能かを乱暴に説明すると、「非同期な関数」に「async」というキーワードをつけておくことで非同期的にでも同期的にでも呼べるようにする機能です。「await」キーワード付でasync関数を呼ぶことで、あたかも同期的な処理を行う関数であるかのように扱えます。

コールバック型の非同期関数とasync/await型の非同期関数の定義と呼び出しのちがいを比較しながら見てみましょう。

非同期な関数はいつ実行が終了するか分からないので、引数として関数の処理完了後に実行する処理をまとめたコールバック関数を渡しておき、それを実行させます。非同期な関数が実行完了してから実行したい処理がある場合はコールバック関数の中に書くことになります。

望む実行順で非同期関数を実行したい場合はコールバック関数の中で非同期関数を呼び出し、そのコールバック関数の中でまた別の非同期関数を呼び出し、という風にコールバック関数が何重にも入れ子になった状態になります。これがいわゆる「コールバック地獄」というやつです。

仮にコールバック型の非同期関数「asyncReadValueCallbackFunc」が下記のように定義されているとしましょう。

function asyncReadValueCallbackFunc(params, callbackFunc) {
    // ...非同期な処理...
   result = ...;

   // 処理が終わってからcallback関数に処理結果を渡す。
    callbackFunc(result);
}

asyncReadValueCallbackFuncは下記のように呼んで使用します。

function main() {
    console.log('呼び出し前');

    asyncReadValueCallbackFunc('パラメータ', function (res) {
        console.log('asyncReadValueCallbackFunc実行後に実行したいコード');
        console.log(result);
    });

    console.log('呼び出し後');
}

この場合、「呼び出し後」というメッセージを出力する処理はasyncReadValueCallbackFuncの実行完了を待たずに実行されるので、「asyncReadValueCallbackFunc実行後に実行したいコード」の出力とどちらが先に実行されるかは実行してみないと分かりません。確実にasyncReadValueCallbackFuncの後に実行したい処理はコールバック関数内に記述します。

コールバック地獄になるとこんな感じのコードになります。

function main() {
    console.log('呼び出し前');

    asyncCallbackFunc1('パラメータ', function (result1) {
        console.log('asyncCallbackFunc1実行後に実行したいコード');

        asyncCallbackFunc2(result1, function (result2) {
            console.log('asyncCallbackFunc2実行後に実行したいコード');

            asyncCallbackFunc3(result2, function (result3) {
                console.log('asyncCallbackFunc3実行後に実行したいコード');
                console.log(result3);
            });
        });
    });

    console.log('呼び出し後');
}

最終的にresult3の計算結果を出すためにasyncCallbackFunc1〜3を順に実行しています。それぞれの関数は意図したとおりの順序で実行されますが、コードが非常に分かりづらいです。

async/awaitを使うと、非同期関数の処理結果をコールバック関数で受け取る代わりに戻り値として受け取ることができます。上記のasyncReadValueCallbackFuncと同じ処理をasync/awaitを用いて行う関数「asyncReadValueFunc」を実装してみましょう。

async function asyncReadValueFunc(params) {
    // ...非同期な処理...
    result = ...;

    // resultを実行結果として返す        
    return result;
}

コールバック関数を受け取るパラメータが不要となり、代わりに「async」というキーワードをfunction定義前に追加しています。

このasyncReadValueFuncは下記のように呼んで使用します。

async function main() {
    console.log('呼び出し前');

    const res = await asyncReadValueFunc('パラメータ');
    console.log('asyncReadValueFunc実行後に実行したいコード');
    console.log(result);

    console.log('呼び出し後');
}

呼び出す側はasyncReadValueFuncを呼び出す際に「await」というキーワードをつけていますが、それ以外は同期関数を呼び出すのと同じ形式で関数を呼んでいます。main関数を実行すると、「呼び出し前」「asyncReadValueFunc実行後に実行したいコード」「resultの値」「呼び出し後」の順で出力されます。

awaitをつけるだけで、コールバック関数を仕込んだりする必要がなく、普通の非同期でない関数と同じように結果を受け取ることができるわけです。

上述のコールバック地獄のサンプルをasync/await版に書き換えてみましょう。

async function main() {
    console.log('呼び出し前');

    const result1 = await asyncAwaitFunc1('パラメータ');
    console.log('asyncAwaitFunc1実行後に実行したいコード');

    const result2 = await asyncAwaitFunc2(result1);
    console.log('asyncAwaitFunc2実行後に実行したいコード');

    const result3 = await asyncAwaitFunc3('パラメータ');
    console.log('asyncAwaitFunc3実行後に実行したいコード');
    console.log(result3);

    console.log('呼び出し後');
}

実にスッキリしたコードになります。

asyncキーワードは関数の戻り値をPromiseオブジェクトに変換する

単純にasync/awaitを使うのは割と簡単です。上の例で見たとおり、asyncキーワード付で宣言された関数であれば、awaitキーワード付で呼び出すだけで同期的に使用することができます。

これだけでも便利に使えるのですが、更に使いこなすためには裏で何が行われているのかを知っておきましょう。

たとえば、非同期関数の前にasyncをつけることで、つける前と何が変わるのでしょう?

答えは、「asyncをつけるとreturnした値がPromiseオブジェクトに変換される」です。async関数とは、必ずPromiseオブジェクトを返す関数なのです。

PromiseはJavascriptに組み込まれた機能の1つで、async/awaitと同じく非同期処理を管理しやすくするためのものです。実際のところasyncとawaitはあくまでPromiseをより簡単に利用するために後から追加された機能であり、Promiseこそが主役なのです。

ではそのPromiseとは何かというと、これまた乱暴に説明すると「非同期処理を1つのオブジェクトにまとめて持ち回しやすくするもの」だと言えます。

ファイルの読み込みなり書き込みなりの時間のかかる入出力処理を非同期で行いつつ、完了した後で別の処理を行いたいと言う時に、その非同期処理ごと1つのPromiseオブジェクトにまとめてしまって、どこでも結果を受け取れるようにしよう、というものです。

Promiseを使うことで、非同期処理を実行する関数にコールバック関数を渡す代わりに、戻り値としてまだ中で非同期処理を実行しているPromiseオブジェクトを返す、ということができるようになります。

developer.mozilla.org

Promiseオブジェクトを返す関数の定義と、その関数を呼び出してPromiseオブジェクトを受け取る処理を見てみましょう。

// Promiseオブジェクトを返す非同期関数
function asyncPromiseFunc() {
    return new Promise(resolve => {
        // ...何かしらの時間がかかる処理...
        const result = ...;

        resolve(result);
    });
}

function main() {
    // 戻り値のpはPromiseオブジェクト
    const p = asyncPromiseFunc();

    // 非同期処理が完了したらthenに渡したコールバック関数の内容が実行される。
    p.then(function (result) {
        console.log(result);
    });
}

asyncPromiseFuncがPromiseオブジェクトを返す非同期関数です。非同期処理はreturnされるPromiseオブジェクト内で実行される。非同期処理の実行結果をどう処理するかはasyncPromiseFunc関数を呼び出した側でPromiseオブジェクトのthenメソッドにコールバック関数を渡すことで定義できます。

コールバック型関数とのちがいは、非同期関数側がコールバック関数について処理をする必要がなくなり、Promiseオブジェクトを返すだけで良くなった、というところです。また、「非同期処理はPromiseオブジェクトで扱う」という風に統一されることで、async/awaitのような形に発展させやすくなりました。

async関数の話に戻りましょう。

asyncキーワードは上記のPromiseオブジェクトを返す関数を簡単に定義できるようにするものです。

下記の2つの関数は同じようなPromiseオブジェクトを返します。

function asyncPromiseFunc1() {
    return new Promise(resolve => {
        // ...何かしらの時間がかかる処理...
        const result = ...;

        resolve(result);
    });
}

async function asyncPromiseFunc2() {
    // ...何かしらの時間がかかる処理...
    const result = ...;

    return result;
}

function main() {
    const p1 = asyncPromiseFunc1();
    const p2 = asyncPromiseFunc2();

    p1.then(function (result) {
        console.log(result);
    });

    p2.then(function (result) {
        console.log(result);
    });
}

async関数の方は変数を返しているだけのように見えますが、実際はPromiseオブジェクトを生成して返しています。なのでasyncPromiseFunc2を呼び出した側はPromiseオブジェクトを受け取り、thenで処理を定義します。

asyncがPromiseオブジェクトを返す非同期関数の定義を簡単にできるようにするためのものだ、ということが確認できました。

awaitはPromiseオブジェクトの結果を同期的に受け取れるようにする

async関数はPromiseオブジェクトを返す、ではawaitは何をしているのでしょうか?

非同期処理を同期化する、という意味ではどちらかというとasyncよりもawaitの方が重要な役割を担っています。

awaitは渡されたPromiseオブジェクトの実行完了を待って結果を返す機能を持ちます。

awaitを使うことで、コールバック関数を使用することなく直接非同期処理の結果を受け取ることができるわけです。これがあって初めて、コールバック関数無しで非同期処理を順次実行できるようになります。

asyncで書いたサンプルをawaitを使用する形に変更すると、下記のようになります。

function asyncPromiseFunc1() {
    return new Promise(resolve => {
        // ...何かしらの時間がかかる処理...
        const result = ...;

        resolve(result);
    });
}

async function asyncPromiseFunc2() {
    // ...何かしらの時間がかかる処理...
    const result = ...;

    return result;
}

async function main() {
    const result1 = await asyncPromiseFunc1();
    const result2 = await asyncPromiseFunc2();

    console.log(result1);
    console.log(result2);
}

Promiseオブジェクトであれば処理できるので、対象がasync関数なのか、Promiseオブジェクトを直接生成して返す関数なのかは関係ありません。何なら関数ではなく直接Promiseオブジェクトを渡してもちゃんと処理してくれます。

まとめついでに、色々なパターンの非同期処理とその結果を見てみましょう。

// Promiseオブジェクトを返す関数
function p1() {
    return new Promise(resolve => {
        resolve('test1');
    });
}

// Promiseオブジェクトを返すasync関数
async function p2() {
    return new Promise(resolve => {
        resolve('test2');
    });
}

// 値を返す関数
function p3() {
    return 'test3';
}

// 値を返すasync関数
async function p4() {
    return 'test4';
}

async function main() {
    const result11 = p1();
    console.log('result11', result11);

    const result12 = await p1();
    console.log('result12', result12);

    const result21 = p2();
    console.log('result21', result21);

    const result22 = await p2();
    console.log('result22', result22);

    const result31 = p3();
    console.log('result31', result31);

    const result32 = await p3();
    console.log('result32', result32);

    const result41 = p4();
    console.log('result41', result41);

    const result42 = await p4();
    console.log('result42', result42);

    const result99 = await new Promise(resolve => {
        resolve('result99');
    });
    console.log('result99', result99);
}

main();

実行結果

result11 Promise { 'test1' }
result12 test1
result21 Promise { <pending> }
result22 test2
result31 test3
result32 test3
result41 Promise { 'test4' }
result42 test4
result99 result99

実行結果で「Promise { ... }」となっているのは、Promiseオブジェクトの状態になっているということです。awaitを介することでPromiseオブジェクトの処理結果が取得されていることが確認できます。

awaitを関数内で使用する場合は必ずその関数がasync指定されている必要があります。ルールとして覚えておきましょう。

async/awaitでは実装できない処理もある

async/awaitは基本的に組み合わせて使うことが多いですが、Promiseを直接使わないとうまく行かない場合もあります。というか自前で非同期処理を実装する場合はasyncではなくPromiseオブジェクトを生成することの方が多いのではないでしょうか。

例えば一定時間処理を停止するsleep関数を作って使いたい時は下記のような関数を作成します。

function sleep(waitMillSeconds: number) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
        }, waitMillSeconds);
    });
}

awaitで呼び出せばシンプルにsleep処理を実行できます。

async function main() {
    console.log('開始');

    // 1秒待機
    await sleep(1000);

    console.log('終了');
}

main();

async/awaitの使い方だけでなく、Promiseオブジェクトについても理解しておく必要がある、ということです。

使いたい非同期関数がPromise非対応の場合はラッパー関数でPromise対応できる

最近のライブラリはだいたいPromise形式の返り値に対応しているので、そういったライブラリはthenを使う代わりにasync/awaitを使えばすっきり書けます。

しかしPromiseオブジェクトを返さない、コールバック関数を前提とした関数も多く存在します。こういった関数を便利に使いたい場合は、自分でPromiseオブジェクトを返すラッパー関数を作成します。

例えばファイルを行ごとにリストにして読み込みたい場合は、下記のような関数を定義するとasync/awaitで呼べるようになります。

import fs from 'fs';
import readline from 'readline';

function readFileLines(srcFilePath, encoding) {
    let rs = fs.createReadStream(srcFilePath, {encoding: encoding});
    let rl = readline.createInterface({input: rs});

    return new Promise((resolve, reject) => {
        const lineList = [];
        rl.on('line', (line) => {
            lineList.push(line);
        })
        .on('close', () => {
            resolve(lineList)
        });
    });
}

おわりに

async/awaitを使うと非同期関数を同期的に使うことができます。Promiseオブジェクトやらそれぞれのキーワードの役割やら色々理解しておかないといけない概念は多いですが、使いこなせば実装がスッキリしてnode.jsでの開発が一層楽しくなること間違いなしなので、ぜひ使ってみてください。