JavaScript
Node.js
es7

Promiseを返す関数を同期的に繰り返し実行したい

最近はNode.jsで簡単なスクリプトを実行することが多く、その時に改めてasync/awaitわかりやすいなーと感じることがあったのでそれについてつらつら記述していきます。

何をしたいか

私がやっていた作業は、「データベースのデータを全て抜き出す」というものでした。
普通に全てのレコードを取得しようとすると、DBサーバーが"too large"とエラーを返すので、
少しずつ取得してスクリプト側でマージしていく必要がありました。
「取得結果が0件になるまで繰り返しレコードを取得」という処理をしたいわけです。(イメージを下に書いておきます)

let result;
let from = 0;
const count = 1000;
while (true) {
  from += count;
  result = record_shutoku(from, count); // record_shutoku()は単純にレコードの配列を返してくれると想定
  if (result.length == 0) {
    break;
  }
}

何が問題なのか

record_shutoku()みたいな重い処理はJSでは大抵非同期実行になります。今回はこれがPromiseを返す関数だと想定します。

何回繰り返すのかがわかっていないというのが今回の問題で、
繰り返す回数がわかっていて、並列実行して構わないのであればPromise.all()を使えばOKです。
(今回は深い事情により事前にレコードの件数が取得できず、しかも並列実行もできない状況でした。お金があれば解決できたかもしれません)

また非同期実行関数(Promiseを返す関数)の同期実行版が用意されているのであれば、whileとそれを使えば解決です。(上で書いたイメージがまさにそれです)
けど普通は用意されてないことの方が多いと思います。

  • 用意してる例: fs.readFile()fs.readFileSync()
  • 用意してない例: fetch()

用意してないこと自体は全く問題ないです。むしろ正常な姿だと思います。

さて非同期実行関数を同期的に繰り返すにはどうしたら良いものか。

async/awaitが使える場合

async/awaitがあるとあっさり解決

Node.js 8以降を使っていれば簡単に解決する。
record_shutoku()Promiseを返す関数と想定すると、

async function main() {
  let result;
  let from = 0;
  const count = 1000;
  while (true) {
    from += count;
    result = await record_shutoku(from, count); // record_shutoku()はPromiseを返してくれると想定
    if (result.length == 0) {
      break;
    }
  }
}

main(); // 実行

このように書ける。処理全体をasync関数として作って、awaitrecord_shutoku()の前につけるだけで良い。
もしエラー処理をしたければrecord_shutoku()をtry/catchしてあげるだけで良い。

確認のために実行できる例

私の場合は脆弱だったHigh Sierra上で、asdfとNode.js 8.9.1を使って実行を確認しています。

// 受け取った値 + 1をPromiseで返すだけの関数
// for文は最初に実行されるcalcほど処理を重くするためのもので、本当に同期実行されてるか確認するためのものです。
function calc(num) {
  return new Promise((resolve, reject) => {
    for (let i = 1; i <= 10 - num; i++) {
      console.log(`  calc(${num}): ${i}`);
    }
    return resolve(num + 1);
  });
}

async function main() {
  console.log('start');
  let num = 0;
  while (true) {
    num = await calc(num);
    console.log('main(): result is ' + num); // ここは同期実行できてるか確認するためのものです。
    if (num >= 10) {
      break;
    }
  }
  console.log('done');
}

main();

実行すると以下のような出力がされて、同期的に実行できてることがわかる。

start
  calc(0): 1
  calc(0): 2
  calc(0): 3
  calc(0): 4
  calc(0): 5
  calc(0): 6
  calc(0): 7
  calc(0): 8
  calc(0): 9
  calc(0): 10
main(): result is 1
  calc(1): 1
  calc(1): 2
  calc(1): 3
  calc(1): 4
  calc(1): 5
  calc(1): 6
  calc(1): 7
  calc(1): 8
  calc(1): 9
main(): result is 2

... # 省略

main(): result is 9
  calc(9): 1
main(): result is 10
done

まとめ1

  • async/awaitありがたい
    • 非同期処理の関数を同期的に繰り返すのも簡単
    • try/catchも普通に使える

async/awaitが使えない場合どうすればいいですか?

今回の私の状況はまさにそれで、Node.js 6を使わなければいけない事態でした。(お金と時間があればNode.js 8にアップデートできたかもしれません)
したがってasync/awaitが使えません。

とりあえず軽くやり方を探ってみた

検索してみて、見つかったのは以下でした。

一番シンプルと思われるものを抜粋

var p = Promise.resolve();
for (let i=1; i<=10; i++) {
    p = p.then(() => promiseReturner(i));
}

ただ上記のやり方だと繰り返す回数がわかっているという前提が必要なのと、async/awaitでやっているbreakが実現できない。

どうすればできるか

以下のように再帰関数を使うと実現できます。(実は独力で解決したわけではなく、弊社のハイパーエンジニアに助けていただきました)

// calc()自体はasync/awaitの例と全く同じ
function calc(num) {
  return new Promise((resolve, reject) => {
    for (let i = 1; i <= 10 - num; i++) {
      console.log(`  calc(${num}): ${i}`);
    }
    return resolve(num + 1);
  });
}

function kurikaeshi_calc(num) {
  if (num >= 10) {
    return Promise.resolve(num);
  } else {
    return calc(num).then((n) => {
      console.log('kurikaeshi_calc(): result is ' + n);
      return kurikaeshi_calc(n);
    });
  }
}

console.log('start');
kurikaeshi_calc(0).then(num => {
  console.log('done');
});

出力はasync/awaitのものと同じなので省略。

少しだけ解説

処理の本流は

  1. console.log('start')で文字列出力
  2. kurikaeshi_calc(0)calc(0).then(...)でできるPromiseを返す
  3. それに対して.then(num => console.log('done'))で次の処理を登録

これで終了。
問題はPromiseの中での処理。

  1. calc(0).then(...)...kurikaeshi_calc(1)、すなわちcalc(1).then(...)を返す
  2. calc(1).then(...)...kurikaeshi_calc(2)、すなわちcalc(2).then(...)を返す
  3. (省略)
  4. calc(9).then(...)...kurikaeshi_calc(10)、すなわちPromise.resolve(10)を返す

上記のように最後以外は .then()によって次の処理を登録済みのPromise を返してくるので、
それら全部の実行が終わるまで.then(num => console.log('done'))は待っていることになる。

まとめ2

  • async/awaitありがたい
  • 再帰関数でも実はそれなりにシンプルに書ける
    • 関数型プログラミング思考は大事だと再認識
    • エラー処理は普通に.catch()すればOK
    • とはいえ(人によっては)ややこしく思える

Promiseが使えない場合どうすればいいですか?

頑張ってください。
すいません。特に必要に迫られてないので考えてないです。
たぶんPromiseの例とそんなに変わらないと思ってます。

総括

今回はES7のasync/awaitの便利さを感じるとともに、自分の関数型プログラミング力の甘さを再認識するテーマとなりました。
ただ元も子もないことを言えば、適当なスクリプト書く程度であればそもそも無理してNode.jsを使わなくてもいいんですけどね。

明日は @magicant さんの「cd コマンドの罠」です。罠とはなんだろう...気になる!