2016年7月4日
AsyncとAwait : コールバック地獄を避けるための最新のやり方、そしてその未来
(2016-06-02)by 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);
});
});
});
}
このコードは、もっと簡単で安全に書けることが分かっています。
以下では、 Promise
に async
/ 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は 単一の 値を表現します。前述のコールバックパターンとは異なり、エラーを得たあとで特定の呼び出しを成功させることはできません。ある値を得たあとにエラーを得ることもできません。
Promise
の resolve
は return
に相当し、 reject
は throw
に相当すると見なすことができます。後ほど見ていきますが、この意味上の等価性は async
、 await
というキーワードで 構文的に実現 されます。
スケジューリングに関しては、 Promise
の仕様はコールバックを必ず”未来の時点”(例:次のマイクロタスク)で呼び出すように 定めて います。このため、 Promise
の挙動は、状態が 確定済み か否かにかかわらず、 then
や catch
を呼び出す際には常に非同期的になります。
最初の例をこの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)
を至る所に書く(または、もっと悪い対処としては無視する)ことがなくなるので、生産性と正確さが向上するでしょう。
今後の見通しは、どの程度立っているのでしょうか?
Promise
は既に、 モバイルおよびデスクトップのモダンブラウザ 全てとNode.js 0.12以降に対応しています。async
/await
は V8 、 Edge 、 Firefox にほぼ完全に実装されています。
教訓
▲ZEIT ではこれらの機能をもう何カ月も使ってきましたが、非常に満足していますし、作業の生産性も上がっています。
私は最近、BabelとNode 6を用いたトランスパイル用 ガイドを公開 しました。これはES6のサポートに優れているため、今や 変換プラグインを2つ しか必要とせず、コンパイルの性能も素晴らしいものとなっています。
ブラウザや古いバージョンのNodeへのサポートが必要な場合は、 es2015
プリセットもインクルードすることを推奨します。そうすれば、ジェネレータではなくステートマシンへとコンパイルすることができます。
この機能を最大限に活用するにはエコシステムから、コールバックだけでなく、 Promise
を使用するモジュールを使うべきでしょう。
- 既に両方を実現しているモジュールもあります。例えば
node_redis
は、メソッドに Async というサフィックスを付ければ、Promise
を使用します。 - 既存のモジュールを
Promise
でラップするためのモジュールもあります。これは通常、then
やpromise
というプレフィックスかサフィックスで識別できます。例えば、 fs-promise や then-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
を 無視 することができても、そのエラーが後で実際に発生したときにクラッシュして判明するでしょう。
でも普通、 async
と await
を使ってエラーを無視するのは非常に困難です。例外的に、非同期コードの エントリポイント が使えるでしょう。
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
は幅広い使用事例に適しています。
下記の例では前記の例の趣旨に沿って、 Observable
を setInterval
と共に使用して、時間が経過した後に値を返します。
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
インスタンス)。
async
と await
に関してはどうでしょうか? ある Observable
の挙動を Promise
と同じに制限する演算子(RxJSなどのライブラリには 既にあります )を実装して、 await
することもできます。
await toPromise(timer(1000));
この例は、動作の一般化を示します。 timer
関数は delay
と同じ使い勝手がありますが、さらにインターバル にも 使えます。
将来の方向
async
と await
を使えば、コードベースを著しく改善することができます。
私たちのオープンソースライブラリ 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
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa