Promise、async/awaitに立ち向かう


はじめに

Node.jsにしてもフロントエンドのJavaScript(以降JSと表記)にしても、非同期処理からは逃れることはできません。Promiseや非同期処理については様々な方が解説記事を書かれていますので、二番煎じ感は否めないのですが、自分の中しっかりと理解する意味も込めてまとめます。

そもそも非同期処理とは?

JSは基本的にはシングルスレッドで動作する言語なので、基本的にはマルチスレッドの処理ができません。シングルスレッドということは、時間のかかる処理が走る場合は、全体の処理がブロックされてしまいます。I/Oやネットワーク処理とは相性が悪いですね。ネットワーク処理が走ると、ボタンのクリック処理が出来なくなっては困ります。
そこで、JSではイベントループによる並行処理と非同期APIを採用することで、上記のシングルスレッドの問題を回避しています。

例えば、以下のようなコードを考えてみます。
requestモジュールは、HTTPリクエストを行うことが出来ます。

const request = require("request");

const param1 = {
    url: "https://www.google.com",
    method: "GET"
}

request(param1, function (error, response, body) {
    console.log("HTTPレスポンス: " + body);  
});
console.log("リクエスト開始");

まず、「リクエスト開始」が出力され、その後「HTTPレスポンス」が出力されます。
request関数の完了を待たずに、下の行が実行されます。
処理の流れをざっくりと記載します。
1. request関数がHTTPリクエストを発行し、イベントハンドラにHTTPリクエストの結果が来た場合のコールバック関数を登録する
2. request関数は、returnを返して関数を終了させる
3. console.log("リクエスト開始")の行が実行される
4. HTTPリクエストの結果が来たことがイベントキューに追加される
5. イベントループがイベントキューから順次実行し、登録されているコールバック関数を実行する(console.log("HTTPレスポンス: "...の行のこと)
6. コールバック関数には、引数にHTTPリクエストの結果が渡された状態で実行される

setTimeoutやsetIntervalも同じ仕組みで動作します。指定した時間以降にコールバック関数を実行するようにイベントハンドラに登録します。ですが、イベントループがキューを順次処理するため、必ず指定した時刻に実行されることは限りません。

Promiseとは?

Promiseとは、ECMA Script 2015(ES6)で追加された非同期処理をより書きやすくするためのオブジェクトです。
Promiseが出来る以前(ES5時代)は、コールバック地獄、というものがありました。

例えば、以下のようなコードを考えてみましょう。
Googleのページを取得し、もし取得できなかったらYahooのページを取得しようとする、という内容です。


const request = require("request");

const param1 = {
    url: "https://www.google.com",
    method: "GET"
}
const param2 = {
    url: "https://www.yahoo.co.jp/",
    method: "GET"
}

// Googleのページを取得
request(param1, function (error, response, body) {
    if(error){
        // Googleのページが取得できなかったら、Yahooのページを取得
        request(param2, function (error, response, body) {
            if(error){
                // もしYahooのページが取得できなかったら!?
            }
            console.log(body);
        });
    }else{
        console.log(body);
    }
})

実行の流れをざっくり説明します。
まず、Googleのページに対してHTTPリクエストを発行し、コールバック関数をイベントハンドラに登録します。HTTPリクエストの結果が返ると、コールバック関数が実行され、Googleのページが取得できない場合、Yahooのページに対してHTTPリクエストを発行し、コールバック関数をイベントハンドラに登録します。HTTPリクエストの結果が返ると、コールバック関数が実行され内容が出力されます。。。。
もうおわかりかと思いますが、Yahooのページが取得できなかった処理を書こうと思うと、更にネストが深くなります。コールバック関数の中に更にコールバック関数があり、更にコールバック関数が。。。

ネストがどんどん深くなることはお分かり頂けたかと思います。そこでPromsieオブジェクトの出番です。

const request = require("request");

const param1 = {
    url: "https://www.google.com",
    method: "GET"
}
const param2 = {
    url: "https://www.yahoo.co.jp/",
    method: "GET"
}

function requestPromise(param){
    return new Promise((resolve, reject)=>{
        request(param, function (error, response, body) {
            if(error){
                reject("ページを取得できませんでした");
            }else{
                console.log(body);
                resolve("取得できました");
            }
        })
    })
}

requestPromise(param1).catch(()=>{
    return requestPromise(param2);
})
console.log("リクエスト開始");

上記のコードでは、request関数をPromiseオブジェクトでラップしています。Promiseオブジェクトの中をもう少し詳しく見てみましょう。Promiseオブジェクトの引数には関数を代入します。関数にはresolveとrejectという引数を設定します。resolveとrejectはそれぞれ関数です。Promiseオブジェクトの引数に設定した関数ではresolveかrejectのいずれかを必ず実行してください。resolveとrejectを実行しないと、Promiseオブジェクトは処理待ちと判断し、次の処理に移らないためです。resolveは処理成功として、rejectは処理失敗としてPromsieオブジェクトを次の処理に進めます。

では、以下の部分について詳しく見ていきましょう。
Promiseオブジェクトはチェーンとして繋げることが可能です。
チェーンとしてつなげた場合、順次(同期的に)実行されます。

requestPromise(param1).catch(()=>{
    return requestPromise(param2);
})
console.log("リクエスト開始");

よって、requestPromise(param1)の実行が完了するまで、return requestPromise(param2);は実行されません。さらに、requestPromise(param1)の実行が失敗した場合(rejectでPromiseオブジェクトが終了した場合)のみ、return requestPromise(param2);が実行されます。

プロミスチェーンでは順番に実行することが保証されますが、console.log("リクエスト開始");は、プロミスチェーンの外にいるので、requestPromise(param1)の実行の完了などを待たずに、即座に実行されます。

では、Yahooのページが取得できなかった場合の実装を追加してみましょう。


const request = require("request");

const param1 = {
    url: "https://www.google.com",
    method: "GET"
}
const param2 = {
    url: "https://www.yahoo.co.jp/",
    method: "GET"
}
const param3 = {
    url: "https://www.msn.com/ja-jp",
    method: "GET"
}

function requestPromise(param){
    return new Promise((resolve, reject)=>{
        request(param, function (error, response, body) {
            if(error){
                reject("ページを取得できませんでした");
            }else{
                console.log(body);
                resolve("取得できました");
            }
        })
    })
}

requestPromise(param1).catch(()=>{
    return requestPromise(param2);
}).catch(()=>{
    return requestPromise(param3);
})

重要なのは以下です。
catchの部分を一つ追加するだけです。ネストが深くなることはなく、非常にわかりやすいプログラムになっていますね。

requestPromise(param1).catch(()=>{
    return requestPromise(param2);
}).catch(()=>{
    return requestPromise(param3);
})

async/awaitとは?

async/awaitはECMA Script2017(ES8)で追加された記法です。
この記法を使うと、Promiseチェーンを使うことなく、非同期処理を同期的に実行させることが可能になります。
asyncで定義した関数内でawaitキーワードを使用することで、Promiseオブジェクトの終了を待つことができます。

const request = require("request");

const param1 = {
    url: "https://www.google.com",
    method: "GET"
}
const param2 = {
    url: "https://www.yahoo.co.jp/",
    method: "GET"
}

function requestPromise(param){
    return new Promise((resolve, reject)=>{
        request(param, function (error, response, body) {
            if(error){
                reject("ページを取得できませんでした");
            }else{
                console.log(body);
                resolve("取得できました");
            }
        })
    })
}

(async function(){
    try{
        await requestPromise(param1);
    }catch(error){
        await requestPromise(param2);
    }
})();
console.log("リクエスト開始");

重要なのは以下の部分です。

(async function(){
    try{
        await requestPromise(param1);
        console.log("param1の実行が完了");
    }catch(error){
        await requestPromise(param2);
    }
})();
console.log("リクエスト開始");

先程までは、Promiseチェーンを使っていたので、別々の関数で実行されていた処理を、async/awaitを使うことで、同じ関数内で同期的に非同期処理を実行することが出来るようになりました。awaitキーワードを使うことで、Promiseオブジェクトの完了を待つことができます。awaitキーワードを使うと、Promiseオブエジェクトの完了を待つので、そこで一旦処理がブロックされ、以降の処理が実行されません。
よって、await requestPromise(param1);の実行が完了した後、console.log("param1の実行が完了")が実行されます。
console.log("リクエスト開始");は、async/awaitなどの外側にいますので、即座に実行されます。

最後に

Promiseの詳細な説明は省いていますので、リファレンスなどを参照頂ければと思います。
async/awaitで更に非同期処理のコーディングがしやすくなっていますが、速度面は少し注意する必要があります。
Node.jsのバージョンが上がるに連れて、処理速度は改善されて来ていますが、基本的にはコールバック関数が速度的には一番優れています。
少しでも皆さんのお役に立てれば幸いです。