ほんじゃらねっと

ダイエットとエッセイとプログラミング

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での開発が一層楽しくなること間違いなしなので、ぜひ使ってみていただきたい。