開発者ブログ
自習室 » 非同期処理とPromise(Deferred)を背景から理解しよう

非同期処理とPromise(Deferred)を背景から理解しよう

Last modified by simdy on 2014/05/16, 14:37

サーバーとの通信、HTML5のAPIを使ったアプリケーションの開発に必要不可欠な「非同期処理」について学びましょう。


非同期処理とは

背景

Webブラウザは基本的に、JavaScriptコードを実行するとき、コードを上から順に1行ずつ実行します。
また、関数を呼び出すと、その関数の実行が終了するまで(return文によって呼び出し元の関数に戻ってくるまで)次の行には進みません。
さらに、JavaScriptコード実行中は他の処理は待ち状態になります。たとえば、マウスクリックイベントをイベントハンドラで処理している間にユーザーがキー入力を行っても、画面に入力した文字は反映されず、キー入力に対するイベントハンドラも実行されません(マウスクリックのハンドラの実行が終わった後に実行されます)。

では、処理の途中に、実行に非常に時間がかかる処理が含まれている場合、どうなるでしょうか。

function search() {
 var result = heavyProcess(); //heavyProcess関数は、呼び出すと結果を返すのに10秒かかるとする
 return result;
}

上記のコードでsearch関数を呼び出すと、内部でheavyProcess関数を実行している間中、つまり約10秒間、ブラウザはマウス操作もキー入力も受け付けず固まってしまいます。

これでは、ユーザーから見ると頻繁にブラウザが固まってしまって見え、使い勝手が悪く、不安定なアプリケーションだと思われてしまいます。

  • 通常の場合
    (各関数は十分短い時間で実行が完了し、ブラウザは次のイベントのために待機している)
    Timeline1.png
  • JavaScriptコードの実行が長い場合
    (コード実行中は他の処理が行えないため、イベントハンドラAの実行中にイベントBが発生しても、それに対応するイベントハンドラBはすぐには実行されない)
    Timeline2.png

現実のアプリケーションでも、たとえばサーバーと通信を行うと、リクエストを送ってからレスポンス(結果)が返ってくるまで数秒以上かかることはしばしば起こります。

処理の「非同期」化

上で説明したように、WebブラウザはJavaScriptコードを上から順に1行ずつ実行します。

そこで、「ある関数が呼び出されたとき、戻り値として本来渡したい結果を返すのではなく、一度関数としては終了し(=呼び出し元に戻る)、後で『本来渡したかった値』を返せる状態になったときに、呼び出し元にその値を通知する」という仕組みが考え出されました。

このような仕組みを、これから「非同期処理パターン」あるいは「処理の非同期化」のように呼びます。

通常、関数を呼び出すと、戻り値にはその関数の結果(本質的にその関数が返したい値)が返ってきます。このような関数の呼び出し方を「同期呼び出し」と言います。非同期処理パターンを用いた呼び出し方は「非同期呼び出し」と言います。

この仕組みを用いることで、時間のかかるサーバーとの通信中でも、ブラウザは他のイベントハンドラを実行したり、画面を書き換えたりすることができるようになります。

HTML5の関連仕様として定義されているAPIでは、このような非同期処理パターンで定義されているものが数多く存在します。
非同期処理パターンを使いこなせるようになることは、複雑なWebアプリケーションクライアントを実装する上では非常に重要です。
初めて学ぶ方には少し難しいかもしれませんが、頑張って少しずつでも身につけていきましょう。

サーバーとの通信を例に考えてみよう

サーバーと通信する関数を例に考えてみましょう。
同期的呼び出しと非同期的呼び出しを図にすると、以下のようになります。

同期的に動作する場合、サーバにリクエストを送ってからレスポンスが返ってくるまで、ブラウザは他の処理を行うことができません。
一方非同期的に動作させた場合、リクエストを送った後JavaScriptコードは一旦終了し、レスポンスが返ってくるまでの間、ブラウザは別の操作の受付や処理を実行できます。

  • 同期的に通信を行う場合
    sequenceSync.png
  • 非同期的に行う場合
    sequenceAsync.png

Promiseパターン

それでは、具体的にはどのようにして非同期処理パターンをコード化すればよいのでしょうか。
ここでは、非同期処理パターンを実現する手法の一つ、「Promiseパターン(プロミス―)」を説明します。

hifiveでは、非同期処理パターンが必要となる場所では基本的にこのPromiseパターンを採用しています。
また、開発者自身が非同期処理を記述する場合にもこのパターンで統一することを推奨しており、そのためのAPIを提供しています。

基本的な考え方

Promiseパターンの基本的な考え方は「非同期的に動作する関数は、本来返したい戻り値の代わりに『プロミスオブジェクト』という特別なオブジェクトを返しておき、(本来返したい)値を渡せる状態になったら、そのプロミスオブジェクトを通して呼び出し元に値を渡す」というものです。

Promiseパターンのイメージ
英語の"promise"は、日本語では「契約」や「約束」などと訳されます。

「私(=非同期的に値を返す関数)は確かにあなた(=呼び出し元の関数)に呼ばれたので、呼ばれた証(=プロミスオブジェクト)を返しておきます。結果は、計算が終了し次第、その証を持っている人(=呼び出し元の関数)にお知らせします」
というイメージです。
食堂やクリーニング屋さんなどに行ったとき、お金を払って一旦引き換えチケットをもらい、後で商品を取りに行く、という状況をイメージするとわかりやすいでしょう。

コードの基本形

(1)呼び出した関数がPromiseパターンに従っている場合

hifiveが提供するAPIの中には、Promiseパターンを用いているものがあります。
例えば、サーバと通信を行うh5.ajax()関数がその一つです。

このような関数は、呼び出すとプロミスオブジェクト(以下適宜「プロミス」と省略)を返します。
呼び出した関数の(本当に受け取りたい)結果はプロミスを介して関数呼び出しの形で渡されます。
以下のコード例のように、プロミスのdone()メソッドを使用して関数を登録しておきます。

このような、done()メソッドの引数に渡す関数のことを、以降省略して「doneハンドラ」と呼ぶことにします。
function search() {
 var promise = h5.ajax('/getdata'); //h5.ajax()はプロミスオブジェクトを返す

 //promiseのdone()を呼び出して、「正常終了時に戻り値を受け取る関数」を登録する。
 promise.done(function(result){
   //この関数(この中のコード)は、サーバーからレスポンスが返ってくると自動的に呼び出される。
   //レスポンス内容は引数に入ってくる。
 });
}

(2)自分で作成する関数でPromiseパターンを利用したい場合

(1)では、呼び出した関数からプロミスオブジェクトが返された場合に、結果を受け取る方法を示しました。
次に、自分で作成する関数でPromiseパターンを利用する方法を説明します。

ある関数でPromiseパターンを利用したい場合、始めに「Deferred(ディファード)オブジェクト」を作成し、そのディファードオブジェクトから、対応するプロミスオブジェクトを生成します。
そして、そのディファードオブジェクトのresolveメソッドを呼び出すことで、処理の完了を呼び出し元に通知し、結果の値を渡すことができます。
(具体的には、resolveメソッドを呼ぶと、対応するプロミスオブジェクトのdoneハンドラが実行されます。)
resolveメソッドを引数付きで呼ぶと、その引数の値はそのままdoneハンドラの引数として渡されます。

以下は、hifiveのロジック定義の関数でPromiseパターンを使う場合の例です。

var sampleLogic = {
  __name: 'sample.SampleLogic',

  doAsync: function() {
   //hifiveのロジック定義内で、deferredメソッドを呼んで、「Deferred(ディファード)オブジェクト」を作成する
   var dfd = this.deferred();

   var calledDate = new Date();

   //setTimeoutは、第2引数で指定したミリ秒後に第1引数の関数を呼び出すタイマー関数。
   //setTimeoutの呼び出し自体はすぐに完了する(ので、次のvar promise = ...の行に処理が移る)
   setTimeout(function(){
     //本当に返したい値はこのresultObject
     var resultObject = {
        date: calledDate
      };

     //本当に渡したかった値が得られたら、ディファードオブジェクトのresolveメソッドを呼ぶ。
     //(本当に渡したかった)値は引数に渡す。
     //このresolveメソッドを呼ぶと、ディファードに対応するプロミスのdoneメソッドで登録した関数が呼ばれる
     dfd.resolve(resultObject);
    }, 1000);

   //ディファードオブジェクトは、このディファードに紐づくプロミスオブジェクトを生成するpromiseメソッドを持つ。
   //このメソッドを呼び出し、呼び出し元にプロミスオブジェクトを返しておく
   var promise = dfd.promise();
   return promise;
  }
}

この関数を呼び出す方のコードは、(1)と同様です。

var sampleController = {
  __name: 'sample.SampleController',

  sampleLogic: sample.SampleLogic,

 '#btn click': function(context, $el) {
   var promise = this.sampleLogic.doAsync(); //doAsyncメソッドはプロミスを返す

   //プロミスのdoneメソッドで、結果を受け取る関数(doneハンドラ)を登録する
   promise.done(function(result){
      alert(result.date.toString());  //ボタンをクリックした後1秒後にダイアログが表示される
   });
  }
}

ロジック定義・コントローラ定義以外の場所でPromiseパターンを使いたい場合
hifiveのロジックおよびコントローラでは、this.deferred()と呼び出すことでディファードオブジェクトを生成でき、Promiseパターンを使うことができます。

これ以外の任意の場所でPromiseパターンを使いたい場合は、h5.async.deferred()関数を使用します。
この関数を呼び出すとディファードオブジェクトが生成されます。後の使い方は、ロジックの場合と同様です。

非同期処理の「失敗」の扱い方

JavaやC#など、多くの言語は「例外」という仕組みを持ち、実行中の処理が継続不能になった場合に処理を中断して呼び出し元にエラーの発生を通知することができます。(try-catch-finallyの構文をご存知の方も多いでしょう。)
JavaScriptにも、他の言語と同様、try-catch-finallyによる例外機構は存在します。

sequence-sync.png

しかし、上で説明したように、非同期処理の場合関数の呼び出し自体は終了してしまっているため例外を出して呼び出し元にエラーを通知することはできません。
(注:promiseをreturn文で返す前にエラーが発生した場合に、例外をスローすることは可能です。)
sequence3.png

そこでPromiseパターンでは、非同期的に呼び出された関数が呼び出し元に「処理が失敗したこと」を通知する&受け取る仕組みを持っています。
具体的には、正常終了の場合と同様、プロミスオブジェクトを通して通知します。

(1)呼び出した関数の失敗通知を受け取る

プロミスオブジェクトは(doneの他に)failというメソッドを持っており、doneと同じように引数に関数を渡しておくと、非同期処理失敗時に通知を受け取ることができるようになります。

以下にコード例を示します。

function search() {
 var promise = h5.ajax('/getdata'); //h5.ajax()はプロミスオブジェクトを返す

 //promiseのdone()を呼び出して、「正常終了時に戻り値を受け取る関数」を登録する。
 promise.done(function(result){
   //この関数(この中のコード)は、サーバーからレスポンスが返ってくると自動的に呼び出される。
   //レスポンス内容は引数に入ってくる。
 });

 //promiseのfail()を呼び出して、「処理失敗の通知を受け取る関数」を登録する。
 promise.fail(function(error) {
   //この中のコードは、呼び出した関数が「失敗」した場合に呼び出される。
   //引数には、エラーの詳細情報が渡されることが多い(何も渡されない場合もある)。
 });
}

(2)自分が作成する非同期処理関数で失敗を通知する

ディファードオブジェクトは(resolveメソッドの他に)rejectメソッドを持っています。
この関数を呼び出すと、ディファードおよびそれに紐づいたプロミスオブジェクトの状態が「rejected(拒否・却下)」状態になり、プロミスオブジェクトのfailメソッドで登録された関数が実行されます。

以下にコード例を示します。

var sampleLogic = {
  __name: 'sample.SampleLogic',

  doAsync: function() {
   //hifiveのロジック定義内で、deferredメソッドを呼んで、「Deferred(ディファード)オブジェクト」を作成する
   var dfd = this.deferred();

   var calledDate = new Date();

   //setTimeoutは、第2引数で指定したミリ秒後に第1引数の関数を呼び出すタイマー関数。
   //setTimeoutの呼び出し自体はすぐに完了する(ので、次のvar promise = ...の行に処理が移る)
   setTimeout(function(){
     try {
       var result = unstableProcess();  //unstableProcess()は、正しく値を返したり例外を発生させたりする
       dfd.resolve(result);
      }
     catch(error) {
       //unstableProcess()が例外を発生させた場合はこの中のコードが実行される。
       //ディファードのrejectメソッドを呼び出して、この関数(doAsync)の呼び出し元に処理の失敗を通知する。
       //reject()に渡した引数は、fail()で登録した関数にそのまま引数として渡される。
       //なお、引数を渡さなくても失敗は通知できるが、どのようなエラーが起きたのかを
       //呼び出し元がわかるよう、エラーの詳細を示したオブジェクトなどを渡すことを推奨する。
       dfd.reject(error);
      }
    }, 1000);

   //ディファードオブジェクトは、このディファードに紐づくプロミスオブジェクトを生成するpromiseメソッドを持つ。
   //このメソッドを呼び出し、呼び出し元にプロミスオブジェクトを返しておく
   var promise = dfd.promise();
   return promise;
  }
}

複数の非同期処理をつなげて順番に行う

上では、ある1つの処理(関数)が非同期処理になっている場合の使い方を説明しました。

以下では、複数の非同期処理を順番に行う方法を説明します。
ここで言う「順番に」とは、1つ目の非同期処理が完了した後に2つ目の非同期処理を行う、という意味です。

例えば、それぞれがPromiseパターンで非同期化されている関数func1とfunc2があるとします。
それぞれの関数はプロミスを返し、後で本当の結果を返してきます。

この2つを順番に実行したい場合は、以下のように、プロミスのthen関数を使用します。

function process() {
 //まずは、func1を実行し、func1の呼び出しに対応するプロミスを得る
 var promise1 = func1();

 //promise1.then()を呼ぶ。この時、thenの引数に(同じくPromiseパターンで非同期化されている)func2を渡す。
 //こうすると、func1の実行が完了した「後」にfunc2の実行が始まる。
 //また、then()関数自身もプロミスを返す。ただし、このプロミスは
 //引数で与えられたfunc2の実行が完了したときに状態遷移するものである。
 var promiseThen = promise1.then(func2);

  promiseThen.done(function(result2){
   //この中のコードは、func2が完了したときに実行される。引数で渡される値も、func2の(本来の)値である。
 });
}

複数の非同期処理を並列に実行して、全てが完了したら最終処理を行う

次は、複数の非同期処理を並列に実行する方法を説明します。
特に、単純に並列に呼ぶだけでなく、すべての(並列に)実行した非同期処理関数が完了したタイミングを取得する方法を説明します。
ここで言う「並列に」とは、指定した複数の非同期処理関数について、各関数の完了を待たずに実行を開始することを言います。

順番に実行する例と同様、それぞれがPromiseパターンで非同期化されている関数func1とfunc2があるとします。

この2つを並列に実行し、その両方が完了したタイミングで処理を行いたい場合は、以下のように、h5.async.when()関数を使用します。

function process() {
 //まずは、func1とfunc2を実行し、それぞれの呼び出しに対応するプロミスを得る
 var promise1 = func1();
 var promise2 = func2();

 //when関数を呼ぶと、「2つのプロミスが両方とも完了」したタイミングで完了する、新たなプロミスが返される
 var promiseWhen = h5.async.when(promise1, promise2);

  promiseWhen.done(function(result1Array, result2Array){
   //この中のコードは、func1とfunc2両方が完了したときに実行される。
   //各関数の値は、配列化されて引数に渡される(戻り値の順番は、whenを呼んだ時のpromiseの順番と同じ)。
 });
}

Copyright (C) 2012-2017 NS Solutions Corporation, All Rights Reserved.