この記事は以下の環境を元に記事にしています
- node v8.9.1
- node/async 2.6.0
- node/blubird 3.5.1
私にとってjavascriptの非同期処理はcallback一択でした。
コールバック地獄を免れる為にasync やCoffeeScriptと仲良くなりました。
- asyncはコールバック処理での並列処理やループ等を素敵に行えるライブラリです。
- いわゆる[async await]とは別モノです
- coffee-scriptはコールバック地獄の括弧多すぎ問題から脱却できます
しかし昨今の主流はPromiseになっているようで、完全に取り残されてしまいました。
そこで今更ながらPromiseの学習を始めます。(async/awaitはまたの機会に)
今回は私が今までコールバック(+async)で実装してきた処理を意識して、promiseとの違いを書いていこうと思います。
async
asyncはnode.jsの非同期処理を簡便に扱えるライブラリです。特にループ処理をする時には欠かせません。
# install
npm install async
# require
async = require('async')
async
はasync/await
の登場もありそのままの変数名を使い続けて良いのかわかりませんが、上記のrequireで正常に動作します。しかしいつどこで不具合が出ないとも限りませんので_async
等の変数名を使った方が良さそうです。
bluebird
bluebirdはV8のPromiseを拡張したようなライブラリです。
map,reduce,filter等のArray.prototypeに定義されているようなメソッド郡が使用できます。
# install
npm install bluebird
# require
Promise = require('bluebird')
callbackとPromiseの非同期関数例
今回の検証の為に以下のメソッドを用意しました。
(以下全てcoffee-scriptで書いています)
callback
定義
callbackFn = (startTime,delay,cb)->
obj =
delay: delay
start: Date.now() - startTime
setTimeout ->
obj.end = Date.now() - startTime
cb(null,obj)
,delay
使用例
callbackFn Date.now(),100,(err,res)->
console.log err if err
console.log res
# { delay: 100, start: 0, end: 106 }
Promise
定義
promiseFn = (startTime,delay)->
new Promise (res,err)->
obj =
delay: delay
start: Date.now() - startTime
setTimeout ->
obj.end = Date.now() - startTime
res(obj)
,delay
使用例
asyncFn(Date.now(),100)
.then(console.log)
.catch(console.log)
# { delay: 100, start: 0, end: 104 }
上記はcallbackとpromiseでそれぞれ書いた同じメソッドです。
しかしcallback形式で書いた関数があればpromise形式に変換する方法があります。
Promise = require 'bluebird'
promiseFn = Promise.promisify(callbackFn)
このようにPromise.promisify
を使うだけで変換できます。(bluebirdの機能です)
ただし条件があります。変換に用いる関数は
- 最後の引数はコールバック関数を定義すること
- コールバック関数の第1引数はerrorであること
- コールバック関数の第2引数はresult(返り値)であること
これさえクリアできていれば変換できます。
1: Parallel(並列処理)
複数の非同期関数を並列処理し、配列に格納して返します。
(以下、mochaで動かしたテストもどきコードを掲載しています)
async.parallel(tasks, callback)
it "async.parallel",(done) ->
startTime = Date.now()
async.parallel [
(next)-> callbackFn(startTime,100,next)
(next)-> callbackFn(startTime,500,next)
(next)-> callbackFn(startTime,300,next)
],(err,results)->
return done err if err
console.log results
done()
# results:
[ { delay: 100, start: 0, end: 106 },
{ delay: 500, start: 0, end: 506 },
{ delay: 300, start: 0, end: 303 } ]
resultsのstartが全て0になっており、同時に処理していることがわかります。
ちなみに
callbackFn
を呼び出している部分は、async.apply
を使えば以下の様に書き換えられます。
(next)-> callbackFn(startTime,100,next)
↓
async.apply(callbackFn,startTime,100)
coffee-scriptではあまり変わらないように見えますがJSで書くと以下の様な違いになります。
(function(next) {
return callbackFn(startTime, 100, next);
});
↓
async.apply(callbackFn, startTime, 100);
Promise.all(tasks)
it "Promise.all", ->
startTime = Date.now()
Promise.all [
promiseFn(startTime,100)
promiseFn(startTime,500)
promiseFn(startTime,300)
]
.then(console.log)
.catch(console.log)
# result:
[ { delay: 100, start: 0, end: 105 },
{ delay: 500, start: 0, end: 502 },
{ delay: 300, start: 0, end: 304 } ]
then
とcatch
に渡す部分がスッキリしています。
上記はmochaのテスト形式ですがasyncの方は
it "async.parallel",(done) ->
から始まるのに対してPromiseの方はit "Promise.all", ->
となっています。つまり(done)
がありません。mochaで非同期関数をテストする時には
(done)
というコールバック関数を引数に与える必要があります。これは
done()
が呼ばれるまでテスト継続だよ、という意味です。しかしPromiseの場合は
Promiseオブジェクト
を返すと自動的に非同期処理する部分を汲み取ってくれるためにdone
が不要になっているのです。
2: map
1のParallelでは配列に非同期処理メソッドを直接書いていました。
これはこれで使いみちもあるのですが、引数だけを変えて同じ関数を呼び出す時はmap
を使った方が便利です。
async.map(coll, iteratee, callback)
第1引数はCollection(配列)
第2引数は反復処理の関数
第3引数は反復処理の終了後の関数
it "async.map",(done) ->
startTime = Date.now()
async.map [100,500,300],((delay,next)->
callbackFn(startTime,delay,next))
,(err,results)->
return done err if err
console.log results
done()
# results:
[ { delay: 100, start: 0, end: 106 },
{ delay: 500, start: 0, end: 506 },
{ delay: 300, start: 0, end: 306 } ]
Promise.map(coll,iteratee,opt)
第1引数はCollection(配列)
第2引数は反復処理の関数
it "map", ->
startTime = Date.now()
Promise.map [100,500,300],(ms)->
promiseFn(startTime,ms)
.then(console.log)
.catch(console.log)
# results:
[ { delay: 100, start: 1, end: 102 },
{ delay: 500, start: 1, end: 503 },
{ delay: 300, start: 1, end: 305 } ]
3: mapLimit
APIを100回叩くような場合にmap
を使うと、一気に100のリクエストを送ることになります。これはサーバーに負担をかけることになりますしDOS攻撃だと受け取られかねません。
mapLimit
は同時に行う処理の上限を指定することができます。
async.mapLimit(coll,limit, iteratee, callback)
async.map
との違いは第2引数がlimit
になっている所です。同時処理のlimit数を設定します。
it "async.mapLimit",(done) ->
startTime = Date.now()
async.mapLimit [100,500,300],2,((delay,next)->
callbackFn(startTime,delay,next))
,(err,results)->
return done err if err
console.log results
done()
# results:
[ { delay: 100, start: 1, end: 104 },
{ delay: 500, start: 1, end: 505 },
{ delay: 300, start: 104, end: 405 } ]
まず2つの処理が同時に行われ、最初の処理が104ms後に終了、同時に3つ目の処理が始まっています。
Promise.map(coll,iteratee,opt)
使用する関数は同じmap
ですが、第三引数を{concurrency: 2}
とすることでlimit数を2に制限しています。
it "map limit", ->
startTime = Date.now()
Promise.map [100,500,300],(ms)->
promiseFn(startTime,ms)
,{concurrency: 2}
.then(console.log)
.catch(console.log)
# results:
[ { delay: 100, start: 1, end: 101 },
{ delay: 500, start: 1, end: 502 },
{ delay: 300, start: 101, end: 406 } ]
4: mapSeries
mapSeries
は同時に行う処理数を1つにします。完全な直列処理です。
mapLimit 1と同義です。
async.mapSeries(coll, iteratee, callback)
async.map
と使い方は全く同じです。ただ直列で処理されます。
it "async.mapSeries",(done) ->
startTime = Date.now()
async.mapSeries [100,500,300],((delay,next)->
callbackFn(startTime,delay,next))
,(err,results)->
return done err if err
console.log results
done()
# results:
[ { delay: 100, start: 1, end: 104 },
{ delay: 500, start: 104, end: 609 },
{ delay: 300, start: 609, end: 914 } ]
endと次行のstartが同じ値になっており、直列で処理していることがわかります。
Promise.mapSeries(coll,iteratee)
Promise.map
と使い方は全く同じです。ただ直列で処理されます。
it "map series", ->
startTime = Date.now()
Promise.mapSeries [100,500,300],(ms)->
promiseFn(startTime,ms)
.then(console.log)
.catch(console.log)
# results:
[ { delay: 100, start: 1, end: 103 },
{ delay: 500, start: 103, end: 605 },
{ delay: 300, start: 605, end: 908 } ]
5: series
直列処理です。map系と違うのは引数を変えて同じ関数を叩くのではなく、全く別の処理を行うという所です。
Promise.all
の直列処理バージョンだと思ってください。
async.series(tasks, callback)
it "async.series",(done) ->
startTime = Date.now()
async.series [
async.apply(callbackFn,startTime,100)
async.apply(callbackFn,startTime,500)
async.apply(callbackFn,startTime,300)
],(err,results)->
return done err if err
console.log results
done()
# results:
[ { delay: 100, start: 0, end: 104 },
{ delay: 500, start: 104, end: 608 },
{ delay: 300, start: 608, end: 910 } ]
上記ではcallbackFn
を3回繰り返していますのでmapSeriesと同じ結果になっていますが、全く別の関数を当てはめることが可能だという点に着目してください。
Promise
Promise(bluebird)にはseries
に当てはまるメソッド(allの直列バージョン)は無いようです。
(あったら教えてください)
そもそもPromiseのthenは直列に処理できる方法なので、以下の様になら書けます。
it "series", ->
startTime = Date.now()
arr = []
Promise.resolve()
.then -> promiseFn(startTime,100)
.then (val)-> arr.push(val)
.then -> promiseFn(startTime,500)
.then (val)-> arr.push(val)
.then -> promiseFn(startTime,300)
.then (val)-> arr.push(val)
.then(->console.log(arr))
.catch(console.log)
一つの処理に2回thenしてるのが気持ち悪いです(-_-;)
6: waterFall
waterFallは取得した値を次のメソッドに渡します。
説明しにくいのでコードを見てください。
async.waterfall(tasks, callback)
it "async.waterfall",(done) ->
startTime = Date.now()
items = []
async.waterfall [
(next)->
callbackFn startTime,100,(err,item)->
items.push(item)
next(null,item.end)
(endValue,next)->
callbackFn startTime,endValue,(err,item)->
items.push(item)
next(null,item.start,item.end)
(startValue,endValue,next)->
value = startValue + endValue
callbackFn startTime,value,(err,item)->
items.push(item)
next(null)
],(err,results)->
return done err if err
console.log items
done()
# items:
[ { delay: 100, start: 0, end: 102 },
{ delay: 102, start: 102, end: 207 },
{ delay: 309, start: 207, end: 517 } ]
async.waterfall
の第1引数はタスク配列です。
最初のタスクでendの102を次に渡しています。
次のタスクでは102を受け取って処理、次にstartの102とendの207を渡しています。
最後のタスクでは受け取った102と207を足した309で処理をしています。
このような処理は使いたいケースが結構多いです。
twitterで例えるとAPIを叩いて人気のユーザーを抽出、そのユーザーの別のツイートを分析、みたいな事をするのには必須のメソッドです。
Promise
Promise(bluebird)にはwaterfall
に当てはまるメソッドは無いようです。
あえて書くなら以下の様な感じでしょうか。
it "water fall", ->
startTime = Date.now()
items = []
Promise.resolve()
.then -> promiseFn(startTime,100)
.then (item)->
items.push(item)
return item.end
.then (end)-> promiseFn(startTime,end)
.then (item)->
items.push(item)
return item
.then (item)-> promiseFn(startTime,item.start+item.end)
.then (item)->
items.push(item)
.then(->console.log(items))
.catch(console.log)
# items:
[ { delay: 100, start: 1, end: 104 },
{ delay: 104, start: 104, end: 210 },
{ delay: 314, start: 211, end: 528 } ]
なかなか気持ち悪いコードになりました。
promise-waterfall
npmにpromise-waterfall
というモジュールがありました。
これを使うとPromiseでwaterfallできるみたいなので使ってみました。
it "promise-waterfall", ->
waterfall = require("promise-waterfall")
startTime = Date.now()
items = []
waterfall [
()->
promiseFn(startTime,100)
.then (item)->
items.push(item)
return item.end
(end)->
promiseFn(startTime,end)
.then (item)->
items.push(item)
return item
(item)->
promiseFn(startTime,item.start+item.end)
.then (item)->
items.push(item)
]
.then(->console.log(items))
.catch(console.log)
# items:
[ { delay: 100, start: 0, end: 104 },
{ delay: 104, start: 105, end: 210 },
{ delay: 315, start: 211, end: 527 } ]
読みやすくなりましたが別モジュールを入れるまでのことなんでしょうか・・・
まとめ
map系はPromiseの方がスッキリ書けそうです。
しかしwaterfallは実際によく使うのでPromise教に改宗するのは躊躇してしまいます。
(オススメの書き方があったら教えてください)
ですがPromise(bluebird)には気になるメソッドがたくさんありますのでもうちょっと調べてみようと思います。