KoaとJavaScriptのジェネレーターを試す(Experiments with Koa and JavaScript Generators 日本語訳)


Koaは新しく登場したNode.js向けのアプリケーションフレームワークです。その特徴はなんといっても、多くのNode.jsアプリ開発者を悩ませるいまいましいコールバック地獄を解消できるということでしょう。Koaではこの特徴を、JavaScript ES6で導入された強力な機能「ジェネレーター」をつかうことで実現しました。これは、ES6より前には不可能でした。KoaはExpressと同じ開発者によるものです。ExpressはNode.jsでは最も良く知られているフレームワークなので、Koa.jsも一見の価値があります。

コールバックを多用したJavaScriptを散々書いてきた者として、この機能が自分のコードをどれだけシンプルにしてくれるかを知ってとても興奮しました。

ジェネレーターと非同期に一体なんの関係があるのですか?

一見、これら2つの間に明確な関連性はなさそうに思えます。ジェネレーターは関数を一旦停止しながら、一連の値を返すために使われるものです。

例:

// これはジェネレーターです。関数名の頭につく"*"は「ジェネレーター」オブジェクトを返すことを意味します。
// コードの中でyieldが使われていなくても、ジェネレーターオブジェクトを返すものとして扱われます(TODO)。
function *getAllSquareNumbers() {
    for (var i = 1; ; i++) {
        // 'yield'を呼ぶたびに、この関数の実行はジェネレーターの'next'メソッドが
        // 呼ばれるまで一時停止します(下のコード例を見てください)。
        yield i * i;
    }
}

// さあ、いくつかの値を読んでみましょう。
var generator = getAllSquareNumbers();
console.log(generator.next().value); //  '1' を出力します。
console.log(generator.next().value); //  '4' を出力します。
console.log(generator.next().value); //  '9' を出力します。
console.log(generator.next().value); // '16' を出力します。
// まだ続けられますが、十分ですね。

ジェネレーターのnext関数を呼ぶ度に、{ value: ..., done: ...}という形のオブジェクトが返されます。ここで、valueはジェネレーターがyieldした次の値、doneはジェネレーターが(例えばreturnを呼んで)終了した場合にtrueとなる真偽値です。

C#の開発者なら、C# 2.0が2005年から実装していたyieldと同様に動作することがわかると思います。

もう一度お聞きしますが、これと非同期プログラミングになんの関係があるんでしょう?

初めてKoaがコールバックを回避すると聞いた時、「なるほど、ジェネレーターは非同期に値を出力(emit)するんだな」と思っていましたが、これは完全に間違っていました。実際、ジェネレーターの正しい理解を持っていればこう考えることは筋が通っていません。しかし、この(間違った)考えに囚われすぎていたために、私は度々完全に混乱してしまい、想定通りに動作するコードを書くことができませんでした。この混乱をいくらか分かうことをお許し下さい。

ジェネレーターは値を同期的に出力(yield)します。

ジェネレーターは値を非同期的にyieldすることはできません。それは理にかなっていないのです。なぜなら、ジェネレーターから返される値を受け取るコードがES6のジェネレーターについて知っているという保証はなく、そして、コード実行はブロックされてはいけない、というのがJavaScriptの基本的な原則だからです(名前に*のつく関数、つまりジェネレーター関数はこの原則の新たな例外なのです)。そのため、genereator.next()を通常のコードから実行した時、値は同期的に受け渡されます。

はい。えーと、三度目になりますが、ジェネレーターが同期的にしか値を渡せないとすると、どうやって非同期コードを書くことを助けてくれるのでしょうか。

ジェネレーターから返されるのが、promiseのように非同期タスクを記述するオブジェクトだったらどうでしょう?この場合 消費者側 のコードで、非同期タスクの終了を待ってから次の.nextを呼んで次の値を取得するようにできます。Koaではあなたの書くアプリケーションコードはジェネレーターで、一連のpromiseを出力(emit)します(下で説明します)。そして、Koaはこれらが終了するのを待ってアプリケーションコードを再開します(この時、タスクの結果がアプリケーションコードに返されます)。これがKoaの動作の基本概念です。

Koaはどのようにして非同期タスクの実行結果をアプリケーションコードのジェネレーターに渡すのでしょう?これは、ES6のジェネレーターの別の機能を使って実現されます。nextに値を渡す(例えば、generator.next(123)とする)と、この値は、現在同期的にyieldされているジェネレーターのコードの返り値となります。従って、コードはvar nextData = yield something;のようになります(これは、somethingを出力(emit)し、しばらくたってからnextDataを受け取ります)。

(訳注:coを用いてこの動作を実現しているようです)

Koaの実例

ごく簡単なKoaのコードです。

var koa = require('koa'),
    app = koa();

app.use(function *() {
    // ここがこの例で重要なアプリケーションロジックの箇所です。
    // ここで、コールバックを使わずに非同期処理を呼び出しています。

    var city = yield geolocation.getCityAsync(this.req.ip);
    var forecast = yield weather.getForecastAsync(city);

    this.body = 'Today, ' + city + ' will be ' + forecast.temperature + ' degrees.';
});

app.listen(8080);

Koaから呼び出されるこのアプリケーションコードはジェネレーター関数です(この場合、無名のジェネレーター関数です。そのため名前を省略してfunction *() { ... }としています)。それぞれのyieldはpromise(または、他のタスクを記述したオブジェクト)をKoaに渡し、コード実行を一時停止します。Koaはそれぞれのpromiseの完了を待ち、アプリケーションコードのジェネレーターを再開します。その時に、非同期タスクの結果がアプリケーションコードに渡されます。

結果として、同期的なコードと少し違うシンタックスを用いるだけで非同期処理を取り扱うことができます。

エラー処理

また、さらなる朗報としてエラー処理もとても綺麗に書けるようになりました。もし、非同期タスク中でエラーが生じた時(例えば、promiseに'failure'コールバックがある時)、Koaはジェネレーターのthrow機能を使って、そのタスクを渡した(yieldした)ものに例外を送ります。それにより、同期コードと同様に例外をcatchしたり、リクエストの失敗を引き起こすことができます(非同期処理の例外がコーディングミスによって補足されずに無視されてしまうことがよくありますよね)。

Koaで非同期をやるには。あるいは、「なにをyieldできるの?」

Koaのドキュメントでこの情報を探してみましたが見つけることができなかったので、ここにKoaにyieldできるものの種類のリストを作りました。

実のところ、これはKoaの背後で使われているNodeモジュール coのドキュメントに書かれていました。

(訳注:Yieldablesyieldできるもののリストがあります。)

1. Promises(プロミス)

一般的にはこれが一番便利です。ライブラリでpromiseオブジェクト(またはpromiseライクなオブジェクト)として非同期タスクを記述できれば、それをKoaに渡す(yieldする)ことができます。

例:

// 'Q' promiseライブラリを用いて、promiseを手動で組み立てる例
var Q = require('q');

function delay(milliseconds) {
    var deferred = Q.defer();
    setTimeout(deferred.resolve, milliseconds);
    return deferred.promise;
}

app.useジェネレーターの中で、意味もなく遅延を引き起こしてみましょう。このようにします。

app.use(function *() {
    yield delay(100); // メモ:このyieldの出力は特に役に立ちません
});

更に現実的な例として、Qのdenotify機能を使って、requestモジュールのpromiseを返すバージョンを作ることができます。

var request = Q.denodeify(require('request'));

// promiseを返すライブラリのコードを呼び出す例
function doHttpRequest(url) {
    return request(url).then(function(resultParams) {
        // 単にレスポンスオブジェクトを展開する
        return resultParams[0];
    });
}

app.useのジェネレーターの中で次のようにします。

app.use(function *() {
    // 返却値の例
    var response = yield doHttpRequest('http://example.com/');
    this.body = "Response length is " + response.body.length;
});

やった!コールバックを使わずにHTTPリクエストが記述できました。

メモ:これは “thenable” と一緒に使うこともできます。これは、promiseよりも緩い概念で、Promises/A+ specに定義されています。ちゃんとしたpromiseライブラリは全てこれに従っています。

2. Thunks(サンク。関数を引数渡しするのが好きな場合以外には無視してください)

何?Thunkだって?(TODO) Koaで thunk は一つだけ引数をとる関数です。この関数の引数は コールバック で、関数が完了した時に実行結果(またはエラーの詳細)を受け取ります。

(訳注:thunkという単語、初めて知ったのですがここに詳しいです:thunkって? - Higepon’s blog

始めるにあたって、次のようなNode.jsで通常使われる、コールバックスタイルで値を返すコードを思い浮かべてください。このコード例では非同期にする必要性はありませんが、そこは無視してください。データベースクエリかなにかと考えるといいと思います。

// 一般的なNodeスタイルのコールバック非同期パターン(promiseでもthunkでもない)
function getSquareValueAsync(num, callback) {
    setTimeout(function() {
        var result = num * num;
        callback(/* error: */ null, result);
    }, 500);
}

引数の数がわからないので、Koaは直接的にはこの関数を認識することができません。でも、以下のようにラップすることでthunkにすることができます。

// Koaが認識できるように非同期処理を`thunk`でラップします。
function getSquareValueThunk(num) {
    // 'thunk'はコールバックを引数にとる関数を返します。
    return function(callback) {
        getSquareValueAsync(num, callback);
    };
}

このラッピングで重要なのは、Koaから呼び出すことができるように、callback以外の引数を取り除いてしまうことです。こうすることで、app.useジェネレーターの中でこのthunkをKoaに対してyieldすることができます。例:

app.use(function *() {
    var square = yield getSquareValueThunk(16);
    this.body = "Square of 16 is " + square;
});

簡潔性のために、getSquareValueThunk(16)は関数(Function)インスタンス(callback引数のみを取る関数)を作って返す以外のことは何もしていません。FunctionインスタンスをKoaにyieldすると、Koaはそれが一つのcallback引数を受け入れて実行できるものとして扱い、コールバックをcallback(error, result)のように実行します。

この動作の仕組みが分からない、また使いたくない場合はpromiseを使いましょう。promiseのほうがアプリケーションコードで用いるにはほとんどの場合明らかに良い選択です。thunkはKoa内部で低レベルな処理をする場合に限って良い選択です。なぜなら、素のKoaはpromiseライブラリを何も使っていないからです。

3. Generators(ジェネレーター)

ここからが面白いところです。ジェネレーターはジェネレーターを出力(emit)するジェネレーターを出力する・・(以下同)・・ことができます。これによって、任意にネストを作って非同期タスクを別の非同期タスクから作り出すことができます。

getSquareValueThunkを複数回連続して呼び出すことを想像してみてください。そして、この複数回の呼出を一回の操作にまとめるにはどうしたら良いのでしょう。簡単です。別のジェネレーターを作れば良いのです。

function *getSquareOfSquareOfSquare(num) {
    // これらはすべて非同期処理です
    var square = yield getSquareValueThunk(num);
    var squareOfSquare = yield getSquareValueThunk(square);
    return yield getSquareValueThunk(squareOfSquare);
}

関数名の頭につく*に注意してください。これは、その関数の中でyieldが使えること、そして、ジェネレーターを返却することを意味します。このジェネレーターは一連のthunkを返します(promiseでも同様に動作します)。では、これをapp.useからyieldすることで非同期パイプラインに組み込んでみましょう。

app.use(function *() {
    var bigNumber = yield getSquareOfSquareOfSquare(16);
    this.body = "16 to the 8th power is " + bigNumber;
});

少し高度なKoaアプリの構造はこのようになります。トップレベルのapp.useジェネレーターは中間層のアプリケーションコード(ビジネスロジック等)が返すジェネレーターをyieldし、その次に、中間層のコードが下位層のアプリケーションロジック(データベースアクセス等)が返すジェネレーターをyieldします。こうして、ソースコードが同期的プログラミングと同様に見える(コールバックがない)ようになり、実際の動作は完全に非同期となります。もちろん、必ずしもこのような三階層のアーキテクチャを採用する必要はありませんが、参考にしてください :)

余談ですが、このテクニックを用いて、この記事の前の方で見たdoHttpRequestを簡素化することができます。元のバージョンはこれです:

function doHttpRequest(url) {
    return request(url).then(function(resultParams) {
        return resultParams[0];
    });
}

・・・そして、これが次のようなジェネレーターで置き換えられます。thenの中でコールバックを使う必要はもうありません:

function *doHttpRequest(url) {
    var resultParams = yield request(url);
    return resultParams[0];
}

4. 配列

並列実行したい複数のタスクがある場合、それらのタスクの配列をyieldすることで全てのタスクの終了を待ち合わせることができます。

例:

app.use(function *() {
    var urls = [
        'http://example.com/',
        'http://twitter.com/',
        'http://bbc.co.uk/news/'
    ];

    // この行で、上記URLへの3つのリクエストは並列実行されます。
    // メモ: doHttpRequestは前の方に出てきました。
    var arrayOfPromises = urls.map(doHttpRequest);

    // ここで、promiseの配列をyieldすると、Koaは全ての
    // promiseの終了を待ちます。
    var arrayOfResponses = yield arrayOfPromises;

    this.body = "Results";
    for (var i = 0; i < urls.length; i++) {
        this.body += '\n' + urls[i] + ' response length is '
              + arrayOfResponses[i].body.length;
    }
});

上記のように、yield arrayOfPromises;の結果も配列になります。配列のそれぞれの要素は対応するpromiseの結果になります。

yieldする配列の全ての要素がpromiseである必要はありません。thunk、generator、別の配列、また次で紹介するkey-valueオブジェクトを配列の要素として設定できます。この配列は好きなようにネストできます。

5. key-valueオブジェクト

並列実行したい複数のタスクの終了を待ち合わせ、それぞれのタスクに明確な名前を付けたいときは、オブジェクトを使いましょう。

例:

app.use(function *() {
    var tasks = {
        imposeMinimumResponseTime: delay(500),
        fetchWebPage: doHttpRequest('http://example.com/'),
        squareTenSlowly: getSquareValueThunk(10)
    };

    // 処理はすべて開始されています。key-valueオブジェクトをKoaにyieldすると、
    // それらの終了を待ち合わせます。
    var results = yield tasks;

    this.body = 'OK, all done.';
    this.body += '\nResult of waiting is: ' + results.imposeMinimumResponseTime; // 表示: undefined
    this.body += '\nWeb page status code is: ' + results.fetchWebPage.statusCode; // 表示: 大抵は200でしょう
    this.body += '\nSquare of ten is: ' + results.squareTenSlowly; // 表示: 100
});

tasks[0]tasks[1]のように結果を読むよりも簡単というだけです。

これってC# 5のasyncとwaitにとても似てますよね?

はい、ほとんどそのものです!(TODO) function *() {}*はC#のasyncキーワードと同様で、JavaScriptのyieldは(少なくともKoaや似たようなフレームワークでは)C#のawaitと同様です。

*/async、そして、yield/awaitの利用方法はとても似ています。主な違いは、実装方法に関することです。C#のawaitは非同期を言語機能として扱い、組み込み標準のTask記法を使うことができます。一方JavaScriptでは、yieldは非同期を扱うことが できる という程度です(非同期タスクを記述するライブラリーや非同期タスクを待つためのライブラリーを利用することでこれが実現できます)。しかし、JavaScriptでは、これらを他のことにも用いることができ、言語レベルではyieldを何に使うかを制約しません。

C#開発者なら、Koa、coと同様のテクニックを用いて、C# 5のasync/awaitをC# 2.0のyieldだけで再実装してみると面白いと思います。

更にKoaについて学ぶには

この記事はKoaのジェネレーターと非同期についてのみ扱っています。Koaを用いてアプリを構築したい場合、フレームワークのサイトを開きましょう。Express(Koaのアイデアの前実装)を使ったことがない場合、是非 ミドルウェア(middleware) について学んでください。これは、Koaの中心的なアーキテクチャで、フレームワークがアプリケーションの構造をどのように規定するのか知ることができるでしょう。


翻訳のあとがき

これは、Experiments with Koa and JavaScript Generators の日本語訳です。

Koa.jsとそのベースのcoを触って少しコードも読んだのですが、ジェネレーターが非同期をどう扱うのかが難しくて良く理解できませんでした。そんな時、ちょうどTwitterでこの記事を知り、翻訳で英語を理解するライフハックをしたところとても分かりやすかったので、原著者のSteveさんの許可を得て公開しました。Node.js、Expressにある程度理解のある人のKoa.js入門にもちょうど良いと思います。僕と同じようにソース難しい><という方もこの記事が内部動作の概略を理解する役に立てば嬉しいです。

訳に自信がない箇所には(TODO)をつけています。訳へのツッコミ、提案があれば是非コメントなどでいただけるとありがたいです!

Thank you so much, Steve!

目次