PromiseとCallbackの両方に対応する関数の書き方

こんにちは。Tokyo Otaku Mode(TOM)ソフトウェアエンジニアの稲田です。

TOMが開発、運営している自社ECサイトのotakumode.comのバックエンドは現在Node.jsで動いています。その中で多数の関数が定義され利用されていますが、そのほとんどがCallbackを取るようになっています。
しかし最近、バックエンドのNode.jsのバージョンがv6.11.xにアップデートされジェネレータが使えるようになり、co + yieldの組み合わせで同期的な書き方ができるようになりました。このco + yieldで書くために、PromiseとCallbackの両方に対応した関数を書いたときの問題とその解決方法を書いておきます。

TL;DR

const f = (callback = () => {}) => {
  const p = new Promise((resolve, reject) => {
    ......
    if (err) {
      return reject(err);
    }
    resolve(obj);
  });
  p
    .then(obj => {
      process.nextTick(() => {
        callback(null, obj);
      });
    })
    .catch(err => {
      process.nextTick(() => {
        callback(err);
      });
    });
  return p;
};

なぜPromiseとCallbackの両方に対応するのか

PromiseとCallback、どちらの世界でも同じ関数(=処理)を使いたいため両方に対応した関数にします。なぜならば、別の関数にすると処理が分散してしまい、将来変更があった際に変更箇所が増え、バグの原因になりやすいからです。
otakumode.comのコードベースはすでにかなりの大きさになっているため、従来のCallback関数を渡すようになっている処理をすべてPromiseを使うように変えるのは現実的ではありません。
逆にPromiseを使わないという選択肢もありますが、新しいコードはできるかぎり直感的にわかりやすく書きたいのでco + yieldを使っていきたいところです。

これらを満たすためには、すでにあるコードは変更せずに新しいコードはco + yieldで書けるよう、Callbackで呼び出せ、かつPromiseを返す関数にする必要があります。

PromiseとCallbackを混ぜたときの問題

Callbackを取りつつPromiseを返す関数は素直に書くと、例外の扱いに困ることになります。

const f = (callback = () => {}) => {
  const p = new Promise((resolve, reject) => {
    ......
    if (err) {
      return reject(err);
    }
    resolve(obj);
  });
  p
    .then(obj => {
      callback(null, obj); // ここで例外が発生した場合に困る。
    })
    .catch(err => {
      callback(err);
    });
  return p;
};

一見すると良さそうに見えますが、このコードだと、渡されたCallbackの中で例外が発生した場合に例外が.catchに捕捉されてしまい、トップレベルまで例外が上がらなくなります。また、.then.catchの両方が実行されることになるので、2重にCallbackが呼ばれてしまいます。

const f = (callback = () => {}) => {
  const p = new Promise((resolve, reject) => {
    resolve('ok');
  });
  p
    .then(obj => {
      console.log('then');
      callback(null, obj);
    })
    .catch(err => {
      console.log('catch');
      callback(err);
    });
  return p;
};

let count = 0;
f(() => {
  console.log(`call ${++count}`);
  throw new Error('test');
});
then
call 1
catch
call 2
(node:2713) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: test

解決方法

process.nextTickの中でCallbackを呼ぶようにすれば、例外が発生したときでも.catchに捕捉されずに例外がトップレベルまで上がります。

const f = (callback = () => {}) => {
  const p = new Promise((resolve, reject) => {
    resolve('ok');
  });
  p
    .then(obj => {
      console.log('then');
      process.nextTick(() => {
        callback(null, obj);
      });
    })
    .catch(err => {
      console.log('catch');
      process.nextTick(() => {
        callback(err);
      });
    });
  return p;
};

let count = 0;
f(() => {
  console.log(`call ${++count}`);
  throw new Error('test');
});
then
call 1
/path/to/repo/after.js:24
  throw new Error('test');
  ^

Error: test
    at f (/path/to/repo/after.js:24:9)
    at process.nextTick (/path/to/repo/after.js:9:9)
    at _combinedTickCallback (internal/process/next_tick.js:73:7)
    at process._tickCallback (internal/process/next_tick.js:104:9)
    at Module.runMain (module.js:606:11)
    at run (bootstrap_node.js:389:7)
    at startup (bootstrap_node.js:149:9)
    at bootstrap_node.js:504:3

まとめ

今回はPromise/Callback両対応をする際の問題と解決方法を書きました。新規プロジェクトではPromiseのみで書いてもいいと思いますが、既存のコードに組み込む際にはPromise/Callback両対応するのが現実的です。そういった場合にこの記事が一助となれば幸いです。
最後に、TOMではサーバサイドエンジニアを募集しています。Node.jsでバックエンドを書いてみたい! 私ならもっとうまくPromiseとCallbackを扱える! という方は下記リンクから応募ください。

https://www.wantedly.com/projects/8214

それでは良いPromiseライフを!