読者です 読者をやめる 読者になる 読者になる

素人がプログラミングを勉強していたブログ

プログラミング、セキュリティ、英語、Webなどのブログ since 2008

連絡先: すかいぷ:javascripter_  か javascripter あっと tsukkun.net skypeのほうがいいです

Deferred/Promisesと非同期処理

概念/仕組み

Deferred/Promisesは非同期処理を簡単にするための取り決め。 callback hellと呼ばれているような、非同期処理によるコールバックのネストを軽減することができて非常に便利。 慣れれば便利だが、一見どう使えばいいのか分かりづらいので、少し解説を書く。 ここではPromises/Aという仕様を実装したQというライブラリを使うが、jQuery.Deferredなどもほぼ同じである。 まず、Deferred/Promisesは、関数のreturn、try/catchの非同期版である。 www.example.comの内容を取得し、正しく取得できたら中身を表示し、取得できなければエラーを表示する、というプログラムは 同期的なコードでは、

function get() {
  var req = new XMLHttpRequest();
  req.open('GET', 'http://www.example.com/', true); // 同期的
  req.send(null);
  if (req.status == 200) {
    return req.responseText;
  } else {
    throw new Error('取得できませんでした');  
  }
}

try {
  alert(get());
} catch (e) {
  alert(e.message);
}

のようになる。

これを、Deferred/Promisesを使って書くと、関数は

function aget() {
  var req = new XMLHttpRequest();
  req.open('GET', 'http://www.example.com/', false); // 非同期的
  req.send(null);

  var d = Q.defer(); // あとで値を返すためのdeferredを作る
  req.onload = function () {
    if (req.status == 200) {
      d.resolve(req.responseText);
    } else {
      d.reject(new Error('取得できませんでした'));
    }
  };

  return d.promise; // responseTextを返すあるいはエラーになるpromiseを返す
}

のようになる。ここで、同期コードではget()がreq.responseTextを返していたが、aget()ではまだrequestが完了していないので、returnすることも、throwすることもできない。なので、完了時に値を渡すあるいはエラーを通知しますよ、という約束を表すpromiseというオブジェクトを返すことになる。 promiseはPromise#then(fulfilled, rejected)というメソッドを持ち、第一引数に、成功時(すなわち非同期にreturnされた値を使う部分)に対応する関数を渡し、第二引数に、失敗時(非同期にthrowされたエラーをcatchする部分)の関数を渡す。これを使うと、さきほどの同期コードのtry/catch部分は、

var p = aget();
p.then(
  function fulfilled(responseText) {
    alert(responseText);
  },

  function rejected(e) {
    alert(e.message);
  }
);

のように書ける。では、ager内で使用しているDeferredとは何か。Deferredは、promiseに非同期にreturnやthrowを通知するオブジェクトである。Deferred#resolveは、非同期のreturnに相当し、Promise#thenで登録されたfulfilledを実行する。Deferred#rejectは、throwに相当し、Promise#thenで登録されたreject関数を実行する。Promiseは関数の値とthrowされたエラーを表すオブジェクトであり、Promiseはその値を受け取るための仕組みなのである。 ここで、Promise#thenについて考える。thenで値を返したらどうやって受け取ればいいだろうか。

aget().then(function fulfilled(responseText) {
  return responseText.length; // responseTextの文字数
});

responseTextの文字数は、非同期にしか受け取る事のできない値であり、失敗する可能性がある。つまり、これもDeferred/Promisesで表現すべきである。実際、Promise#then()は新しいpromiseを返す。 よって、

aget().then(function fulfilled(responseText) {
  return responseText.length; // responseTextの文字数
}) // ここで返ってくるのはlengthを返す/エラーを伝播するpromise
  .then(function fulfilled(length) {
    alert(length);
  },

  function rejected(e.message) {
    alert(e.message);
  });

aget().thenでreject時に非同期throwされたエラーをcatchする関数を書かなかったので、そのまま次のpromiseにエラーがbubblingしたことに気づいただろうか。これは同期版での

function getLength() {
  var responseText = get();
}

try {
  var length = getLength();
  alert(length);
} catch (e) {
  alert(e.message);
}

にきれいに対応している。

chaining

chaining、複数の非同期動作を組み合わせたい場合は、thenのfulfilled/rejectedのreturn時にpromiseを返せばよい。 例えば、レスポンスを受け取ってから1秒待ってからそれを表示する場合は、

function delay(ms) {
  var d = Q.defer();
  setTimeout(d.resolve, ms);
  return d.promise;
}
aget().then(function (responseText) {
  return delay(1000).then(function () {
    alert(responseText);
  });
});

と書けばよい。

aget().then(function () { return delay(1000); }).then;

としてつなげようとすると、delay(1000).thenはresponseTextを返さないので駄目。

まとめ

ここまでわかればだいたいどういう風に実装できるのか、parallelっぽいのはどう作ればいいのかが分かると思う。一般的には

when(promise1, promsie2, promise3).then(function (values) {

});

というものが提供される事が多い。

promiseは、非同期であることをマークしそれを伝播させていくことで成り立っている。ある意味、関数自体を非同期に拡張したようなものであり、一度Deferred/Promisesに入ると抜けられない点で、HaskellのIOなどにも似ている。 その点で、Node.jsでよく使われているasync.jsなどのコールバックをまとめるためのユーティリティとは少し性質が違う。

promiseは値/エラーを扱えるという性質上、ES6のgeneratorを使った非同期を同期っぽく見せる仕組みとも相性が良い。 task.jsというMozillaによるライブラリを使うと、ES6のgeneratorが導入されると

spawn(function*() {
    try {
        var [foo, bar] = yield join(read("foo.json"),
                                    read("bar.json")).timeout(1000);
        render(foo);
        render(bar);
    } catch (e) {
        console.log("read failed: " + e);
    }
});

と書けるようになる。

補足/制約

非同期による処理は大きくわけてコールバックとイベントの二種類があるが、Deferred/Promisesはこれらすべてを抽象化するわけではない。 Deferred/Promises自体は単なる非同期の値/エラーの仕組みであり複雑なフローを表すものではないので、イベントを含む、callbackが何度も呼ばれるような場合や、Deferred/Promisesのキャンセルなどに対するサポートはあまりない。 例えば、setInterval(fn, ms)や$('body).on('click', fn)などは、上記の仕組みだけではあまりうまく実装できない。

jQuery.Deferredなどは.then(resolved, rejected, progressed)というように、もう一つprogressedという引数を受け取るようになっていて、.notify(value)を何度も呼ぶことにより、progressedに何度も値を渡す事ができるので、これを使うと多少は改善される。下記に$.Deferredを使い、jQuery#onをdeferredにする例を示す。三回bodyをclickするとdoneと表示する。

$.fn.$on = function (type, selector) {
  var d = $.Deferred();
  function handler(event) {
    d.notify(event);
  }
  if (selector) {
    this.on(type, selector, handler);
  } else {
    this.on(type, handler);
  }
  return d.promise({
    off: $.proxy(function () {
      d.resolve();
      if (selector) {
        this.off(type, selector, handler);
      } else {
        this.off(type, handler);
      }
    }, this)
  });
};

var i = 0;
var click = $('body').$on('click');

click.progress(function (event) {
  i++;
  alert(i);
  if (i == 3) {
    click.off();
  }
}).done(function () {
  alert('done');
});

参考