AsyncとAwait : コールバック地獄を避けるための最新のやり方、そしてその未来

(2016/7/7、いただいたフィードバックを元に記事を修正いたしました。)

JavaScript、特にNode.jsといえば、コールバック地獄がよく連想されます1。たくさんの非同期I/Oを扱うコードを書いたことがある方には、おそらく以下のようなパターンはおなじみでしょう。

export default function getLikes () {
  getUsers((err, users) => {
    if (err) return fn(err);
    filterUsersWithFriends((err, usersWithFriends) => {
      if (err) return fn(err);
      getUsersLikes(usersWithFriends, (err, likes) => {
        if (err) return fn (err);
        fn(null, likes);
      });
    });
  });
}

このコードは、もっと簡単で安全に書けることが分かっています。

以下では、Promiseasync / awaitを組み合わせて書く方法をご紹介しますが、これらの新しい機能を本番環境で使った経験から得た教訓についてもお話ししたいと思います。

まずは、上記の例に潜む落とし穴を見ていきましょう。

コールバックの問題

エラー処理を繰り返している

エラーは、次の処理に渡すだけでいいという場合が大多数です。

ですが上記の例では、何度も繰り返し書いています。また、returnを入れ忘れてしまい、実際にエラーが起きて初めて(それとないデバッグをしてみて)気づくことが多いでしょう。

エラー処理が指定されていない

エラーが発生すると、一般的なライブラリは大抵、Errorパラメータと共にコールバックを呼び出し、成功した場合には代わりにnullを使います。

ただ残念ながら、必ずしもそうなるとは限りません。nullではなくfalseを使う場合もあるのです。これを完全に省略するライブラリもあります。いくつものエラーが起きたら、コールバックが何度も呼び出されるかもしれません。これは次の問題を引き起こしてしまいます。

スケジューリングが指定されていない

コールバックは直ちに開始されるのでしょうか? それとも別のマイクロタスク上で? または別のティック(tick)上で? 時々? 常に?

それは誰にも分かりません。自分が書いたコードを読んでもきっと分からないでしょう。ライブラリのドキュメントを読めば、運が良ければ分かるかもしれません。

コールバックが、予期せず複数回開始されることもあり得ます。やはり、この場合もほぼ間違いなく、デバッグが極めて難しいコードになってしまうでしょう。

コードが動き続ける場合もあるかもしれませんが、見込まれたとおりの動作はしないでしょう。そうでない場合は、スタックトレースが得られるかもしれませんが、それが根本的原因を必ずしも明らかにしてくれるとは限りません。

こうした問題の解決策となるのが、Promiseでの標準化です。

Promiseの働き

promiseは、明確な取り決めとAPIを提供します。その取り決めの細部やAPIが最善のものであるかは意見が分かれるでしょうが、定義は厳密になされています。

したがって、エラー処理やスケジューリングが指定されていないという前述の問題については、Promiseを用いたコードを扱っている際は心配する必要がありません。

以下のコードは、setTimeoutに相当する処理がPromiseを使うとどのように書けるかを示したものです。

function sleep (time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}

sleep(100)
.then(() => console.log('100ms elapsed'))
.catch(() => console.error('error!'));

promiseには、resolved、rejectedという2つの確定された状態があります。上の例で分かるように、resolvedの値とrejectedの値を得るために1組のコールバックを設定することができます。

コールバックをpromiseに渡すということは、誤った二分法のような状況になりがちです。言うまでもなく、promiseが意味のある動作をするにはコールバックが必要です。したがって、本当の比較対象となるのはpromiseと、JavaScriptコミュニティが暗黙のうちに了解しているコールバックパターンなのです2

promiseは単一の値を表現します。前述のコールバックパターンとは異なり、エラーを得たあとで特定の呼び出しを成功させることはできません。ある値を得たあとにエラーを得ることもできません。

Promiseresolvereturnに相当し、rejectthrowに相当すると見なすことができます。後ほど見ていきますが、この意味上の等価性はasyncawaitというキーワードで構文的に実現されます。

スケジューリングに関しては、Promiseの仕様はコールバックを必ず”未来の時点”(例:次のマイクロタスク)で呼び出すように定めています。このため、Promiseの挙動は、状態が確定済みか否かにかかわらず、thencatchを呼び出す際には常に非同期的になります。

最初の例をこのAPIで書くと、以下のようになります。

export default function getLikes () {
  return getUsers()
  .then(users => filterUsersWithFriends)
  .then(usersWithFriends => getUsersLikes);
}

既にずっと良くなっていますね。とはいうものの、ロジックを変えるとすれば、コードのリファクタリングがたちまち複雑になってしまいます。

仮に、上記のコードでは、filterUsersWithFriendsの特定のエラー型については異なる処理が必要だとしましょう。

export default function getLikes () {
  return new Promise((resolve, reject) => {
    getUsers().then(users => {
      filterUsersWithFriends(users)
      .then(resolve)
      .catch((err) => {
        resolve(trySomethingElse(users));
      });
    }, reject)
  });
}

“便利な仕組み”をどんなに連鎖させても効果は上がりません。解決策を見ていきましょう。

asyncとawaitの今後

C#とF#の世界では以前から知られているように、この問題を解決する見事な方法があります。

export default async function getLikes () {
  const users = await getUsers();
  const filtered = await filterUsersWithFriends(users);
  return getUsersLikes(filtered);
}

上記を機能させるために必要なのは、依存対象のI/Oを実行する関数(getUsersなど)が確実にPromiseを返すようにしておくことだけです。

これでコードが(前述の連鎖した例よりも)読みやすくなるだけでなく、エラー処理の挙動が通常の同期的なJavaScriptコードと全く同じになります。

すなわち、関数をawaitすると、エラーは(もしあれば)表に出てthrowされます。上記のgetLikes関数が呼び出される場合、エラーはデフォルトで発生します。特定のエラーについては異なる処理をしたいという場合は、awaitの呼び出しをtry/catchでラップすればいいのです。

こうすれば、if (err) return fn(err)を至る所に書く(または、もっと悪い対処としては無視する)ことがなくなるので、生産性と正確さが向上するでしょう。

今後の見通しは、どの程度立っているのでしょうか?

教訓

▲ZEITではこれらの機能をもう何カ月も使ってきましたが、非常に満足していますし、作業の生産性も上がっています。

私は最近、BabelとNode 6を用いたトランスパイル用ガイドを公開しました。これはES6のサポートに優れているため、今や変換プラグインを2つしか必要とせず、コンパイルの性能も素晴らしいものとなっています。

ブラウザや古いバージョンのNodeへのサポートが必要な場合は、es2015プリセットもインクルードすることを推奨します。そうすれば、ジェネレータではなくステートマシンへとコンパイルすることができます。

この機能を最大限に活用するにはエコシステムから、コールバックだけでなく、Promiseを使用するモジュールを使うべきでしょう。

  • 既に両方を実現しているモジュールもあります。例えばnode_redisは、メソッドにAsyncというサフィックスを付ければ、Promiseを使用します。
  • 既存のモジュールをPromiseでラップするためのモジュールもあります。これは通常、thenpromiseというプレフィックスかサフィックスで識別できます。例えば、fs-promisethen-sleep

以上に加えて、Nodeでは標準ライブラリにおいてPromiseを直接返すことが検討されています。その議論はこちらでご覧いただけます。

同じく強調しておきたいのは、この構文によってPromiseがコードベースからなくなるわけではないということです。実際、頻繁に必要になりますので、完全に理解していなくてはなりません。

Promiseが出現する一般的な例としては、ループの一部で複数の値を必要とするコードが挙げられます。その際、複数の値は同時に要求されます。

const ids = [1, 2, 3];
const values = await Promise.all(ids.map((id) => {
  return db.query('SELECT * from products WHERE id = ?', id);
}));

また、前述の例(async getLikes())で、return await getUserLikes()ではなくreturn getUserLikes()としていることにも注目してください。

asyncというキーワードの目的は、関数がPromiseを返すようにすることですので、これら2つのスニペットは等価です。

つまり、以下のコードは完全に有効であり、「await付きで呼び出された場合は次のマイクロタスクで処理の解決を行う」という点を除けば、相当する同期処理const getAnswer = () => 42と等価なのです。awaitなしで呼び出された場合はPromiseを返します。

async function getAnswer () {
  return 42;
}

ブールヴァード・オブ・ブロークン”プロミス”

上記で、コールバックを使うと遭遇しがちな多くの問題を解決するために定められたPromiseの仕様を述べてきました。

次に、未解決の問題と新たに生まれた問題について説明するとともに、まだ仕様が定まっていないものの我々のニーズにとって重要な挙動について説明します。

デバッグの難しさ

Promiseを使うときにエラーハンドラを付けないと、多くの環境ではエラーに関する情報は決して得られないでしょう。

これはコールバックパターンでerrパラメータを無視することと同じですが、目的の値にアクセスしようとするとTypeErrorが発生する可能性があるという点が違います。

コールバックパターンでは、なんとかしてerr無視することができても、そのエラーが後で実際に発生したときにクラッシュして判明するでしょう。

でも普通、asyncawaitを使ってエラーを無視するのは非常に困難です。例外的に、非同期コードのエントリポイントが使えるでしょう。

async function run () {
   //アプリケーションコード
}

run().catch((err) => {
   //必ずエラー処理を!
});

幸いなことに、この問題には回避策と、おそらく決定的な解決策があります。

  • ChromeとFirefoxは、未処理rejectionに関して開発者ツール内で警告を発します。
  • Node.jsはunhandledRejectionを発するので、それを使うと手動でログをとることができます。バックエンドシステムのための未処理rejectionの暗示に関するこの議論を一読されるようお勧めします。
  • 将来的にはawaitトップレベルのサポートによって、Promiseを手動でインスタンス化してキャッチする必要はなくなるでしょう。

最後に、先に私は「Promiseは1回しか解決されないので、コールバックのように期待に反して何度も繰り返されることはない」と書きました。

ここにも問題があるのです。Promiseはその後の解決を帳消しにし、さらに問題なことにrejectionも帳消しにしてしまいます。ログに記録されることのないエラーが存在するかもしれません。

キャンセル

元々のPromise仕様では、ある値の非同期の読み込みが進行しているときに、それをキャンセルすることの意味は考慮されていませんでした。

運命の巡り合わせか、ブラウザのベンダーは、HTTPリクエストのように昔からキャンセルの必要があった関数の戻り値として、このキャンセルを実装するようになりました。

つまり、XMLHttpRequestを使えば、結果のオブジェクトにabortを呼び出すことができるのに、ピカピカの新機能fetchでは……なんと、呼び出せないのです。

TC39は現在、第3の状態、つまり「キャンセル済み」を追加することを検討しています。第一段階の案について、詳しくはこちらを参照してください。

Promiseには改めて付け加えられることになるのですが、キャンセルは、次に説明する抽象化、Observableの必須な属性なのです。

Observable

この記事では既に、Promiseで解決を待つことは、ある関数が何らかの動作を行いながら同期的に値を返すことに似ているということを明らかにしました。

Observableは、ゼロ個以上の値を返すことのできる関数呼び出しを表す、より一般的な(したがって、より強力な)抽象化です。

Promiseとは異なり、Observableオブジェクトは同期的(同一ティック)または非同期的に値を返すことができます。

このような設計によって、Observableは幅広い使用事例に適しています。

下記の例では前記の例の趣旨に沿って、ObservablesetIntervalと共に使用して、時間が経過した後に値を返します。

function timer (ms) {
  return new Observable(obv => {
    let i = 0;
    const iv = setInterval(() => {
      obv.next(i++);
    }, ms);
    return () => clearInterval(iv);
  });
}

前述したように、Observableは幅広い可能性に対応します。この観点から見れば、Promiseは単に、単一の値を返すObservableなので、次のようになります。

function delay(ms) {
  return new Observable(obv => {
    const t = setTimeout(() => {
      obv.next();
      obv.complete();
    }, ms);
    return () => clearTimeout(t);
  });
}

Observableのセットアップで返される値はクリーンアップを行う関数だということに注意してください。そのような関数は、残りのサブスクリプションがなくなると実行されます。

const subscription = delay(100).subscribe();
subscription.unsubscribe();  //cleanup happens

このことは、Promiseに欠けている別の役割、つまりキャンセルをObservableが担うということを意味します。このモデルでは、キャンセルは、単にobservationが終わったことの結果です。

そうは言っても、非同期の動作の多くはPromiseサブセットだけで表現しても上手くいきます。実際、Node.jsのコアライブラリのうち多くでは、それしか必要ありません
(例外はStreamと、いくつかのEventEmitterインスタンス)。

asyncawaitに関してはどうでしょうか? あるObservableの挙動をPromiseと同じに制限する演算子(RxJSなどのライブラリには既にあります)を実装して、awaitすることもできます。

await toPromise(timer(1000));

この例は、動作の一般化を示します。timer関数はdelayと同じ使い勝手がありますが、さらにインターバルにも使えます。

将来の方向

asyncawaitを使えば、コードベースを著しく改善することができます。

私たちのオープンソースライブラリmicroは、リクエスト応答サイクルをどれほど単純明快にできるかの良い例です。

下記のマイクロサービスは、あるデータベースのJSONエンコードされたユーザ配列を返します。
ハンドラのいずれかがスローすれば、応答はアボートされてerr.statusCodeが返ります。
未処理の例外が発生すると、応答500が生成され、エラーがログ記録されます。

export default async function (req, res) {
  await rateLimit(req);
  const uid = await authenticate(req);
  return getUsers(uid);
}

前述したように、ES6モジュールに最上レベルのawaitを認めるための提案がなされています。Node.jsでは、これは次のようなコードが書けることを意味します。

import request from 'request';
const file = await fs.readFile('some-file');
const res = await request.post('some-api', { body { file } });
console.log(res.data);

では、これをラッパーなしで(そして、簡単明瞭なエラー処理を使って)実行してみましょう!

▲ node my-script.mjs

同時にObservableは、この言語の主要な構成体になるように、TC39で進歩し続けています。

同時性と同期性を管理するための、この新しいプリミティブ群がJavaScriptエコシステムに非常に深い影響を及ぼすと私は確信しています。その時がやってきました。

本記事の提供元であるZEITは、Node.jsを利用したWebサイト・アプリケーション・サービスのクラウドへのデプロイを高速化・容易化するサービス、nowを提供しています。
https://zeit.co/now


  1. コールバック地獄を見分けるテスト:インデントの形が波動拳を繰り出すリュウの姿に一致するか? 

  2. このパターンをまとめると次のようになります:コールバックは、別のtickにおいて1回呼び出される。その際の第1パラメータは、エラーの場合はErrorオブジェクト、それ以外の場合はnullとなる。第2パラメータは本来意図した返り値である。ただし、エコシステムの中では、この暗黙の了解に反するケースに遭遇することはよくある。例えば、いくつかのライブラリはエラーオブジェクトを省略して、他のどこかでerrorイベントを発する。コールバックには、複数の値を伴って始動するものもある。