LoginSignup
3
4

More than 5 years have passed since last update.

[js非同期] Calback教Async派の信者だけどPromise教bluebird派に改宗しようか悩んでる

Posted at

この記事は以下の環境を元に記事にしています
- 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の非同期処理を簡便に扱えるライブラリです。特にループ処理をする時には欠かせません。

async - Documentation

# install
npm install async
# require
async = require('async')

asyncasync/awaitの登場もありそのままの変数名を使い続けて良いのかわかりませんが、上記のrequireで正常に動作します。しかしいつどこで不具合が出ないとも限りませんので_async等の変数名を使った方が良さそうです。

bluebird

bluebirdはV8のPromiseを拡張したようなライブラリです。

map,reduce,filter等のArray.prototypeに定義されているようなメソッド郡が使用できます。

API Reference | bluebird

# 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. 最後の引数はコールバック関数を定義すること
  2. コールバック関数の第1引数はerrorであること
  3. コールバック関数の第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 } ]

thencatchに渡す部分がスッキリしています。

上記はmochaのテスト形式ですがasyncの方はit "async.parallel",(done) ->から始まるのに対してPromiseの方はit "Promise.all", ->となっています。つまり(done)がありません。

mochaで非同期関数をテストする時には(done)というコールバック関数を引数に与える必要があります。

これはdone()が呼ばれるまでテスト継続だよ、という意味です。

しかしPromiseの場合はPromiseオブジェクトを返すと自動的に非同期処理する部分を汲み取ってくれるためにdoneが不要になっているのです。

MochaがPromisesのテストをサポートしました | Web Scratch

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

これを使うと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)には気になるメソッドがたくさんありますのでもうちょっと調べてみようと思います。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4