はじめに
CoffeeScriptは、AltJS (たぶんAlternative JavaScript)と呼ばれる言語の一つ。コンパイルした結果はJavaScriptとなり、Web BrowserやNode.jsで動かすことができる。
文法はRubyやPythonから影響された感じで、普通にJavaScriptを書いている人に取っては少し取っ付きにくいのかもしれない。そこが、JavaScriptのスーパーセットであるTypeScriptと違うところ。JavaScriptな方々には批判されたり敬遠されたりすることも多いが、最近流行りのAtom EditorがCoffeeScriptで書かれていたり、これまたGitHub製のbot作成環境のhubotもCoffeeScriptだったりで、これらのプロジェクトに貢献したりプラグインを書いたりするのにCoffeeScriptを書かざるを得ない状況も出てくる。IT武士の嗜(たしな)みとして、CoffeeScriptの読み書きはある程度できておいた方が良さそう。
ということで、各種チュートリアル的なものはあれこれあるが、非同期の処理をどう書くのかというのを少しやってみた。
題材として使わせて頂いたのが、
[JavaScript] 非同期処理のコールバック地獄から抜け出す方法という LightSpeedCさんの投稿で、ここでの課題をCoffeeScriptで書いてみる。
コールバック地獄をCoffeeScriptで
まずは非同期で結果を返すgetという関数。CoffeeScriptで書くとこうなる。
get = (file, callback)->
console.log "file: #{file}..."
setTimeout ->
console.log "file: #{file} complete"
callback null, "(#{file})"
, 200 + Math.random() * 100
module.exports = get
CoffeeScriptの場合、複数の引数を取って先の方に関数が来る場合の、後の引数の書き方に迷うのだが、関数呼び出しの頭と揃えて,
を置くと良い。上記の場合だと、setTimeoutの最初の引数がタイムアウト時に呼び出される関数で、二つ目の200 + ...
がタイムアウト時間だが、こんな風に書ける。これをCoffeeScriptのコンパイラにかけるとこんな具合。
// Generated by CoffeeScript 1.9.3
(function() {
var get;
get = function(file, callback) {
console.log("file: " + file + "...");
return setTimeout(function() {
console.log("file: " + file + " complete");
return callback(null, "(" + file + ")");
}, 200 + Math.random() * 100);
};
module.exports = get;
}).call(this);
想定通りに、setTimeoutの一つ目、二つ目の引数が渡されている。CoffeeScritはコンパイル後のJavaScriptが読みやすくてデバッグ時には重宝する。まぁ、それやっていると、なぜJavaScriptで書かずにわざわざCoffeeScriptで書いているんだろ、という気になるけど。
それで、これを呼び出す側だが、まず、コールバック地獄風に書くとこうなる。
get = require './get'
get "a.txt", (err, a)->
get "b.txt", (err, b)->
get "c.txt", (err, c)->
console.log a + b + c
閉じカッコが不要なため、行数は短くなるが、多段にネストしていく様子は明白だ。
実行するとこうなる
$ coffee callback-hell.coffee
ile: a.txt...
file: a.txt complete
file: b.txt...
file: b.txt complete
file: c.txt...
file: c.txt complete
(a.txt)(b.txt)(c.txt)
getを逐次呼び出しして、最後に結果を出している
Asyncを使ってみる
これをAsyncを使って書き換えてみる。簡単なのでまずは並列実行の方から。なお、asyncは標準ライブラリじゃないので、実行前に一度npm install async
しておく必要がある。
get = require "./get"
async = require "async"
async.map ["a.txt", "b.txt", "c.txt"], get, (err, result)->
if err
console.log err
return -1
console.log result.join ""
Asyncのmapは3つの引数を取る。一つ目は繰り替えしを行うリストで二つ目と三つ目が関数。
二つ目の関数は(item, callback)->
の形式になっていて、各アイテムに対して処理を行い、結果をcallbackで返す。CallbackはNode.js標準の(error, result)の形式。今回はたまたま(実はわざとだけど)getがそういう形式なのでそのまま渡している。
三つ目の関数は(error, results)->
の形式になっていて、繰り返しの途中でエラーが起こるか、全ての繰り返しが終わると呼び出される。繰り返しの間にエラーが起きればそれがerrorで返される。そうでなければerrorはnullで結果がリストになってresultsとして返される。ここで嬉しいのは、async.mapの場合、繰り返し処理は並行して実行されるので順序の保証が無いが、結果は元のリストの順序が保証される。つまり、[a, b, c]に対してfuncを適用した場合は[func(a), func(b), func(c)]となる事を期待できる。
さて、CoffeeScriptで書くとこういう風に複数の関数を引数に持つ呼び出しを書きにくいことがある。今回はたまたま第二引数が関数として切りだされていたから良かったが、そうでなかった場合はこのように書ける。やはり、呼び出す関数と同じindentで,
を置けばよい。
async.map ["a.txt", "b.txt", "c.txt"]
, (item, callback)->
get item, callback
, (err, result)->
if err
console.log err
return -1
console.log result.join ""
これを実行するとこうなる。
$ coffee async1.coffee
file: a.txt...
file: b.txt...
file: c.txt...
file: b.txt complete
file: c.txt complete
file: a.txt complete
(a.txt)(b.txt)(c.txt)
最初に全ての関数が起動されていて、その終了の順番もバラバラで、でも結果は期待通りの順番になっていることがわかる。
次に、逐次実行の方。実は、これもそれほど難しくない。
get = require "./get"
async = require "async"
async.mapSeries ["a.txt", "b.txt", "c.txt"], get, (err, result)->
if err
console.log err
return -1
console.log result.join ""
async.mapをasync.mapSeriesにするだけ。これだけで、逐次実行になる。
$ coffee async2.coffee
file: a.txt...
file: a.txt complete
file: b.txt...
file: b.txt complete
file: c.txt...
file: c.txt complete
(a.txt)(b.txt)(c.txt)
応用編で、a, bを並行実行、その後cを実行。色々とやり方はあると思うが、async.seriesを使うとこうなる。
async.series [
(cb)->
async.map ["a.txt", "b.txt"], get, (err, result)->
cb err, result.join ""
,(cb)->
get "c.txt", cb
], (err, results)->
console.log results.join ""
async.seriesは関数のリストを引数に取り、それぞれを逐次実行していく。それぞれのcallbackで返された結果を2つ目の引数で指定する関数でまとめて受け取る。この辺りはasync.mapやasync.mapSeriesと同じだ。
PromiseやGeneratorなどが新しいJavaScript仕様(ES6)に取り入れられて、今後asyncが使われる場面が減るのかも知れないが、慣れれば使いやすいし、まだまだ現役で行けると思う。
Promiseを使ってみる
Promiseは非同期処理を扱うための概念・仕組みの一つで、ES6でJavaScriptに正式に言語仕様の一部として取り入れられたので最近注目されてます。LightSpeedCさんは同じくES6に入ったGeneratorという仕組みを使われているが、ボクはどちらかというとPromiseの方が腑に落ちたので取り敢えずこちらを使ってみている。なお、Promiseに明るくない方は超オススメの電子書籍がここにあります。通称Promise本。
まずは、get関数を少し書き換える。
get = (file)->
console.log "file: #{file}..."
new Promise (resolve)->
setTimeout ->
console.log "file: #{file} complete"
resolve "(#{file})"
, 200 + Math.random() * 100
module.exports = get
引数からcallbackが抜け、代わりにPromiseオブジェクトを返す形になっている。CoffeeScriptは関数の最後の実行結果をその関数の結果として返すのでこの場合は new Promise..
が返されることになる。
これを呼び出すコードがこちら。まずはPromiseの動作を理解するために単独で呼び出してみる。
get = require "./get_promise"
get "a.txt"
.then (result)->
console.log result
.catch (error)->
console.log "error", error
上記で述べたようにgetの呼び出しはPromiseオブジェクトを返す。そして、成功した場合(Promise生成時に指定した関数でresolveが呼ばれた場合)、thenで指定した関数が呼ばれ、失敗した場合(Promise生成時に指定した関数でrejectが呼ばれた場合)にcatchが呼ばれる。上記のget_promiseは失敗する場合が含まれていないため、catchで指定したエラー処理が呼ばれることは無いが、形としてこうなるということで記述してある。
このget関数を複数実行してみる。先に並行実行バージョンから。
get = require "./get_promise"
Promise.all ["a.txt", "b.txt", "c.txt"].map get
.then (results)->
console.log results.join ""
.catch (error)->
console.log "error", error
Promise.allは複数のpromiseの実行を待ち、全てが終わったら新しいpromiseを返す。全ての実行が成功の場合はthenで指定した関数を呼び出し、一つでもエラーがあればcatchで指定した関数を呼び出す。Promise.allでの実行順序は保証されないが、async.map同様に結果の順序は最初のリストの通り。
そして、逐次実行。ベタな書き方だが、こうなる。
get = require "./get_promise"
results = []
get "a.txt"
.then (result)->
results.push result
get "b.txt"
.then (result)->
results.push result
get "c.txt"
.then (result)->
results.push result
console.log results.join ""
これをもう少しスマートに書きたいのだが、うまいやり方が見つからなかった。Promise本によると、Array.prototype.reduceを使うと良いと書かれていて、ちょっと試してみたが意図通りに動かなかった。どなたか、上手い書き方をご存知のかた、ぜひともコメント下さい