BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル 仮想パネル: JavaScriptで非同期プログラミングを乗り切る方法

仮想パネル: JavaScriptで非同期プログラミングを乗り切る方法

ブックマーク

原文(投稿日:2011/01/09)へのリンク

プログラマはものごとを順番にやるようなアルゴリズムを書いて、シーケンシャルにプログラミングするのを当然のことだと思っている。

しかしJavaScriptでブロッキングI/Oや時間を要する操作のあるコードを書いているなら、シーケンシャルなコードというのは問題外だ。システムで唯一のスレッドをブロックするのは、非常にまずいことだからだ。これを解決するには非同期コールバックを使ってアルゴリズムを実装すればよい。つまり、シーケンシャルなコードを複数のコールバックに展開するのだ。

これで問題は解決と言いたいところだが、これはシーケンシャルなアルゴリズムが書けなくなることを意味している。重要なシーケンシャルなコードを書く代わりに、コールバックのグラフを書くことになる。

非同期性を多用している大規模アプリケーションでは、これはさらに重要になる。コールバック渡しを使っても、うまい具合に非同期アクションを組み立てられず、戻り値を処理するためにコールバックをたらい回しにするという複雑なフローになるおそれがある。

JavaScriptコミュニティ、特にNode.jsコミュニティは、このことに気づいている。Node.jsでは非同期コードが重要になるためだ。

これに対して、CommonJSグループPromiseという形でこれに答えている。これは任意の時点で、完了しているかもしれないし完了していないかもしれない、非同期に実行されるアクションの結果を表現したオブジェクトとのインターフェイスを提供する。この方法では、さまざまなコンポーネントが非同期アクションのためのpromiseを返すことができ、コンシューマは予測可能な形でそのpromiseを利用できる。また、Promiseは非同期性を支援するために構文上便利な言語レベル拡張のために利用される基本エンティティを提供することもできる。

Stratified JavaScriptはこれとは別のアプローチをとっており、JavaScript言語のスーパーセットを提供することで、この問題を解決している。しかし、使う言語を切り替えられないのなら、とるべき道はシーケンシャルなコードをエミュレート可能な、柔軟なAPIを使うことだ。そのAPIに簡潔な記法が許されている場合、組み込みDSLと呼ばれることが多い。

InfoQはこうしたAPIとDSLについて調査し、それぞれの作者に問題、設計原則、従うべきパラダムなどにどうアプローチしたのか、そして、それぞれの限界がどこにあるのかについて質問した。

InfoQでは以下の方々にコンタクトした。

InfoQ: あなたのライブラリはどんな問題を解決するのですか? 例えば、お決まりのコードと非同期I/Oの手作業のコールバック処理をなくしてくれるとか、それらをまとめたり、その他の機能を提供してくれるとか(例えば、複数のI/O呼び出しを発行して、それらが完了するのを待つのに役立つなど)

Tim氏 (Step): Stepが目標にしているのは、お決まりのコードをなくすこと、そして、非同期コードの可読性を高めることです。これは極めて最小限のもので、いずれも大量のtry..catchブロックとカウンタ変数を使えば手作業でやれることです。Stepが提供するのは、各stepでオプションの並列グループを使ったシリアルなアクションを簡単にチェーンできることです。

Will氏 (Flow-js): Flow-JSは、ほかの言語にある継続(continuation)やファイバ(fiber)のようなものをJavaScriptに提供します。事実上、多段同期ロジックから、いわゆる「ピラミッド」を取り除くのに使えます。コールバック関数リテラルを直接ネストするのではなく、フロー定義における次の関数へのコールバックとして特別な"this"値を使います。

Kris Zyp氏 (node-promise): 問題なのは、通常のコールバックスタイルの実行フロー(継続渡しスタイル)では、関数の入力と出力の処理がまざって、複数の関心事をひとつのインターフェイスに融合していることです。Promiseは計算の最終的な完了をカプセル化することで、関数/メソッドが純粋に入力パラメータで実行されるようにします。その一方で、出力である返されたpromiseが結果を保持します。

ここここで、もう少し詳しく説明しています。

最終的な完了をカプセル化することにより、複雑な条件分岐があるような並列なシリアルなアクションをまとめる場合にも、promiseは非常にうまく機能します。node-promiseライブラリには、これを簡単にする関数が含まれています(promised-ioのall()関数とstep()関数)。

ご参考までに、promised-ioはnode-promiseの代替品の一種です。コアとなるpromiseライブラリは同じですが、promised-ioには、Node.JSのIO関数のpromiseスタイル版と、ブラウザで同様の関数にアクセスするために使うプラットフォーム正規化が含まれています。

Caolan (Async): はい、大部分はお決まりのコードをなくすことです。JavaScriptにおいて、関数を順番に、もしくは、並列に呼び出して、コールバックを待つというコードは、かなり冗長ですが明確です。node.jsが非同期コードを処理する基本手段としてpromise上でのコールバックを採用して間もなく、私は何度も同じパターンを使っていることに気づきました。そこで、明らかにこれらは別のライブラリに抽出するべきだと考えたのです。

以来、依存関係に基づいてコールバックをまとめられるようにするなど、さらに複雑な機能を取り込んで成長してきました。しかし、その大部分において、これはかなり低レベルのライブラリであり、全体の構成については開発者の手に委ねられています。しかし、JavaScriptには関数プログラミングスタイルや、map、reduce、filterといった便利なものの非同期バージョンの追加が適していると思っています。このライブラリはそう使われたときに真の効果を発揮して、継続やpromiseオブジェクトを使わずに従来通りのコールバックが使えるようにします。

Fabian氏 (Async.js): Async.jsはJavaScriptでよく見られる非同期パターンを単純化しようとしたものです。一番の目的は、オブジェクトの一様な集合に一連の非同期関数を適用することです。これは非同期のforEach関数から進化して、その概念を一般化したものです。これはnode.jsの非同期ファイルシステムAPIを扱うときに特に役立ちます。しかし、node.jsに縛られているわけではなく、同じような問題すべてに使えます。async.jsでどんなことができるか、少しお見せしましょう。

async.readdir(__dirname)
   .stat()
   .filter(function(file) {
       return file.stat.isFile()
   })
   .readFile("utf8")
   .each(function(file) {
       console.log(file.data)
   })
   .end(function(err) {
       if (err)
           console.log("ERROR: ", err)
       else
           console.log("DONE")
   })

このコードは現在のディレクトリにあるすべてのアイテムに対して操作するものです。これがオブジェクトの一様な集合になります。各アイテムに対して、一連の非同期オペレーションが実行されます。まずファイルではないアイテム(例えばディレクトリ)をフィルタリングし、次にディスクからそのアイテムを読み出して、コンソールに表示します。すべてのアイテムが処理されると、最後のコールバックが呼び出され、最終的なエラーを表示します。

AJ氏 (FuturesJS): 非同期のイベント駆動プログラミングを論理的に考えるのはやや難しいことです。

私は主に以下を目的としてFuturesを作りました。

  • ブラウザとサーバーサイド(Node.JS)で使うための、単一の非同期コントロールフローのライブラリを提供する
  • コールバックとエラーバック処理に一貫性のあるパターンをもたせる
  • あるイベントが別のイベントに依存するという状況で、アプリケーションのフローをコントールする
  • マッシュアップのように、複数のリソースに関するコールバックを処理する
  • モデルの利用やエラー処理など、すぐれたプログラミング・プラクティスを促す

Futures.futureとFutures.sequenceは、よく見かける大量のお決まりのコードを削減して、多少柔軟性をもたせます。

Futures.joinは、複数のfuturesをjoinしたり(スレッドのjoinがやるのと同様)、(時々発生するイベントを)同期します。

Futures.chainifyは、Twitter Anywhere APIのような非同期モデルの作成を簡単にします。

Isaac氏 (slide-flow-control): slideが解決する問題とは、私がOakJS meetupで話すネタを必要としていたことです。まったく新規のアイデアを思いつきたかったわけではありません。私はほんとに、なまけ者なんです。ほとんど仕事をせずに、目立って、ビールを飲んで、中華料理をつまんで、おもしろい人たちと付き合って、ちょっと注目を浴びて、そうして帰宅したいと、いつも思っているんです。私にとって、ソフトウェア、そして、人生における仕事と報酬の比率は極めて重要なことです。だから、npmで使えて、スライドに収まる、極めて単純な非同期ヘルパー関数を作って、その目的を達成したわけです。そして、それに遠慮して、"slide"という名前をつけて、発表したんです。

slideが解決するもうひとつの問題は、自作のフローコントロールライブラリがとても簡単に書けることをお見せすることです。みんな自分で書いたのが一番好きなんです。だから、基本パターンをいくつか提供して、みんなをクリエイティブにさせるのは筋が通っているでしょう。

InfoQ: そのライブラリにはコンピュータサイエンス研究由来のアイデアを実装しているのですか?

Tim氏 (Step): 直接的にはないですね。

Will氏 (Flow-js): 私が知る限りありませんね。これは私がNode.jsで扱えるビジネスロジックを作るときに使う、最初のスタブにすぎず、外部サービスへの複数の同期呼び出しの世話をするものでした。

Kris Zyp氏 (node-promise): ええ、もちろんです。非同期設計におけるコンピュータサイエンス研究の多くは、関数フローや関心事の分離に最も適切なメカニズムとして、さまざまな形でpromiseを取り上げています。"promise" という言葉はもともとDaniel P. Friedman氏とDavid Wise氏によるもので、1976年にまでさかのぼります。コンピュータサイエンスにおけるpromiseの歴史については、Wikipediaの記事を読むとよいでしょう。

Caolan氏 (Async): 私にはコンピュータサイエンスのバックグラウンドがありません。Asyncライブラリは純粋に実践に基づいて実装していきました。非同期JavaScriptを少し整理するのに高階関数が必要になると、私はそれを何度も使って、ライブラリに取り入れたのです。

Fabian氏 (Async.js): async.jsの実装は、Haskellのモナドに間接的に似ていますが、これは偶然です。

AJ氏 (FuturesJS): はい。最も影響を受けたのは以下からです。

非同期プログラミングで一番よいことは、自然にモジュール化されたコードが書けるようになることです。そして、もし非同期モデルのようなものがあれば、常にパラメータを渡したり、そのモデルの外部にあるモデルに属するデータを渡さないといった原則に従うことを強制することができます。

Isaac (slide-flow-control): いわゆる継続パターンを実装しています。多くのコンピュータサイエンス研究はポイントを見失っていると思ってます。月を指している指のようなものです。そこへ行くにはロケットが必要なのです。真っ直ぐ指している指など役に立ちません。これが完全に理解できたら、深い謎が正体を見せるでしょう。

InfoQ: そのライブラリにはエラー処理戦略というものがありますか? どうやって例外処理とやりとりするのですか?

Tim氏 (Step): 例外が任意のstepで投げられると、それはキャッチされて次のstepにエラーパラメータとして渡されます。また、未定義でない戻り値は次のstepにコールバック値として渡されます。このように、stepは同期にも非同期にも同じシンタックスを使えます。

Will氏 (Flow-js): Flow-JSには組み込みの例外処理はありません。もちろん、これは弱みです。Tim Caswell氏は"Step"というフローに基づいたモジュールを書いています。これは与えられた各関数への呼び出しをtry/catchブロックにラップして、キャッチした例外を順番に次の関数へと渡しています。

Kris Zyp氏 (node-promise): はい、promiseは同期フローと同じものを非同期にも提供するよう設計されています。JavaScript関数がエラーをスローするか、もしくは成功して値を返すのと同様に、promiseも成功した値かエラー状態へと解決します。呼び出し元へ渡されたpromiseは、エラーハンドラがエラーを「キャッチ」するまで、エラーを伝播させます。node-promiseライブラリはこの概念をうまく維持しながら、エラーハンドラを登録したり、(エラーが黙って抑制されるのを避けるため)他でキャッチされるまでエラーを伝播させるのを簡単にします。promiseに直接同期と同じものをもたせることで、コードフローに対する既存の理解でpromiseを論理的に考えやすくしています。

Caolan氏 (Async): 非同期コードにおける例外処理は少々トリッキーです。node.jsなどの非同期環境になじみがなければ、特にそうです。私にとって、どんなスタイルでエラーを処理するかよりも、実際にエラーを処理することの方が重要です。ブラウザの場合、すぐに例外が一番上まであがってきて、そのページのJavaScriptを壊してしまうので、これは特に重要です。

従うべき決まりとして、いろいろなことが言われています。例外処理はわかりやすく、できればなじみがあり、そのため、実装するのが簡単で、忘れてもすぐにわかる必要があります。残念ながら、ブラウザにおけるJavaScriptはあまり役に立ちません。しかし、node.jsはどちらの環境でも使えるシンプルな決まりを提供しています。

Asyncライブラリは、まさにこの、エラーをプログラムの次ステップに渡すために、コールバックの最初の引数を使う、というスタイルを採用しています。もし最初の引数がnull(あるいは他の「偽」な値)であれば、それは無視されます。さもなければ、例外として扱われます。可能であれば、高速化のためにAsyncライブラリ内で実行をショートカットします。もし順番に動作している関数のひとつがそのコールバックにエラーを渡すと、後に続く関数は呼び出されません。

Fabian (Async.js): Async.jsにはnode.jsのエラー処理の決まりを組み込んでいます。すなわち、コールバックの最初の引数にはエラーオブジェクトが保存されます。もし計算が失敗したり、例外がスローされると、エラー/例外がコールバックの最初の引数に渡されます。Async.jsは2つのエラー処理戦略をサポートしていて、API経由で設定可能になっています。エラーが発生したときには、すべての動作をストップしてエラーコールバックを呼び出すか、もしくは、失敗したエレメントをスキップします。

AJ氏 (FuturesJS): 例外は非同期に「スロー」されないため、代わりにユーザーがコールバックの最初の引数として例外を渡すことを推奨しています。

基本となる考え方は、無関係なタイミングでアプリケーションをストップするのではなく、エラーを try {} catch(e) {} して渡すことです。Futures.asyncify()は、大部分が非同期な環境において同期関数を使えるようにしてくれます。

以下に例をあげましょう。

(function () {
  "use strict";

  var Futures = require('futures'),
    doStuffSync,
    doStuff;

  doStuffSync = function () {
    if (2 % Math.floor(Math.random()*11)) {
      throw new Error("Some Error");
    }
    return "Some Data";
  };

  doStuff = Futures.asyncify(doStuffSync);

  doStuff.whenever(function (err, data) {
    if (err) {
      console.log(err);
      return;
    }
    console.log(data);
  });

  doStuff();
  doStuff();
  doStuff();
  doStuff();
}());
  

Isaac氏 (slide-flow-control): スローしません。決してスローしません。スローするのは悪です。スローしてはいけません。決してスローしてはいけません。コールバックが呼ばれると、最初の引数はエラーかnullになります。もしエラーであれば、それを処理するか、それを処理するコールバックに渡します。エラーをコールバックに渡して、エラーが発生したことを知らせましょう。

InfoQ: そのライブラリはF#のWorkflowsやRx(JavaScriptバージョン)など、他のプロジェクトからインスパイアされたり影響を受けましたか?

Tim氏 (Step): ええ、このスタイルはflow-jsから直接インスパイアされたものです。

Will氏 (Flow-js): それはないですね。これが最初に思い浮かんだ解決策でした。

Kris Zyp氏 (node-promise): node-promiseライブラリは、Mark Miller氏のE言語とそのpromiseの使い方、Tyler Close氏のref_sendライブラリ、Kris Kowal氏のQライブラリ、Neil Mix氏のNarrativeJS、TwistedとDojoのDeferred実装、その他ライブラリの影響を受けています。

Caolan氏 (Async): 私はF#やRxを使ったことがないので、これらプロジェクトとの関係にコメントする資格はありませんね。しかし、Underscore.jsからはインスピレーションを受けました。これはJavaScriptのための優れた関数プログラミングライブラリです。Underscore.jsにおいてイテレータを使うほとんどの関数は、Asyncライブラリでコールバックを受け取って非同期に動作するようアップデートされています。

Fabian氏 (Async.js): APIのチェーン文字はjQueryの影響を受けています。私の目標のひとつは、jQueryライクなAPIをnode.jsのファイルシステムモジュールに提供することです。もうひとつ大きな影響を受けたのは、pythonスタイルのジェネレータです。チェーンの各要素が値を生成し、それは続くチェーン要素によって使われます。全体動作はチェーンの最後の要素によってトリガーされます。これはチェーン全体の値を「プルする」ことになります。この意味で、async.jsは、値がソースからプッシュされるjQueryやRxとは違っています。このプルシステムは、すべての値をあとで遅延計算することを可能にし、無限の値(例えば、すべての偶数など)を生成するジェネレータを実現可能にします。

AJ氏 (FuturesJS): いいえ、当初はなかったですね。

私はFacebookとAmazonを使ったマッシュアップのサイトを作っていました。最初の試みは失敗に終わりました。2つのリソースから作られたモデルをどう扱えばよいのか、理解していなかったのです。(実は当時はJavaScriptのことをよく知りませんでした。"WTFJS"を見ながら試行錯誤して、プレーンなDOMを簡単にするのに少しjQueryを使っていた程度です)。

私は、必要なときにデータが存在すると想定して、あるデータセットを非同期に処理するときだけチェーンにあるものをすべてリファクタリングするよりも、どんなデータも必ず取得するのにある程度の時間がかかかると想定した方が簡単だということがわかりました。

私は試してみて、失敗して、いろいろな方法を使って半分は成功しました。それから、幸運なことに、私のローカルなJavaScriptユーザーグループリストの人がCrockford氏のJS講座に触れました。私はそれを全部見て(3章については少なくとも3回は見ました)、非同期プログラミングの「問題」(あるいは、むしろチャンス)をどうすればよいか、ようやく理解できました。そして、Crockfords氏のスライドを見つけて、基本的なポイントとして提供してくれていたpromiseの例を始めました。

そのあと、Node.JSで遊び始めました。それが自分のエラー処理戦略を変えたときです(しかし、数日前までドキュメントには反映されていませんでした)。今度の日曜日にリリースするつもりのFutures 2.0では、ブラウザのためにNode.JSのEventEmitterもバンドルします。

Isaac氏 (slide-flow-control): いいえ。そうですね、コールバックのためにNode.JSで使われるようになったパターンにインスパイアされたかな。私は一貫性にうるさい人間です。というのも、1つ(調子のいい日で、コーヒーがたくさんあれば、おそらく2つ)以上のやり方を覚えられるほど賢くないからです。さもないと、本当に困惑して、トイレだと思ってクローゼットに歩いて、コート全部にXXXの臭いを残して...、ほら、わかるでしょ。

いや。どこでもやっていることです。それだけです。関数は最後の引数としてコールバックをとります。コールバックは最初の引数としてエラーか、うまく動いていればnull/undefinedを受けとります。Slideにはいくつかのヘルパー関数があって、このパターンが簡単に使えます。

InfoQ: そのライブラリを改善できるような、例えば、もっと簡潔にできるなど、JavaScript言語に対する新たな機能や変更というのはありますか?

Tim氏 (Step): 言語のセマンティックスに大きな変更をしない限り、ないでしょうね。coffeescriptのようなプリプロセッサは、シンタックスには役立ちますが、たいていの場合、素のJavaScriptにこだわる方がよいと思っています。

Will氏 (Flow-js): JavaScriptにはRuby 1.9のファイバのようなものが必要だと思っています。次のプロジェクトのために、かなり時間をかけてNode.jsを検討しましたが、いつか非同期プログラミングは頭を混乱させます。これを何とかするツールはたくさんありますが、助けにはなりませんでした。Flow-JSのようなライブラリが多数存在するということは、実際のところJavaScriptは並列プログラミングに向いていないことを示しているように感じています。

Node.jsの目的のひとつはV8 JavaScriptエンジンの修正を回避することでだったのは知っていますが、Asanaの人によると、ファイバを追加するのはそんなに問題はないようです

Kris Zyp氏 (node-promise): はい、単一フレームやshallow continuationsに関する議論があります。ジェネレータのように、分岐やループのフローを複雑にするコールバックを不要にできます。promiseと組み合わせて使うことで、極めてシンプルで読みやすい非同期コードが作れます。

それから、ひとつ言っておきたいことがあります。node-promiseライブラリはhttp://wiki.commonjs.org/wiki/Promises/A仕様も実装しています。つまり、Dojoやおそらく将来のjQueryのpromiseと互換性があります。

Caolan氏 (Async): Asyncライブラリは、言語の大部分がそのままになるよう設計されました。それに合うように、JavaScript上に新しい言語を実装しようとはしていません。

とは言うものの、JavaScript 1.7へのyieldの追加は将来のプロジェクトで興味深い応用があるんじゃないかと思っています。yieldを使えば、Twistedにあるような機能を同期のようなコーディングスタイルでJavaScriptに移植できるかもしれません。私の同僚がWhorlで調べていましたが、今は中断しているようです。

Fabian氏 (Async.js): Mozillaによってサポートされるジェネレータとイテレータの標準化は、async.jsのコードをもっと簡潔にして、こうした問題を単純化してくれます。

AJ氏 (FuturesJS): ライブラリでたくさんコードが繰り返されているのは、同じコードがブラウザとNode.JSで動くようにするためのごまかしです。この問題を解決しようと、いくつかのライブラリ(teleportなど)が出てきているのは知っていますが、まだどれも試していません。もし非同期のrequireが言語に組み込まれれば、間違いなくすばらしいことでしょう。

JavaScriptほど本質的に非同期性を備えた言語は、組み込みのFuturesのようなものを持つべきだと思ってます。

CommonJSはサーバーサイドPromiseの標準化についていくつかの提案をしていますが、彼らはデータプライバシーを重視しています。一方、Futuresはコントロールフロー、開発者の使いやすさ、ブラウザ互換性を重視しています。

Isaac氏 (slide-flow-control): いいえ。私のフローコントロールがベストです。だれも改善なんてできません。これがベストなのは私自身に直接かかわっているからです。だから、外部の影響というのは私にとってはマイナスで、ベストではなくなるのです。もし私と同じ経験をしたいなら、フローコントロールライブラリを書くことをおすすめします。自分で書けばすぐにそれがベストだとわかるでしょう。もし他のライブラリの方がよさそうに見えたなら、大急ぎでエディターに戻って、恥を隠して、すぐに彼らのアイデアを自分自身の少し違ったやり方で再発明しましょう。そうすれば、今やそれがベストなのだと実感できるでしょう。

JavaScriptについて、詳しくはInfoQを見てみましょう。

この記事に星をつける

おすすめ度
スタイル

BT