Intelligent Technology's Technical Blog

株式会社インテリジェントテクノロジーの技術情報ブログです。

Promiseパターンによる非同期処理(JavaScript)

こんにちは、間藤です。
今回は、Promiseパターンについて取り上げてみたいと思います。説明のためにjQuery.Deferredを使っていきます。

話の流れとしては、まず最初に同期処理のサンプルを確認します。このサンプルで同期処理の問題点を押さえた後、非同期処理に書き換えたサンプルを確認します。そして、非同期処理の実装における課題に触れ、jQuery.Deferred(Promiseパターン)でその解決を試みます。
なお、以下に示すサンプルは、エラーに対する考慮を一切加えていませんので、ご了承ください。また、Deferredについても、説明が足りない部分もありますので、詳細は公式サイトのドキュメントなどご参照ください。


サンプルの概要

サーバサイドで実行する計算処理が2つあると仮定します。
クライアントからそれぞれに対して計算のリクエストを送ります。クライアントは、両方から結果を受け取って加算し、その結果を画面に表示します。但し、サーバサイドには計算処理を用意するわけではなく、以下のようなPHPプログラムで固定値を返すようにしておきます。計算処理に時間がかかるという状況を作るために、それぞれ3秒スリープするようにしています。
サーバサイドは、今回の主題ではありませんので、どんなものであっても構いません。もし、以下に示すサンプルを動かされる場合は、お使いの環境に都合のよいものをご用意ください。

【sample1.php

<?php
sleep(3);

header("Cache-Control: no-store, no-cache, must-revalidate");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");

echo "100";
?>

【sample2.php

<?php
sleep(3);

header("Cache-Control: no-store, no-cache, must-revalidate");
header("Cache-Control: post-check=0, pre-check=0", false);
header("Pragma: no-cache");

echo "200";
?>

同期処理のサンプル

では、最初に同期処理のサンプルを示します。

【sample.html】

<!DOCTYPE html>
<html>
    <head>
        <title>サンプル</title>
        <meta charset="utf-8">
        <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>
        <script>

        $(function() {
            $("#add").click( function(){ appendMessage( $('#msg').val() ); } );
            $("#start").click( calcStart );
        });

        function appendMessage(msg) {
            var current = new Date();
            var line = "<p>" + current.toLocaleTimeString() + " " + msg + "</p>";
            $('#messages').append(line);
        }

        function calcStart() {
            appendMessage("calc01 start");
            calc01();
            appendMessage("calc01 end");
        }

        function calc01() {
            var result = 0;
            var xhr = new XMLHttpRequest();

            xhr.open("GET", "sample1.php", false);  // 第3引数にfalseを指定して同期通信に
            xhr.send(null);                         // ここでブロックする
            if (xhr.status === 200) {
                result = xhr.responseText - 0;
            }

            xhr.open("GET", "sample2.php", false);  // 第3引数にfalseを指定して同期通信に
            xhr.send(null);                         // ここでブロックする
            if (xhr.status === 200) {
                result += xhr.responseText - 0;
            }

            var msg = "result ---> " + result;
            appendMessage(msg);
        }

        </script>
    </head>

    <body>
        <div>
            <input type="text" id="msg"/>
            <button id="add">add</button>
        </div>
        <div style="margin-top:10px;">
            <button id="start">start</button>
        </div>
        <div id="messages"></div>
    </body>
</html>

画面上には、入力テキストが1つ、ボタンが2つ配置されます。
addボタンは、入力テキストの内容を"messages"というidのDIV要素に追加します。同期処理が画面操作をブロックしてしまうことを確認するために用意しました。
startボタンを押下すると、冒頭に書いたようにサーバサイドの計算処理を呼び出します。XMLHttpRequestを利用して、同期通信を行って結果を得ています。
以下に実行結果のイメージを示します。

f:id:IntelligentTechnology:20130719121542p:plain

startボタンを押してから結果が表示されるまでの間(このサンプルでは6秒程度)、addボタンを押しても画面にはメッセージが反映されません。つまり、ページが固まってしまったように見えます。これが同期処理の問題点です。ですから、サーバへのリクエスト送信のような処理は、同期処理で書くべきではないということがわかります。

なお、ブロック中にaddボタンを押していて、サーバからの応答が戻った後の挙動は、ブラウザごとに異なりました。
ChormeやIE10では、addボタン押下のイベントは認識され、画面に反映されました。一方、Firefoxでは、ボタン押下イベントそのものが無視されました。

非同期処理のサンプル

次に、非同期処理に書き換えたサンプルを示します。
calc02関数を追加し、calcStart関数からはこれを呼び出すようにします。

function calc02() {
    var result = 0;
    var xhr = new XMLHttpRequest();

    xhr.open("GET", "sample1.php");  // 第3引数を指定せず非同期通信に
    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                result += xhr.responseText - 0;
            }

            xhr.open("GET", "sample2.php");  // 第3引数を指定せず非同期通信に
            xhr.onreadystatechange = function() {
                if (xhr.readyState == 4) {
                    if (xhr.status == 200) {
                        result += xhr.responseText - 0;

                        var msg = "result ---> " + result;
                        appendMessage(msg);
                    }
                }
            }
            xhr.send(null);
        }
    }
    xhr.send(null);
}

以下に実行結果のイメージを示します。

f:id:IntelligentTechnology:20130719122101p:plain

サーバへのリクエスト送信を非同期にすることで、リクエストが返される前でも、addボタン押下で即座にメッセージがページに反映されます。
非同期処理に置き換えたことで、最初のリクエスト送信のコールバック関数内に、次のリクエスト送信が記述されるようになりました。このように非同期処理では、プログラムを見たときに、処理の流れ(どのような順序で処理されるか)がわかりにくくなる傾向があります。

なお、calcStart関数内で終了ログ("calc02 end")を出力していますが、実際には、サーバ処理が終わる前に出力されてしまいます。本来は、意図したようにログ出力されるよう修正すべきですが、今回はそのままにしておきます。

jQuery.Deferredを使った非同期処理サンプル

jQuery.Deferredを使って、非同期処理を書き換えてみます。

function calc03() {
    calc03_sub1().then( calc03_sub2 ).done( calc03_sub3 );
}

function calc03_sub1() {
    var d = new $.Deferred;

    var result = 0;
    var xhr = new XMLHttpRequest();

    xhr.open("GET", "sample1.php");  // 第3引数を指定せず非同期
    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                result += xhr.responseText - 0;
                d.resolve(result);
            }
        }
    }
    xhr.send(null);

    return d.promise();
}

function calc03_sub2(result) {
    var d = new $.Deferred;

    var xhr = new XMLHttpRequest();

    xhr.open("GET", "sample2.php");  // 第3引数を指定せず非同期
    xhr.onreadystatechange = function() {
        if (xhr.readyState == 4) {
            if (xhr.status == 200) {
                result += xhr.responseText - 0;
                d.resolve(result);
            }
        }
    }
    xhr.send(null);

    return d.promise();
}

function calc03_sub3(result) {
    var msg = "result ---> " + result;
    appendMessage(msg);
}


プログラム自体はだいぶ長くなってしまいましたし、慣れない方にはわかりにくくなったと感じられるかもしれませんが、非同期処理のコールバックが入れ子になっていく問題は解消されました。
calc03_sub1関数とcalc03_sub2関数が、それぞれサーバにリクエスト送信する関数になっています。
まず、calc03_sub1関数では、Deferredを生成した後で、サーバにリクエストを送信しています。Deferredオブジェクトは、「unresolved」「resolved」「rejected」のいずれかの状態を持ちます。初期状態は「unresolved」です。
また、Deferredオブジェクトには、コールバック関数を登録することができます。登録のためのメソッドには、thenやdoneがあります。上の例では、calc03関数内で、calc03_sub2関数を登録しています。このコールバック関数は、Deferredオブジェクトの状態が「resolved」や「rejected」に変化すると呼び出されます。
そして、Deferredオブジェクトの状態を変化させるのが、resolveメソッドやrejectメソッドです。calc03_sub1関数内のコールバック関数内でこれを呼び出すことで、コールバックとして登録したcalc03_sub2関数が呼び出されます。なお、resolveメソッドにサーバから返された計算結果を渡していますが、このようにすると、calc03_sub2関数にも計算結果が渡されます。
calc03_sub1関数は、生成したDeferredオブジェクトのpromiseメソッドが返すPromiseオブジェクトを返します。Promiseオブジェクトは、Deferredオブジェクトから内部状態を変更するためのメソッドを削除したものです。つまり、calc03関数では、resolveメソッドやrejectメソッドを呼ぶことができないようにしています。

thenメソッドは、内部で新しいDeferredオブジェクトを生成し、そのPromiseを返します。このPromiseは、calc03_sub2関数が返すPromiseが基準となります。つまり、calc03_sub2関数が返すPromiseが「resolved」に変化したとき、登録されたコールバック(この例だとcalc03_sub3関数)を呼び出します。よって、calc03_sub1→サーバから応答→calc03_sub2→サーバから応答→calc03_sub3の順番に実行されます。
なお、このthenメソッドの仕様は、jQuery 1.8以降に適用されるものです。jQuery 1.8より前のバージョンでは、pipeメソッドがこのDeferredオブジェクトのチェーンを実現していました。jQuery 1.8以降、pipeメソッドは、Deprecatedとなっています。jQuery 1.8より前のバージョンのthenメソッドでは、calc03_sub1関数が返すPromiseが基準となるため、calc03_sub2関数がサーバにリクエストを送信した直後に、calc03_sub3関数を呼び出します。つまり、calc03_sub1→サーバから応答→calc03_sub2→calc03_sub3→サーバから応答、という処理順序になり正しい結果が得られません。

なお、関数名のネーミングを工夫すると、見通しがさらによくなります。例えば、以下のように関数名を変更します。

  • calc03_sub1 → calc03_get_sample1
  • calc03_sub2 → calc03_add_sample2
  • calc03_sub3 → appendResult

そうすると、calc03は以下のように書き換えられます。

function calc03() {
    calc03_get_sample1().then( calc03_add_sample2 ).done( appendResult );
}

jQuery.ajax関数を使った非同期処理サンプル

jQuery.ajax関数を使うと、以下のように短く記述することができます。
(以下では、ajax関数のラッパーであるgetを使っています。)

function () {
    var result = 0;
    $.get("sample1.php")
     .done( function(data, textStatus, jqXHR) { result += data - 0; } )
     .then( function() { return $.get("sample2.php"); } )
     .done( function(data, textStatus, jqXHR) { result += data - 0; } )
     .done( function() { appendMessage("result ---> " + result); } );
}

jQuery.get関数は、Promiseインターフェースを実装したオブジェクト(「The jQuery XMLHttpRequest (jqXHR) 」)を返します。※jQuery 1.5.0 以降
つまり、1つ前のサンプルでいうcalc03_sub1関数に相当すると考えればよいと思います。このように、Deferred(≒Promise)の状態管理は、jQuery.get関数内で行われるので、resolveメソッドやrejectメソッドを自ら利用する機会は少ないかもしれません。
なお、上記サンプルのthenメソッドを、doneメソッドに書き換えると、2番目のサーバリクエストが送信された直後、以降の処理が動いてしまいます。(sample1.phpが返す計算結果の100が2回resultに足されて、200が計算結果として表示されます。)

上の例では、resultをcalc04関数のローカル変数として定義し、各コールバックでその変数を参照するようにしましたが、以下のように書けば、ローカル変数を用意することなく記述することができます。

function calc04() {
    return $.get("sample1.php").then(function(data1) {
        return $.get("sample2.php").then(function(data2) { return ~~data1 + ~~data2; });
    }).then(appendResult);
}

非同期処理を並列実行するサンプル

これまでは、sample1.php→sample2.phpの順番でサーバにリクエストして、2つの結果を加算していました。これを同時にリクエストして、両方からレスポンスが得られたら結果を表示するように書き換えてみます。
jQuery.when関数を使えば、簡単に記述することができますが、まずはこれを使わないサンプルを示します。

function calc05() {
    calc05_parallel().done(function(result) {
        var msg = "result ---> " + result;
        appendMessage(msg);
    });
}

function calc05_parallel() {
    var d = new $.Deferred;

    // 同時にリクエストを送信
    var p1 = $.get("sample1.php");
    var p2 = $.get("sample2.php");

    // 両方からの応答があったらresolveメソッドを呼び出すようにする
    var total = 0;
    var resolvedCount = 0;
    var callback = function(result) {
        total += ~~result;
        resolvedCount++;
        if (resolvedCount >= 2) {
            d.resolve(total);
        }
    }
    p1.then(callback);
    p2.then(callback);

    return d.promise();
}

2つのサーバリクエストの完了を待ち合わせするためのDeferredを作成するようにしています。このような「待ち合わせ」を実現してくれるのがjQuery.when関数です。
jQuery.when関数を利用したサンプルを以下に示します。

function calc06() {
    $.when($.ajax("sample1.php"), $.ajax("sample2.php"))
    .done(function(a1, a2) {
        var result = (a1[0] - 0) + (a2[0] - 0);
        var msg = "result ---> " + result;
        appendMessage(msg);
    });
}

なお、コールバック関数に渡される引数(a1、a2)には、jQuery.ajax関数のコールバック関数に渡される引数が配列([ data, statusText, jqXHR ])で格納されています。


サンプルは以上となります。


Promiseパターンに慣れるまでは、むしろ難しくなったと感じられるかもしれません。私自身混乱してしまうことが多々あります。それでも、Promiseパターンは広く利用されるようになっていると思いますので、理解するメリットはそれなりに大きいと思います。