util.promisify が追加された

Node.js のコアに util.promisify が追加された。 github.com

今回は util.promisify が持つ役割を中心に Node.js における Promise の立場についても話していけるといいと思う。

util.promisify とは

読んで字のごとく関数を Promise に変換してくれるユーティリティメソッド。 下記のような要領で変換できる。

const util = require('util');
const fs = require('fs');

const stat = util.promisify(fs.stat);
stat('.').then((stats) => {
  console.log(stats);
}).catch((error) => {
  console.error(error);
});

async-awaitを使いたい場合(Node.js v7の最新では既にenabled)は下記の通り

const util = require('util');
const fs = require('fs');

const stat = util.promisify(fs.stat);

async function callStat() {
  try {
    const stats = await stat('.');
    console.log(stats.uid);
  } catch(e) {
    console.error(e);
  }
}

util.promisify 注意事項

Node.js のコアメソッドに限らず、他のメソッドもPromiseに変換できる、ただし、変換する場合はその関数がpromisifyの規約に従っている必要がある。

その規約というのは、

  • コールバック関数を引数の最後に取ること ( function(arg1, arg2, cb){} )
  • コールバック関数の最初の引数はエラーであること ( cb(err, res) )

である、これに従わない関数の場合はうまくPromisifyされないので要注意、特に2つ目の規約に違反しているとエラーじゃないものがPromise.reject の対象になってしまうことがある。

Node.js のコアメソッドのほとんどは上記の規約に従ったコールバック関数を取るが、Node.jsの規約から外れたコールバックの使い方をしているメソッドの場合は一工夫が必要になる。

例えば、 setTimeoutsetImmediate の場合がそうなる、これらはコールバック関数を最初の引数に要求するし、コールバック関数の最初の引数はエラーとは限らない。

こういった関数を Promise に変換したい場合は util.promisify.custom をプロパティにしてカスタマイズされたPromisify関数を提供してあげる必要がある。

const test = function(cb, arg){ cb(arg) }
test[util.promisify.custom] = (arg) => { return new Promise((resolve, reject) => { test(resolve, arg); }) };

const p = util.promisify(test);
p('foo').then((arg) => console.log(arg));

Node.js の setTimeoutsetImmediate はこの util.promisify.custom を使ってcustomizeされたPromisifyを作っている。

Node.js における Promise の位置付け

フロントエンドでは async await が採用されたり、 Web 標準のAPIが採用していたりと、ほぼスタンダードな印象を受ける Promise だが、 Node.js の中では実はまだまだ議論の余地がある。

util.promisify を採用するかどうかを議論していた時にその場に居たので、要点をまとめると

unhandledRejectionの時の振る舞いが決まっていない

という一点につきる。

議論で話している感じは以下の通り:

  • Promise のニーズは高い、特に async await のような構文サポートまであるので強力
  • しかしながら、Promise の unhandledRejection が起きた時にNodeのデフォルトをどう動かすようにするかが未定
  • 現時点では warnings が出る。しかし、今やってる unhandledRejection はただ単に例外発生時に .catch をする Promise が その時点で いなかっただけであり、Promiseが例外をキャッチするのは仕様上いつでも良いので、この時点で出るwarningsとしては適切ではない(非同期にキャッチされる可能性があるため)。
  • 現在の仕様で Promies を使ったとして、例外をキャッチしなかった際に容易にメモリリークやファイルディスクリプタのリークが起きることは想像しやすく、やはりNodeコアの中でも簡単にリークが作り込めてしまうような状況にするべきではない、リークが気づきにくくなる位なら異常終了した方がマシ

というのが議論ポイントだった。

もう少し噛み砕くと、『 Promise を簡単に使えるようにする(util.promisifyを提供する)なら、 Promise を安全に使える手段として提供してあげる(リークを起こさないようにする)べき』という感じだろうか。

これに対しては現時点で提案中のデフォルトの unhandledRejection の動きで既に3,4候補存在する。

unhandledRejection が起きたら:

まだこの部分の議論は続いている。

Node.js Collaborators Summitにおける、約一時間の議論は発散して終わった感じがするが、 util.promisify を追加する件に関してはある程度の有用性、コアでやることの意義が認められてマージされた。

Promise とどう付き合っていくか

僕らアプリケーションをNode.jsで書いている側としては気をつけるべきなのは、 Promise が使いやすくなってきているが、まだまだ運用面での知見が少ないという点だと思う。

もしかしたらリークが起きてるけど気づいていないとか、例外がスローされていたけどそのまま放置されていたとかそういう事がないように Promise は気をつけて使うべきだろう。

実際に Node.js v4 では Promise でメモリリークが起きていた(現在は修正済み)

更に言うと、現時点の Promise には core-dump を出す仕組みもない(processが死んだ時の --abort-on-uncaught-exception 相当)。Promiseを使ってしまうとエラーになって死んだ時に解析がしにくいという側面もある。

www.joyent.com

util.promisifyができたことで、Promiseが使いやすくなっているが、この辺りはまだ仕様検討中なのでv8.0次第ではPromiseの使い勝手は変わる可能性もある。

2017/05/12 追記: Promiseを無限ループさせるとリークが起きるというのは仕様の問題であって、Node.jsの問題ではありませんでした。

まとめ

  • util.promisify の説明
  • Promise と Node の位置付け
  • Promise とどう付き合っていくか