2019年1月18日

Node.js

Callback を撲滅せよ

  • このエントリーをはてなブックマークに追加

こんにちは。言語サポート(Node.js)チームの伊藤(@koh110)です。

Node.js v10 も10月にLTSとなり async/await によるフロー制御は当たり前のように利用されるようになってきました。JavaScriptの非同期処理は async/await から覚える人も今後増えていくでしょう。今回はそんな非同期処理について、社内での事例を交えて記事を書いていこうと思います。

index

  1. Promise 化がなぜ重要なのか
  2. ユーザーに promisify をさせる落とし穴
  3. Road to Promise
  4. まとめ

Promise 化がなぜ重要なのか

ちょうど3年前のアドベントカレンダーで、今後はいろいろなモジュールが Promise 対応をするはずという話をしていましたが、実際に対応もどんどん進んでいるように感じます。おかげでここ最近の JavaScript の非同期処理は劇的に記述しやすくなっています。
https://techblog.yahoo.co.jp/javascript/nodejs/Node-es6/

どれだけ簡単になったか見てみましょう。下記の仕様を満たすコードを作成します。

  1. __filename のバックアップファイル ${__filename}-${timestamp} を作成する
  2. バックアップファイルを ReadOnly にする
  3. 最新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パターンです。

  1. Callback省略時にPromiseが返るスタイル
  2. 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つのパターンがあるため、それぞれを解説します。

  1. require('fs').promises スタイル
  2. .promise() スタイル
  3. funcAsync スタイル

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

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加

アンケートにご協力ください