NHN Cloud Meetup 編集部
ES6のジェネレータを使った非同期プログラミング
2016.04.18
1,188
Javascriptが他の言語と区別される大きな特徴の1つは、シングルスレッドを基盤とする非同期方式の言語であるという点です。このような特徴から、Non-blocking IOを用いたNode.jsの言語として使用され、最近ではサーバーサイドでも大きな人気を博しています。しかし、このような構造的特徴から少なからず欠点もあり、その代表的なものが連続伝達方式(CPS)によるコールバック地獄です。
このコールバック地獄を解決するために、様々な試みがなされてきました。最近はES6のプロミス(Promise)が導入されて、コールバック地獄の問題をかなり緩和できるようになっています。しかし、プロミスはコールバック地獄を解決するために導入されたツールではなく、単に緩和させる方法を提供するだけです。比較的注目を集めているようですが、ES6には非同期プログラミング向けのジェネレータ(Generator)という、さらに重要なツールがあります。
ジェネレータとは?
ジェネレータについては、関連記事(イテレータとジェネレータ)が参考になると思います。ここではジェネレータの詳細スペックは説明しませんので、ジェネレータ(あるいはイテレータ)の概念に不慣れな方は、まず前のリンクをクリックして内容を確認しましょう。
ジェネレータは関数の実行を途中で止めて、必要な時に再開することができます。一種のコルーチン(Coroutine)として見ることができますが、Wikipediaにもあるように、コルーチンとは別の方法で、停止したときに戻る位置を直接指定することができず、単純に呼び出し元に制御を渡します。(そのためセミ-コルーチンと呼ばれる)。以下の例を見てみましょう。
function* myGen() { yield 1; yield 2; yield 3; return 4; } const myItr = myGen(); console.log(myItr.next()); // {value:1, done:false} console.log(myItr.next()); // {value:2, done:false} console.log(myItr.next()); // {value:3, done:false} console.log(myItr.next()); // {value:4, done:true}
myGenジェネレータは実行時にイテレータを返却します。そして、イテレータのnext()関数が呼び出される度に、呼び出される場所の位置を記憶したまま実行され、関数の内部でyieldに会う度に記憶しておいた位置に制御を渡します。このようにnext()
-> yield-> next()-> yield
という循環の流れが作られ、この流れに沿って当該関数が終わるまで(returnに会ったり、最後の行が実行されるまで)進行されます。
ここで重要なことは、next()とyieldが互いにデータを送受信できるという点です。上の例からも分かるように、yieldキーワードの後の値は、next()
関数の戻り値として伝達されます(正確にはvalueプロパティの値で)。では逆に、呼び出し元がジェネレータに値を渡すこともできるでしょうか?もちろん可能です。next()を呼び出すときに引数を渡せば完了します。次の例を見てみよう。
function *myGen() { const x = yield 1; // x = 10 const y = yield (x + 1); // y = 20 const z = yield (y + 2); // z = 30 return x + y + z; } const myItr = myGen(); console.log(myitr.next()); // {value:1, done:false} console.log(myitr.next(10)); // {value:11, done:false} console.log(myitr.next(20)); // {value:22, done:false} console.log(myitr.next(30)); // {value:60, done:true}
next()を呼び出すときに引数として値を指定すると、yieldキーワードがある代入文に値が割り当てられます。このように、ジェネレータと呼び出し者は相互制御だけでなく、データの送受信までできます。ではもう一度myGen内部をのぞいてみましょう。確かに関数の内部ではコールバックもプロミスもありませんが、非同期的にデータをやりとりしながら実行されています。これは何の非同期でしょう?非同期ではなく非同期のコードなのでしょうか?
コールバック地獄召喚
ここで非効率的なコーヒー注文システムを作ってみることにします。まずこのシステムは非常に非効率なので、ユーザーIDを知るためには携帯電話番号が必要で、メールアドレスを知るためにはユーザーIDが必要で、名前を知るためにはメールアドレスが必要で、名前が分からなければ注文ができないように設定します。
function getId(phoneNumber) { /* … */ } function getEmail(id) { /* … */ } function getName(email) { /* … */ } function order(name, menu) { /* … */ } function orderCoffee(phoneNumber) { const id = getId(phoneNumber); const email = getEmail(id); const name = getName(email); const result = order(name, 'coffee'); return result; }
さらに非効率な状況にするため、それぞれのデータを外部ネットワーク上のサーバーから取得すると仮定しましょう。シングルスレッドであるJavaScriptでネットワークを要請するため、このようなコードを組むとコールバック地獄が召喚されます。
function getId(phoneNumber, callback) { /* … */ } function getEmail(id, callback) { /* … */ } function getName(email, callback) { /* … */ } function order(name, menu, callback) { /* … */ } function orderCoffee(phoneNumber, callback) { getId(phoneNumber, function(id) { getEmail(id, function(email) { getName(email, function(name) { order(name, 'coffee', function(result) { callback(result); }); }); }); }); }
コールバックの問題は単にインデントと可読性の問題だけでなく、さらに重要な問題は、コールバック関数を他の関数に渡す瞬間、当該コールバック関数に対する制御権を失うことにあります。つまり、自分が提供したコールバックがいつ実行されるか、何回実行されるかについて信頼できなくなります。上記のコードから分かるように、最初に提供したコールバック関数は限りなく委任され、これによりプログラムの予測が困難になり、エラーが発生しやすくなって、デバッグも難しくなっています。
プロミスの救い
しかしプロミスが登場したことで、これらの問題は大幅に緩和されました。上の例をプロミスで包んでみましょう。まず、すべてのgetXXX関数でコールバックパラメータを削除し、実行結果としてプロミスを返却すると仮定しましょう。
function getId(phoneNumber) { /* … */ } function getEmail(id) { /* … */ } function getName(email) { /* … */ } function order(name, menu) { /* … */ } function orderCoffee(phoneNumber) { return getId(phoneNumber).then(function(id) { return getEmail(id); }).then(function(email) { return getName(email); }).then(function(name) { return order(name, 'coffee'); }); }
可読性が一層高まったように見えます。それだけではなく、当該関数が処理を正常に完了した場合、常にthen()で伝達された関数が一度で実行されるという信頼が生まれました。素晴らしい発展ですね。しかしここで満足せずに、Arrow関数を使ってより洗練させてみましょう。
function orderCoffee(phoneNumber) { return getId(phoneNumber) .then(id => getEmail(id)) .then(email => getName(email)) .then(name => order(name, 'coffee')); }
既存のコールバックを使用したコードと比較すると、はるかに見栄えがよくなりました。
非同期コードを同期コードのよう作成する
ここで、最初に作成したコードを見てみましょう。
function orderCoffee(phoneNumber) { const id = getId(phoneNumber); const email = getEmail(id); const name = getName(email); const result = order(name, 'coffee'); return result; }
そして、もう一度プロミスで作った洗練されたコードを見てみましょう。両者を比較したとき、どのようなコードがより理解しやすいでしょうか?当然のことながら、上のコードがはるかに直感的で分かりやすいですね。しかし冷静に見るとこれは次善策で、それができない理由は先ほど述べたように、JavaScriptがシングルスレッド基盤の言語であるからです。それぞれのネットワーク要請が値を返却するまでプログラム全体が停止して待機しなければならないなら、このプログラムは遅すぎて使用できないでしょう。
ここでジェネレータを活用するとどうなるでしょうか?先ほど見た「非同期ではないが非同期のようなコード」を思い出してみよう。ジェネレータは関数を実行している途中に停止して制御権を別の場所に渡したり、値を伝えることができます。ならば全体のプログラムを停止しなくても、このような方法のコードを作ることができるのではないでしょうか。
試してみましょう。まず、既存の関数の宣言に*を追加してジェネレータに変更し、代入文にyieldを追加してみよう。
function* orderCoffee(phoneNumber) { const id = yield getId(phoneNumber); const email = yield getEmail(id); const name = yield getName(email); const result = yield order(name, 'coffee'); return result; }
望んだ通りになりました。
しかし、yieldを用いて実行を停止して制御権を渡したまでは良かったのですが、getId()がタスクを完了した瞬間、再び戻り値と共に制御権を取得するためには、誰かがイテレータのnext()関数を呼び出す必要があります。ここで、現時点で作業の完了時点が分かるgetId()関数を内部から直接呼び出すようにすると、イテレータと密接な依存性が生じます。つまり、次のようにデータを返却する全ての関数の最後にnext()を呼び出すコードを追加する必要があります。
const iterator = orderCoffee('010-1234-1234'); iterator.next(); function getId(phoneNumber) { // … iterator.next(result); } function getEmail(id) { // … iterator.next(result); } function getName(email) { // … iterator.next(result); } function order(name, menu) { // … iterator.next(result); }
(実際にはiterator.next()など関数内部で依存しているライブラリによってコールバック形式で呼び出されますが、ここでは説明のために単純に一番下の行に追加しました。)
これではジェネレータで汎用的な関数は使用できませんね…。
ジェネレータとプロミスの出会い
しかし、ここで終わりではありません。もし、すべての関数がプロミスを返却するとき、それぞれの関数が制御権を直接扱うのではなく、第3者に委任することができないでしょうか?
試してみよう。まずプロミスの例のように、すべてのgetXXX関数はプロミスを返却すると仮定します。今後はイテレータを生成して、関数が終了するまで繰り返し実行すればよいでしょう。
const iterator = orderCoffee('010-1010-1111'); let ret; (function runNext(val) { ret = iterator.next(val); if (!ret.done) { ret.value.then(runNext); } else { console.log('result : ', ret.value); } })();
イテレータを生成してnext()を実行すると、結果のvalue値にプロミスを返し、プロミスのthen()
メソッドで再びイテレータのnext()関数を実行します。このようにイテレータがdone:true
を返却するまで循環しながら呼び出します。つまり、 next()-> yield-> then()-> next()の流れに沿って実行されるということです。
(runNext()関数が再帰的に呼び出されています。流れがよく理解できない場合は、上記ジェネレータの例を参考にしてながら見てください。)
さて、結果は?
されていますね!プロミスとジェネレータを使用すると、それぞれの関数でジェネレータを気にすることなく、外部から制御することができます。ジェネレータを使って非同期的なコードを同期コードであるかのように作成できるようになりました。
ここではコードを単純にするために、例外処理などの作業を省略しましたが、もう少しコードを発展させると、汎用的に使える関数を作成できるでしょう。ジェネレータの実行結果としてプロミスを返すようにすれば、さらに便利に使用できそうです(上記のコードを見ると分かりますが、今の状態では戻り値を渡すことができません)。このように優れた機能なら、すでに誰かがライブラリを作っていることでしょう。npmを使ってみよう。
co
$ npm install co
これらの機能を実装したライブラリはすでに複数存在します。その中でもよく使用されているcoを見てみましょう。coは200ライン程度の非常に小さなライブラリですが、ジェネレータを簡単に使用できる非常に便利な関数を提供しています。まず次のようにco関数にジェネレータを引数として渡すと、ジェネレータを最後まで実行し、実行結果としてプロミスを返却してくれます。
co(function* () { const id = yield getId('010-1234-5678'); const email = yield getEmail(id); const name = yield getName(email); return yield order(name, 'coffee'); }).then(result => { console.log(result); });
一歩進んでwrap関数を使うと、ジェネレータ関数をプロミスを返却する関数に変換できます。
const orderCoffee = co.wrap(function *() { const id = yield getId('010-1234-5678'); const email = yield getEmail(id); const name = yield getName(email); return yield order(name, 'coffee'); }); orderCoffee.then(result => { console.log(result); });
このように生成された関数は別のジェネレータがyieldとして利用できるでしょう。またcoはプロミスだけでなく、関数、配列、オブジェクト、ジェネレータなどをyieldすることができます(詳細は、coのREADMEページで確認しよう)。
もう1つおまけで、エラー処理はtry/catchをリロードできます。
co(function* () { let result = false; try { const id = yield getId('010-1234-5678'); const email = yield getEmail(id); const name = yield getName(email); result = yield order(name, 'coffee'); } catch(err) { console.log('これも通り過ぎよう…', err); // エラー処理ロジック } return result; }).then(result => { console.log(result); });
co以外にもプロミスを展開してbluebirdのcoroutine()
、またはnode-fibersを使ったasyncawaitのようなライブラリもあります。興味のある方は一度見てみてください。
Koa
KoaはExpressのアップグレード版と考えるとよいですが、Expressと同じ機能がジェネレータに基づいて作成されているため、非常に簡単に非同期コードを作成できます。たとえばkoa-routerを使って、特定のURLを処理する際、ジェネレータを用いると次のように記述できます。
router.post('/login', function*() { const {email, password} = this.request.body; const user = yield userDB.get(email); const valid = yield crypter.compare(password, user.password); // … });
Koaがジェネレータに基づきミドルウェアに対して制御しているので、yieldを使って非常に簡単に非同期コードを作成できます。従来のExpressのrouterを使う場合と比べると、違いがわかることでしょう。
router.post('/login', function(req, res, next) { const {email, password} = req.body; return userDB.get(email) .then(user => crypter.compare(password, user.password) .then(valid => { // … next(); }); });
単にfunctionの後に*を1つ追加しただけで、コードがこのように変わります。
One More Thing
ここで見逃せないものがもう1つあります。
async function orderCoffee(phoneNumber) { const id = await getId(phoneNumber); const email = await getEmail(id); const name = await getName(email); return await order(name, 'coffee') } orderCoffee('011-1234-5678').then(result => { console.log(result); });
上記のco.wrapを使ったコードから変更された点は、co.wrap(function *()-> async functionと、yield-> awaitの2つだけです。残りはプロミスを返すところまで同じです。JavaScriptの開発者が待ち望んでいるスペックが、まさにasync-awaitで、現在Stage 3の段階でまだ標準化されていませんが、既にregeneratorのようなライブラリには実装されており、BabelやTypeScriptのようなトランスコンパイラでサポートされています。上記のKoaのリンクからドキュメントを確認された方は既知の内容ですが、Koaも2.0からasync/await基盤に完全に変更される予定です(すでに開発は完了しており、node.jsが正式サポートするまでAlphaをつけているもと思われる)。
おわりに
ここまで、JavaScriptにおいてジェネレータで非同期プログラミングを行う方法を説明しました。ジェネレータをプロミスのように使用すると、非同期コードをまるで同期コードを記述するように作成することができます。実際使ってみると分かりますが、複雑な非同期コードを取り扱う際に、これを活用すると以前とは比べものにならないほど、簡単にコードを記述することができます。
プロミスが万能薬ではないようにジェネレータも万能ではありません。コールバックも上手く使うと薬になるし、ジェネレータも使いようによっては毒になるでしょう。優れたツールが提供されたら、状況に合わせて活用するのがプログラマーの役割です。ぜひここで入手した新しいツールを存分に活用してみてください。