POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Guillermo Rauch

本記事は、原著者の許諾のもとに翻訳・掲載しております。

(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 イベントを発する。コールバックには、複数の値を伴って始動するものもある。