こんにちは。言語サポート(Node.js)チームの伊藤(@koh110)です。
Node.js v10 も10月にLTSとなり async/await によるフロー制御は当たり前のように利用されるようになってきました。JavaScriptの非同期処理は async/await から覚える人も今後増えていくでしょう。今回はそんな非同期処理について、社内での事例を交えて記事を書いていこうと思います。
index
Promise 化がなぜ重要なのか
ちょうど3年前のアドベントカレンダーで、今後はいろいろなモジュールが Promise 対応をするはずという話をしていましたが、実際に対応もどんどん進んでいるように感じます。おかげでここ最近の JavaScript の非同期処理は劇的に記述しやすくなっています。
https://techblog.yahoo.co.jp/javascript/nodejs/Node-es6/
どれだけ簡単になったか見てみましょう。下記の仕様を満たすコードを作成します。
- __filename のバックアップファイル ${__filename}-${timestamp} を作成する
- バックアップファイルを ReadOnly にする
- 最新5件のバックアップファイルを残して古いバックアップファイルを削除する
ちなみにこれは言語サポートチームが社内でやっている Node.js ハンズオンで実際に説明に利用しているサンプルです。
Callback 時代のフロー制御
最初期の Node.js でよく見たコードです。見事な Callback Hell ですね。
const fs = require('fs');
const path = require('path');
const backup = `${__filename}-${Date.now()}`;
fs.copyFile(__filename, backup, (err) => {
if (err) return console.error(err);
fs.chmod(backup, 0o400, (err) => {
if (err) return console.error(err);
fs.readdir(__dirname, (err, files) => {
if (err) return console.error(err);
const backupFiles = files.filter((file) => {
return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
}).map((file) => {
return path.join(__dirname, file);
});
const dropFiles = (files, callback) => {
if (files.length === 0) {
return callback(null);
}
console.log('unlink:', files[0]);
fs.unlink(files.shift(), (err) => {
if (err) return callback(err);
dropFiles(files, callback);
})
};
dropFiles(backupFiles.reverse().slice(5), (err) => {
if (err) return console.error(err);
console.log('done');
});
});
});
});
Callback Hell はなぜ嫌われるのでしょうか。
プログラムはいつか仕様が変更されるものです。ある時、仕様変更により「バックアップファイルを ReadOnly にする」というステップを削除することになりました。この時の差分を見てみましょう。
const backup = `${__filename}-${Date.now()}`;
fs.copyFile(__filename, backup, (err) => {
if (err) return console.error(err);
- fs.chmod(backup, 0o400, (err) => {
+ fs.readdir(__dirname, (err, files) => {
if (err) return console.error(err);
- fs.readdir(__dirname, (err, files) => {
- if (err) return console.error(err);
+ const backupFiles = files.filter((file) => {
+ return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
+ }).map((file) => {
+ return path.join(__dirname, file);
+ });
+
+ const dropFiles = (files, callback) => {
+ if (files.length === 0) {
+ return callback(null);
+ }
- const backupFiles = files.filter((file) => {
- return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
- }).map((file) => {
- return path.join(__dirname, file);
- });
-
- const dropFiles = (files, callback) => {
- if (files.length === 0) {
- return callback(null);
- }
-
- console.log('unlink:', files[0]);
- fs.unlink(files.shift(), (err) => {
- if (err) return callback(err);
-
- dropFiles(files, callback);
- })
- };
-
- dropFiles(backupFiles.reverse().slice(5), (err) => {
- if (err) return console.error(err);
- console.log('done');
- });
+ console.log('unlink:', files[0]);
+ fs.unlink(files.shift(), (err) => {
+ if (err) return callback(err);
+
+ dropFiles(files, callback);
+ })
+ };
+
+ dropFiles(backupFiles.reverse().slice(5), (err) => {
+ if (err) return console.error(err);
+ console.log('done');
});
});
});
おそらく多くの人が理解に苦しむのではないでしょうか。ネストの深いコードは本来変更した部分以外のインデントを変更してしまうため、差分を追うコストが非常に高くなってしまいます。
また、Callback 利用時には毎回必ずエラーハンドリングを行わなければなりません。これを忘れるとプロセスをクラッシュさせる可能性が高く、当時の Node.js でアプリケーションを作るのは難しいという印象を与えていた要因にもなっていました。
Promise 時代のフロー制御
Callback の問題点を解消した新しい非同期処理を実現しようと、次に登場したのが Promise です。Node.js では v4 から起動フラグなしで Promise が利用可能になりました。
Promise は JavaScript の API らしくチェインによってフロー制御をします。.then
をつなげていくことで、それぞれの非同期処理の終了を待ち受け、直列に実行できます。さらに Promise が Callback より優れている点として、.catch((err) => { ... })
による包括的なエラーハンドリングもあげられ、Callback の弱点をうまく解消しています。
また Promise.all
による並列実行など Callback に比べ、より多彩な記述が可能になりました。
Node.js の標準モジュールの util.promisify
は、下記の慣例に従った Callback を Promise 化できます。
- 引数の最後が Callback関数であること
- Callback が必ず呼び出されること
- Callback にわたされる第一引数がエラーを表すこと
これらを利用して先ほどの Callback によるフロー制御を書き直すと、次のようなコードになります。
const fs = require('fs');
const path = require('path');
const util = require('util');
const copyFileAsync = util.promisify(fs.copyFile);
const chmodAsync = util.promisify(fs.chmod);
const readdirAsync = util.promisify(fs.readdir);
const dropFiles = (files, callback) => {
if (files.length === 0) {
return callback(null);
}
console.log('unlink:', files[0]);
fs.unlink(files.shift(), (err) => {
if (err) return callback(err);
dropFiles(files, callback);
});
};
const dropFilesAsync = util.promisify(dropFiles);
const backup = `${__filename}-${Date.now()}`;
copyFileAsync(__filename, backup).then(() => {
return chmodAsync(backup, 0o400);
}).then(() => {
return readdirAsync(__dirname);
}).then((files) => {
const backupFiles = files.filter((file) => {
return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
}).map((file) => {
return path.join(__dirname, file);
});
return dropFilesAsync(backupFiles.reverse().slice(5));
}).then(() => console.log('done')).catch((err) => console.error(err));
Promise を利用することでネストを浅く保つことができるようになりました。そのおかげで先ほどの仕様変更を適用した場合でも何をしたかが伝わりやすくなっています。
const dropFilesAsync = util.promisify(dropFiles);
const backup = `${__filename}-${Date.now()}`;
copyFileAsync(__filename, backup).then(() => {
- return chmodAsync(backup, 0o400);
-}).then(() => {
return readdirAsync(__dirname);
}).then((files) => {
const backupFiles = files.filter((file) => {
async/await 時代のフロー制御
Promise の登場により JavaScript は Callback Hell からの独立を果たしました。しかしまだまだ可読性が低く記述量も多いです。また、ループや条件分岐など、Promise では記述しにくい処理も多く残っています。そこで、Promise の糖衣構文として async/await が導入されました。async/await を利用することで非同期処理を、同期的な見た目で記述することが可能です。
async/await を利用して Promise を書き直すを下記のようになります。
'use strict';
const fs = require('fs');
const path = require('path');
const util = require('util');
const copyFileAsync = util.promisify(fs.copyFile);
const chmodAsync = util.promisify(fs.chmod);
const readdirAsync = util.promisify(fs.readdir);
const unlinkAsync = util.promisify(fs.unlink);
const dropFilesAsync = async (files) => {
for (let i = 0; i < files.length; i++) {
console.log('unlink:', files[i]);
await unlinkAsync(files[i]);
}
};
const backup = `${__filename}-${Date.now()}`;
const main = async () => {
await copyFileAsync(__filename, backup);
await chmodAsync(backup, 0o400);
const files = await readdirAsync(__dirname);
const backupFiles = files.filter((file) => {
return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
}).map((file) => {
return path.join(__dirname, file);
});
await dropFilesAsync(backupFiles.reverse().slice(5));
};
main()
.then(() => console.log('done'))
.catch((err) => console.error(err));
このソースの中で重要なフロー制御は main
関数です。 Promise に比べて処理のフローがかなりわかりやすくなったのではないでしょうか。
const main = async () => {
await copyFileAsync(__filename, backup);
await chmodAsync(backup, 0o400);
const files = await readdirAsync(__dirname);
const backupFiles = files.filter((file) => {
return (new RegExp(`^${path.basename(__filename)}-[0-9]{13}$`)).test(file);
}).map((file) => {
return path.join(__dirname, file);
});
await dropFilesAsync(backupFiles.reverse().slice(5));
};
function の前に async を宣言すると AsyncFunction が利用できます。AsyncFunction の内部では await を使って Promise を呼び出すことで、その Promise から結果が返って来るまで次の処理の実行を保留します。このため AsyncFunction の内部では、非同期処理であっても同期コードのように処理の結果を変数に格納したり、Promise が苦手としていたループや条件分岐をより直感的に記述することが可能になりました。
const dropFilesAsync = async (files) => {
for (let i = 0; i < files.length; i++) {
console.log('unlink:', files[i]);
await unlinkAsync(files[i]);
}
};
このような歴史をたどり JavaScript における非同期処理の記述コストは大きく下がりました。あと数年もたてば Callback なんて使ったことがない、 async/await 以外書いたことがない、という 開発者も当たり前にでてくるのではないでしょうか。われわれ ES5 時代に生きてきた JavaScript エンジニアは老人扱いされる時代がきっとくるでしょう。
ユーザーに promisify をさせる落とし穴
ヤフーでは Java と Node.js がサーバーサイド標準言語として定められています。これはあくまで社内のプラットフォームが優先的に対応する言語という位置付けで、その他の言語が使えないわけではありません。
Node.js はフロントエンド技術者のツール利用で必須なことはもちろん、その手軽さやライブラリの豊富さ、ブラウザーコードとのコンテキストスイッチが少ない、などのメリットも相まり、ヤフー社内での採用例は急速に増えています。
そんな流れの中で顕在化した問題が、社内に存在する SDK がまだ Callback のインターフェースしか提供していないことがある、という事実でした。Callback も優れたインターフェースではありますが、async/await を利用したフロー制御に組み込みにくいという問題があります。
そういった言語に依存する問題をキャッチアップし、解消することが各言語サポートチームのメインの仕事です。そこで最近社内で Callback しか提供していない SDK に Promise のインターフェースを追加する PR を出しました。
その時に起きた議論の中で、Node.js の util.promisify
があるからユーザーがそれを使えばいいじゃないか、といった意見がありました。モジュールはなるべくシンプルであるのが望ましいですし、これはこれで正しい意見だと思います。ですが自分は Promise 化に関してはユーザーではなくモジュール側でやるべきであると考えています。
これは実際にあった事例ですが、例えば下記のような req.js
というモジュールがあるとします。今はクラス構文があるのでこの書き方を見ることも少なくなりましたが、起きうる問題は同じです。
const request = require('request');
function RequestClass(url) {
this.url = url
}
RequestClass.prototype.get = function (cb) {
request.get(this.url, (err, data) => cb(err, data));
}
module.exports = RequestClass;
利用する側のソースコードは下記のようになります。
const Request = require('./req.js');
const req = new Request('https://www.yahoo.co.jp');
req.get((err, data) => {
if (err) return console.error(err);
console.log(data);
});
これをユーザー側が util.promisify を利用して Promise 化した場合、意図せず下記のようなコードを書いてしまう可能性があります。
const Request = require('./req.js');
const req = new Request('https://www.yahoo.co.jp');
const getAsync = require('util').promisify(req.get);
getAsync()
.then((data) => {
console.log(data);
}).catch((err) => {
console.error(err);
});
これは何が悪いでしょうか。実際に実行してみましょう。
$ node main.js
TypeError: Cannot read property 'url' of undefined
at get (/home/dev/req.js:9:18)
JavaScript に慣れている人であればすぐに気付けると思いますが、req.get
の中で this.url
を参照しています。getAsync
で単体の関数として抜き出してしまうと this が変わってしまうため this.url
が undefined となってしまいます。
正しく Promise 化しようと思うと下記のようなコードになります。
const Request = require('./req.js');
const req = new Request('https://www.yahoo.co.jp');
req.getAsync = require('util').promisify(req.get);
req.getAsync()
.then((data) => {
console.log(data);
}).catch((err) => {
console.error(err);
});
これはユーザーがモジュールの内部実装を知らなければ気づけないバグです。モジュールの内部実装はどう変わるかわからないので、そのような対応を強いてしまうのは問題ですし、内部実装を知らないまま利用できたほうがユーザーフレンドリーである事は間違いありません。
以上のことからNode.jsサポートチームでは、Promise 化は技術的にはユーザー側での対応も可能だが、Promise のインターフェースはモジュール側が提供しているのがベストであると考えています。
みなさんも自分がメンテナンスしているモジュールに Callback のインターフェースがあるならば、必ず Promise のインターフェースも実装しましょう。
Road to Promise
といっても Promise のインターフェースを追加する手法はたくさんあり、どれを選択すればよいのかは非常に悩ましい問題です。そこで今回はその手法について、世の中の流れやユーザーの利便性を含めいくつかの手法を検討・議論しました。その中でまとまったのが下記4パターンです。
- Callback省略時にPromiseが返るスタイル
- Promise専用のインターフェースを加える
2-1.require('fs').promises
スタイル
2-2..promise()
スタイル
2-3.funcAsync
スタイル
それぞれの手法の Good/Bad ポイント、サンプルコードを記載しています。また、モジュールのサンプルコードは社内向けに段階的に async/await に対応すること(Node.js v10 になったとはいえ即座に async/await に移れるものではないので)、モジュールの互換性を壊さないこと、ということを前提にしているため、なるべく Promise のみで実装しています(async/await を利用すると Promise でラップされてしまうため、undefinedを返していたcallback形式の関数が返り値の互換性を崩してしまう可能性があります)。モジュールの利用対象が async/await を利用できる環境に限定できる場合はもう少しスマートに対応ができると思います。
1. Callback省略時にPromiseが返るスタイル
関数の引数から Callback を省略すると Promise が返ってくるようにするスタイルです。
Good | 新規で Promise のみのAPIを提供するのであればこの形です。今後は最初からインターフェースに Promise を採用したモジュールが多くなると予想されるため、長期的に見るとこの形をとった関数に集約されていくと思われます。 | いずれモジュールの Callback API を廃止する予定であれば、返り値を Promise に固定できるのでこの形式がベストです。 |
Google や MongoDB などマーケットを見る限り採用例は多く、JavaScript の文化的にパラメータを省略して挙動が変わる仕組みは多く存在しています。 | |
Bad | 既に Promise 以外の値を返している関数の場合、API 仕様の変更が必要なため互換性が破壊されます(メジャーバージョンアップになる)。例えば setTimeout のようなCallback 関数を受け取りつつ、timeoutID も返す Timer 系メソッドなどがこれに該当します。 |
Callback 関数の多重呼び出しを避けるために、Callback が与えられた場合は Promise を返さないよう気をつけなければならない(Callback と Promise は排他の関係)。 |
このスタイルを採用しているモジュール
サンプルコード
一度全体の処理を Promise でラップし、Callback が与えられている場合は Promise の実行後に Callback を呼びだすように変更します。将来的に Callback のインターフェースを廃止し、Promise に統一する場合 if 文を削除し Promise を返すだけで対応が完了できるので、この形に修正するのが省コストだと思います。
Before
const get = (opt, cb) => {
request.get({ url: opt.url }, (err, res) => {
cb(err, res);
});
};
exports.get = get;
After
const get = (opt, cb) => {
const p = new Promise((resolve, reject) => {
request.get({ url: opt.url }, (err, res) => {
if (err) return reject(err);
resolve(res);
});
});
if (cb) {
p.then((res) => cb(null, res)).catch((err) => cb(err));
} else {
return p;
}
};
exports.get = get;
2. Promise専用のインターフェースを加えるスタイル
Callback のインターフェースは残しつつ、呼び方を変えることで Promise のインターフェースを返すスタイルです。
この方法には3つのパターンがあるため、それぞれを解説します。
2-1. require('fs').promises
スタイル
Node.js が採用しているスタイルです。読み込む先を変えることでインターフェースが Callback から Promise に変わります。
Good | 開発者、ユーザー視点でもともと存在する Callback の関数に変更がありません。 |
ユーザー視点では複数のメソッドをまとめて Promise 化できるため利用のコストが低いです。 | |
引数の静的解析が容易です(TypeScript を利用した場合エラーとして検出できます)。 | |
Bad | 開発者視点では Promise で全てラップする必要があるため、改修時のコストが高くなります。 |
このスタイルを採用しているモジュール
- Node.js
サンプルコード
exports している要素が関数の場合
.promises に入れるオブジェクトには、Callback を与えられないように util.promisify を使わずに Promise 化しています。
const get = (opt, cb) => {
if (!cb) throw new Error('cb must be a function');
request.get({ url: opt.url }, (err, res) => {
cb(err, res);
});
};
exports.get = get;
exports.promises = {
get: function(opt) {
return new Promise(function(resolve, reject) {
this.get(opt, (err, res) => {
if (err) return reject(err);
resolve(res);
})
});
}
};
exports している要素がオブジェクトの場合
Node.js の内部コードでは Object.defineProperties
を利用して promises オブジェクトを定義しています。後から意図しない再代入を防げるという点で、ただオブジェクトに代入するより優れているように見えますが、Object.defineProperty
をもう一度実行することで上書きは不可能ではありません。
Object.defineProperties
で複雑に見えてしまいますが、実質的にやっていることは obj.promises
に promisify されたオブジェクトを代入しているだけです。
// Promise化前のオブジェクト
const obj = {
get: (opt, cb) => {
request.get({ url: opt.url }, (err, res) => {
cb(err, res);
});
}
};
Object.defineProperties(obj, {
promises: {
configurable: true,
enumerable: false,
get: () => {
return {
get: (opt) => {
return new Promise((resolve, reject) => {
obj.get(opt, (err, res) => {
if (err) {
return reject(err);
}
resolve(res);
})
});
}
};
}
}
});
module.exports = obj;
exports している要素がクラスの場合
RequestPromises というクラスを作成し、Reuqestクラスの promises にぶら下げます。 RequestPromises の get メソッドでは親クラスの get メソッドを呼び出します。この際、this がずれないように arrow function を使ってはいけません。あえてクラス構文を使っていないので読みづらくはなっていますが、気をつけるべき部分は同じです。クラスの場合はどうしても注意点や黒魔術的な書き方をしなければならないことが多い気がします。利用者の安全性や利便性を取ると、モジュール側が担保しなけらばならない部分が多くなりがちなのは大変ですよね。
Before
function Request(opt) {
this.url = opt.url
}
Request.prototype.get = function (opt, cb) {
if (!cb) throw new Error('cb must be a function');
request({ url: opt.url ? opt.url : this.url }, (err, res) => cb(err, res));
}
module.exports = Request;
After
const util = require('util');
function Request(opt) {
this.url = opt.url
}
Request.prototype.get = function (opt, cb) {
if (!cb) throw new Error('cb must be a function');
request.get({ url: opt.url ? opt.url : this.url }, (err, res) => cb(err, res));
}
function RequestPromises(opt) {
Request.call(this, opt);
};
util.inherits(RequestPromises, Request);
RequestPromises.prototype.get = function (opt) {
return new Promise(function (resolve, reject) {
Request.prototype.get.call(this, opt, (err, res) => {
if (err) return reject(err);
resolve(res);
})
});
}
Object.defineProperties(Request, {
promises: {
configurable: true,
enumerable: false,
get: () => RequestPromises
}
});
module.exports = Request;
NOTE: Node.jsは require('fs/promise')
というスタイルを最初に採用していたが、上記の方法に変わりました。/
による呼び分けはネームスペースの衝突の可能性があるため避けた方がよいでしょう。
2-2. .promise()
スタイル
メソッドチェーンによって Promise を受け取れるようにするスタイルです。
const promise = func(opts).promise();
Good | Promise や Stream などの複数のインターフェースを返すことができます。 AWSでは .createReadStream() や MongoDB の toArray() などが該当します。 |
利用者視点ではもともと存在する Callback の関数に変更がありません。 | |
メソッドごとに Promise 対応ができます(ゆるやかにバージョンアップが可能)。 | |
Bad | Callback を省略しないインターフェースが許されているため、Callback が与えられた場合は .promise() を返さないよう気をつけなければなりません(Callback 省略スタイルと同様)。 |
このスタイルを採用しているモジュール
サンプルコード
対応方法は Callback省略時にPromiseが返るスタイル に似ています。オブジェクトやクラスの場合のサンプルコードはrequire('fs').promises
スタイル と同様なのでそちらを参照してください。
const get = (opt, cb) => {
const p = new Promise((resolve,reject) => {
request.get({ url: opt.url }, (err, res) => {
if (err) return reject(err);
resolve(res);
});
});
if (cb) {
p.then((res) => cb(null, res)).catch((err) => cb(err))
} else {
return { promise: () => p };
}
};
exports.get = get;
2-3. funcAsync
スタイル
元の関数名に Async と付けた別名の関数を用意し提供するスタイルです。
bluebird モジュールの promisifyAll()
が昔から採用している方法です。
Good | 開発者が関数の中身に手を加える必要がありません。 |
関数別に Promise 化するかどうかを選択できます。 | |
対応方法がとてもシンプルです。 | |
Bad | 将来的に Callback を廃止する場合に互換性をどうするか考える必要があります。互換性を完全に破壊して「Async なし」で Promise を提供する形式に変更するか、ある程度の互換性を維持するために「Async あり」で Promise を提供するか、もしくは「Async なし・あり」のどちらでも Promise を提供するなどの方法が考えられますが、「Async なしで Promise を提供する」場合はこれまで非推奨ではない Promise を利用していたユーザーに影響が及びます。しかし「Async ありで Promise を提供する」場合、冗長な関数名を維持し続ける必要があります。 |
このスタイルを採用しているモジュール
サンプルコード
この場合対応方法はとてもシンプルで、ただ単に別名のラッパーを定義していくだけです。
const get = (opt, cb) => {
request.get({ url: opt.url }, (err, res) => {
cb(err, res);
});
};
const getAsync = (opt) => {
return new Promise((resolve,reject) => {
get(opt, (err, res) => {
if (err) return reject(err);
resolve(res);
});
});
};
exports.get = get;
exports.getAsync = getAsync;
まとめ
Callback なモジュールに Promise のインターフェースを加える方法について、Good/Bad ポイントやサンプルコードをまとめました。
Node.js に Promise がきたばかりの頃は Callback の方がパフォーマンスがいいんだ!といった声もありましたが、V8 の進化などもあり現在ではほぼ問題を感じないレベルのパフォーマンスを出しています。読みやすくてパフォーマンスも出るならば採用しない手はありません。そのためにはモジュールのインターフェースが Promise を提供している世界が必要です。この記事がそんな世界を作る一助となれば幸いです。
みんなで幸せな Node.js ライフを生み出しましょう!
Ref
- Google API の Promise 化対応
https://github.com/googleapis/google-api-nodejs-client/pull/1091
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました