generatorとJavaScriptの非同期処理

  • 150
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

ES6以降で使えるようになったgeneratorでJavaScriptの非同期処理を書く、という話をします。同様のトピックについてはすでに多くのブログで言及されているのですが、各所で異なった使われ方や解説がなされていることが多いように思われたので、ここではそれぞれの関係を明らかにしつつ、generatorのさまざまな使い方を整理するのが目的です。

ふつうのやり方

もっとも一般的なコールバックスタイルで書くとこうなる。コールバックの第1引数にErrorオブジェクト、第2引数に得られた結果を渡すのはnodeの慣習(エラー処理については今回は省略)。

function sleep(cb) {
  setTimeout(function() {
    cb(null, 'I woke up at ' + new Date());
  }, 1000);
}

sleep(function(err, res) {
  console.log(res);
  sleep(function(err, res) {
    console.log(res);
    sleep(function(err, res) {
      console.log(res);
    });
  });
});

こんな風にネストがどんどん深くなるのを、もっと「同期的に」書きたい、というのが問題の出発点。

非同期処理を同期的に書く

「同期的に」とはどういうことか? 代表的な要件としては

  • ネストが入れ子にならない
  • 上から下に読んで実行フローが把握できる
  • 非同期処理の結果得られた値を次の処理に引き回せる
  • try/catchで素直に例外を捕捉できる

などが挙げられる(もちろんこれら全てが必要不可欠なわけではない)。

代表的な方法を大別すると

  • deferred/promiseを使う方法
  • generatorを使う方法
  • その他(async.jsなど)の方法

こう書くとgeneratorは他の方法の代替手段のようにみえるのだけれど、実はそうではない。むしろ両者を組み合わせることでより汎用的なインターフェースを提供できる。その代表例がco。はじめはここを全く勘違いしていて、coのソースコード自体は読めるのに、その意義がうまく理解できなかった。

基本的な考え方

yieldを使えば関数の実行を途中で止められる」→「それならフロー制御の本体をgenerator関数で書けばよい?」→「yieldで非同期処理が終わるのを待って、非同期処理が終わったらg.next(val)で処理を戻す」→「ネストなしで書けてうれしい」というのが、generatorによる非同期処理の基本的な発想。

実装その1(素朴な方法)

http://techblog.yahoo.co.jp/programming/js_callback/

function sleep(g) {
  setTimeout(function() {
    g.next('I woke up at ' + new Date());
  }, 1000);
}

var g = (function *() {
  console.log(yield sleep(g));
  console.log(yield sleep(g));
  console.log(yield sleep(g));
})();

g.next();
  • sleep()はgeneratorを受け取って、非同期処理が終わったらg.next()を呼び出す
  • ユーザはgenerator関数を実行してgeneratorを作成
  • ユーザはg.next()を明示的に呼び出して、あとは順々に実行

実装その2(generatorの抽象化)

http://labs.cybozu.co.jp/blog/kazuho/archives/2007/05/coopthread.php

function runnable(generator) {
  var g;
  g = generator(function(val) { g.next(val); });
  g.next();
}

function sleep(cb) {
  setTimeout(function() {
    cb('I woke up at ' + new Date());
  }, 1000);
}

runnable(function *(next) {
  console.log(yield sleep(next));
  console.log(yield sleep(next));
  console.log(yield sleep(next));
});

上から順に「フローの実行ライブラリ」「実行内容(とりあえずミドルウェアと呼んでおく)」「フロー本体」と、きれいに機能を分割できている。こうすると、フロー本体を書くユーザは、フローをgenerator関数でまとめて、内部でyieldを書くと非同期で実行した値がもらえることさえ知っていればよい。またミドルウェアを書く人は「終ったらcb(data)を呼び出す」ことさえ知っていればよい。細かい実行タイミングの制御はrunnable()がやってくれる。

実装その3(coっぽい何か)

function naiveCo(generator) {
  var g = generator();
  function next(data) {
    var ret = g.next(data);
    if (!ret.done) {
      ret.value(function(data) {
        next(data);
      });
    }
  }
  next();
};

function sleep(next) {
  setTimeout(function() {
    next('I woke up at ' + new Date());
  }, 1000);
}

naiveCo(function *() {
  console.log(yield sleep);
  console.log(yield sleep);
  console.log(yield sleep);
});

その2との大きな違いは、yieldには関数を渡して、実行自体はライブラリの内部で行うようにした点。g.next()の戻り値のオブジェクト(のvalueプロパティ)からsleepを取り出して、コールバックを渡して実行している。その2までは「g.next(val)で入れた値をyieldで取り出す」だけだったのが、今度は「yieldで入れた値をg.next()の戻り値として取り出す」という逆向きのデータのやりとりが発生している。こうすると「yieldに何を入れるか」を抽象化できる。これがcoのドキュメントに書かれているyieldable

その4(ほんもののco)

https://github.com/visionmedia/co

var co = require('co');

function sleep(cb) {
  setTimeout(function() {
    cb(null, 'I woke up at ' + new Date());
  }, 1000);
}

co(function *() {
  yield function *() {
    console.log(yield sleep);
    console.log(yield sleep);
    console.log(yield sleep);
  };
  console.log(yield sleep);
  console.log(yield sleep);
})();

ほんもののco。基本的な構造はその3と一緒なのだけれど、yieldにyieldableなものたち、すなわちgenerator関数やpromise、配列やオブジェクトを渡したりできる。

実はここでsleep()の形式がとても重要で、必ずコールバックのみを引数に取る1引数の関数でなくてはならない。ところがnodeの非同期処理は、たとえばfs.readFile()がそうであるように、複数の引数の最後にコールバックを指定することが多い。この橋渡しを行うのがnode-thunkifyで、これを使えば、元の関数を、コールバック以外の引数をあらかじめ部分適用できるような関数に変換できる。ここでは詳細な説明は省略する。sleep()をthunkifyする必要があるのは、下記のように休止時間を外から渡したい場合など。

function sleep(interval) {
  return function(cb) {
    setTimeout(function() {
      cb(null, 'I woke up at ' + new Date());
    }, interval);
  }
}

まとめ

「generatorを使って非同期処理を同期的に書く」というテーマで、いくつかの利用パターンを整理してみました。その1からその4に進むに従って、抽象化と機能の分割が進んでいるのが見てとれます。

generatorと非同期処理については、すでに2006年頃、Firefox2.0が出た頃から話題になっていたのだけれど、去年の終わり頃、ちょうどkoaのリリースに前後するあたりから再び盛り上がりをみせているようです。

もともとJavaScriptの非同期処理をシンプルに書きたいというニーズは非常に高いのですが、(1) 実行フローをどれほど見通しよく書けるのか? (2) 既存のAPIからどのようにミドルウェアを生成するのか? という問いに対してのベストプラクティスはまだ確立していないように思います。それぞれのフロー制御のライブラリがどんなコンセプトのもとで実装されているかを考えてみると、コードを読む際にも理解が深まるかもしれません。

参考