Patrick Triest氏がブログで投稿した記事「ASYNC/AWAIT WILL MAKE YOUR CODE SIMPLER (Posted Aug 11, 2017)」を翻訳してご紹介しています。
なお、この記事は原著者の許諾を得て翻訳・掲載しています。
コールバック関数を書くのを止め、JAVASCRIPT ES8を愛した理由
時には、現代のJavascriptプロジェクトが手に負えなくなることもあります。この主な原因は、非同期タスクの面倒な処理です。この処理によって、コードブロックは、長く複雑で深く入れ子になってしまいます。Javascriptは、これらの操作を処理するための新しい構文を提供するようになりました。それによって、最も複雑な非同期操作を簡潔で読みやすいコードに変えることができます。
背景
AJAX(非同期JavaScriptとXML)
最初に簡単な歴史を説明します。1990年代後半、非同期Javascriptの最初の大きな打開策としてAjaxが生まれました。この技術により、HTMLが読み込まれた後にウェブサイトが新しいデータを引き出して表示できるようになりました。これは、ほとんどのウェブサイトがコンテンツの更新を表示するためにページ全体を再度ダウンロードしなければならない時代において、革新的なアイデアでした。この技術(jQueryのバンドルされたhelper関数によって名前が普及した)は、2000年代のすべてのウェブ開発を支配していました。そして、Ajaxは、現在ウェブサイトがデータの検索に使用する主な技術ですが、JSONの代わりにXMLが使われています。
NodeJS
NodeJSが2009年に最初に発表されたとき、サーバーサイド環境の主な焦点は、プログラムが適切に同時(並行)実行を処理できるようにすることでした。当時のほとんどのサーバーサイド言語は、操作が終了するまでコード補完をブロックすることによってI/O操作(入出力操作)を処理しました。Nodejsは代わりに、イベントループアーキテクチャを利用しました。これにより、開発者は、Ajax構文が機能するのと同様の方法で、ノンブロック非同期操作が完了した後にトリガーされる「コールバック」関数を割り当てることができます。
Promise
数年後、NodeJSとブラウザ環境の両方で「Promise」という新しい標準が登場しました。Promiseは、非同期操作を合成するための強力かつ標準化された方法を提供します。Promiseは依然としてコールバックベースのフォーマットを使用していましたが、非同期操作のチェイニングと合成のための一貫した構文を提供していました。人気のあるオープンソースライブラリによって開拓されたPromiseは、最終的に2015年にJavascriptのネイティブ機能として追加されました。
Promiseは大きく改善された標準でしたが、やや冗長で読みにくいコードブロックの原因となることがよくありました。
ようやくその解決策が登場しました。
async/awaitは、コールバックを持たない通常の同期関数であるかのようにPromiseを合成する新しい構文です(.NETとC#から借りています)。async/awaitは、昨年のJavascript ES7で追加された、Javascript言語における素晴らしい構文であり、既存のJSアプリケーションを単純化するために使用することができます。
使用例
いくつかのコードの例を紹介します。
これらの例を実行するために必要なライブラリはありません。async/awaitは、Chrome、Firefox、Safari、Edgeの最新バージョンで完全にサポートされているため、ブラウザコンソールで例を試すことができます。さらに、async/await構文は、Nodejsバージョン7.6以降で動作し、BabelとTypescriptのトランスバイラによってサポートされているため、現在のすべてのJavascriptプロジェクトで実際に使用することができます。
セットアップ
自分のマシンを使って理解したい場合は、このダミーのAPIクラスを使用してください。このクラスは、呼び出された後の200msの単純なデータで解決されるPromiseを返すことによって、ネットワーク呼び出しをシミュレートします。
class Api {
constructor () {
this.user = { id: 1, name: 'test' }
this.friends = [ this.user, this.user, this.user ]
this.photo = 'not a real photo'
}
getUser () {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.user), 200)
})
}
getFriends (userId) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.friends.slice()), 200)
})
}
getPhoto (userId) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(this.photo), 200)
})
}
throwError () {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('Intentional Error')), 200)
})
}
}
それぞれの例では、①ユーザーを取得する、②友人を取得する、③その画像を取得するという3つの同じ操作を順番に実行します。最後に、3つの結果すべてをコンソールに記録します。
試行1 - 入れ子になった(ネスト化した)Promiseコールバック関数
入れ子になった(ネスト化した)Promiseコールバック関数を使用した実装は以下のとおりです。
function callbackHell () {
const api = new Api()
let user, friends
api.getUser().then(function (returnedUser) {
user = returnedUser
api.getFriends(user.id).then(function (returnedFriends) {
friends = returnedFriends
api.getPhoto(user.id).then(function (photo) {
console.log('callbackHell', { user, friends, photo })
})
})
})
}
これは、おそらくJavascriptプロジェクトに関わったことがある方はよくご存知でしょう。合理的に単純な目的を持っているコードブロックは長く深い入れ子になっていて、このように終わります……。
})
})
})
}
実際のコードベースでは、それぞれのコールバック関数がかなり長くなる可能性があり、長く大きくインデントされた関数になる可能性があります。コールバック内のコールバック内のコールバックを処理するこのタイプのコードを処理することは、一般的に「コールバック地獄」と呼ばれています。
さらに悪いことに、エラーチェックが行われないので、コールバックのいずれかがPromiseのunhandled rejection(未処理の拒否)として何の表示もないまま失敗する可能性があります。
試行2 - Promiseの連鎖
改善できるかどうか見てみましょう。
function promiseChain () {
const api = new Api()
let user, friends
api.getUser()
.then((returnedUser) => {
user = returnedUser
return api.getFriends(user.id)
})
.then((returnedFriends) => {
friends = returnedFriends
return api.getPhoto(user.id)
})
.then((photo) => {
console.log('promiseChain', { user, friends, photo })
})
}
Promiseの素晴らしい特徴の一つは、それぞれのコールバックの中で別のPromiseを返すことによって連鎖させることができることです。そのため、すべてのコールバックを同じインデントレベルに保つことができます。また、コールバック関数の宣言を省略するためにarrow関数も使用しています。
このバリアントは、前のものよりも読みやすく、連続性が優れていますが、まだ非常に冗長で、少し複雑です。
試行3 - async/await
コールバック関数を使用せずに書くことができるとしたらどうでしょう?不可能だと思いますか?7行で書いてしまうというのはどうでしょう?
async function asyncAwaitIsYourNewBestFriend () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const photo = await api.getPhoto(user.id)
console.log('asyncAwaitIsYourNewBestFriend', { user, friends, photo })
}
ずっといいですね。Promiseの前に「await」を呼び出すと、Promiseが解決されるまで関数のフローが一時停止され、その結果が等号の左側の変数に代入されます。このようにして、あたかもそれが通常のコマンドの同期列であるかのように非同期操作フローをプログラムすることができます。
この時点で皆さんが私と同じくらい興奮していることを願っています。
「async」は関数宣言の最初で宣言されていることに留意してください。これは必須事項であり、関数全体をPromiseに変えます。このことについては後に掘り下げます。
ループ
async/awaitを使用すると、これまでの複雑な操作を簡単に行うことができるようになります。例えば、ユーザーの友人のそれぞれの友人リストを順番に取得したい場合はどうすればよいのでしょう?
試行1 - 再帰的Promiseループ
以下は、通常のPromiseを使って各友人のリストを順番に取り出すコードです。
function promiseLoops () {
const api = new Api()
api.getUser()
.then((user) => {
return api.getFriends(user.id)
})
.then((returnedFriends) => {
const getFriendsOfFriends = (friends) => {
if (friends.length > 0) {
let friend = friends.pop()
return api.getFriends(friend.id)
.then((moreFriends) => {
console.log('promiseLoops', moreFriends)
return getFriendsOfFriends(friends)
})
}
}
return getFriendsOfFriends(returnedFriends)
})
}
リストが空になるまで友人の友人を取り出すために、Promiseを再帰的に連鎖させる内部関数を作り出しています。げげっ。これは完全に関数的で、それ自体はいいのですが、かなり簡単な作業のために非常に複雑な解決策を施しているように思えます。
注 - Promise.all()を使用してpromiseLoops()関数を単純化しようとすると、かなり異なる方法で振る舞う関数になってしまいます。この例の目的は、操作を順番に(一度に1つずつ)実行することですが、Promise.all()は非同期操作を同時に(すべてを一度に)実行するために使用されます。しかし、次のセクションで説明しますが、Promise.all()はasync/awaitと組み合わせても非常に強力です。
試行2 - async/awaitのFor-Loop
こちらの方がはるかに簡単かもしれません。
async function asyncAwaitLoops () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
for (let friend of friends) {
let moreFriends = await api.getFriends(friend.id)
console.log('asyncAwaitLoops', moreFriends)
}
}
再帰的なPromiseの閉じを書く必要はありません。for-loopを書くだけで大丈夫です。async/awaitがあなたのために動いてくれます。
並行処理
それぞれの追加の友人リストを1つずつ取得するのは少し遅いですね。並行して取得するのはどうでしょう?async/awaitを使って実行することはできるのでしょうか?
もちろん、可能です。それによってすべての問題を解決してくれます。
async function asyncAwaitLoopsParallel () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const friendPromise = friends.map(friend => api.getFriends(friend.id))
const moreFriends = await Promise.all(friendPromise)
console.log('asyncAwaitLoopsParallel', moreFriends)
}
並行して操作を実行するには、実行するPromiseの配列を形成し、パラメータとしてPromise.all()に渡します。これは、awaitに単一のPromiseを返し、すべての操作が完了したら解決します。
エラー処理
しかし、非同期プログラミングにおいて、まだ取り組んでいない重要な問題が一つあります。それはエラー処理です。多くのコードベースの災いである非同期エラー処理では、各操作の個々のエラー処理コールバックを記述することがよくあります。コールスタックの先頭にエラーをパーコレートすると複雑になる可能性があり、通常、すべてのコールバックの開始時にエラーがスローされたかどうかをはっきりと確認する必要があります。このアプローチは退屈で、冗長で、エラーを起こしやすいです。さらに、Promiseにスローされた例外は、適切に捕らえなければ何の表示もないまま失敗するため、不完全なエラーチェックを伴うコードベースでは「目に見えないエラー」につながってしまいます。
例に戻って、それぞれにエラー処理を追加してみましょう。エラー処理をテストするために、ユーザーの写真を取得する前に、追加の関数である「api.throwError()」を呼び出します。
試行1 - Promiseエラーコールバック
最悪のシナリオを見てみましょう。
function callbackErrorHell () {
const api = new Api()
let user, friends
api.getUser().then(function (returnedUser) {
user = returnedUser
api.getFriends(user.id).then(function (returnedFriends) {
friends = returnedFriends
api.throwError().then(function () {
console.log('Error was not thrown')
api.getPhoto(user.id).then(function (photo) {
console.log('callbackErrorHell', { user, friends, photo })
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}, function (err) {
console.error(err)
})
}
これは、単純に最悪です。非常に長くて不格好であるだけでなく、制御フローが、通常の読み取り可能なコードのように上から下ではなく、外側から内側にフローしているため、全くもって直観的ではありません。最悪です。次に進みましょう。
試行2 - Promise連鎖の「Catch」メソッド
Promiseの「Catch」メソッドを組み合わせることで、この状態を少し改善することができます。
function callbackErrorPromiseChain () {
const api = new Api()
let user, friends
api.getUser()
.then((returnedUser) => {
user = returnedUser
return api.getFriends(user.id)
})
.then((returnedFriends) => {
friends = returnedFriends
return api.throwError()
})
.then(() => {
console.log('Error was not thrown')
return api.getPhoto(user.id)
})
.then((photo) => {
console.log('callbackErrorPromiseChain', { user, friends, photo })
})
.catch((err) => {
console.error(err)
})
}
こちらの方がいいですね。Promise連鎖の最後にある単一のCatch関数を利用することで、すべての操作に対して単一のエラー処理を行うことができます。しかし、まだ複雑です。そのため、通常のJavascriptエラーと同じ方法でそれらを処理するのではなく、特別なコールバックを使用して非同期エラーを処理する必要があります。
試行3 - 通常のTry/ Catchブロック
さらに改善できます。
async function aysncAwaitTryCatch () {
try {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
await api.throwError()
console.log('Error was not thrown')
const photo = await api.getPhoto(user.id)
console.log('async/await', { user, friends, photo })
} catch (err) {
console.error(err)
}
}
ここでは、通常のTry/Catchブロックで操作全体を包み込んでいます。このようにすると、同期コードと非同期コードから全く同じ方法でエラーをスローして捕らえることができます。ずっとシンプルです。
コンポジション
先ほど、「async」とタグ付けされた関数は実際にPromiseを返すと述べました。これによって、非同期制御フローを簡単に合成することができます。
例えば、先ほどの例を再構成して、ログに記録する代わりにユーザーデータを返すことができます。次に、Async関数をPromiseとして呼び出すことによって、データを取得することができます。
async function getUserInfo () {
const api = new Api()
const user = await api.getUser()
const friends = await api.getFriends(user.id)
const photo = await api.getPhoto(user.id)
return { user, friends, photo }
}
function promiseUserInfo () {
getUserInfo().then(({ user, friends, photo }) => {
console.log('promiseUserInfo', { user, friends, photo })
})
}
さらに良い点として、レシーバ関数でもasync/await構文を使用することができます。これにより、非同期プログラミングが非常にはっきりとした、取るに足らないブロックになります。
async function awaitUserInfo () {
const { user, friends, photo } = await getUserInfo()
console.log('awaitUserInfo', { user, friends, photo })
}
では、最初の10名のユーザーのすべてのデータを取得しなければならない場合はどうでしょう?
async function getLotsOfUserData () {
const users = []
while (users.length < 10) {
users.push(await getUserInfo())
}
console.log('getLotsOfUserData', users)
}
並行処理は行っていますか?また、隙のないエラー処理関数を備えていますか?
async function getLotsOfUserDataFaster () {
try {
const userPromise = Array(10).fill(getUserInfo())
const users = await Promise.all(userPromise)
console.log('getLotsOfUserDataFaster', users)
} catch (err) {
console.error(err)
}
}
結論
シングルページのjavascript ウェブアプリケーションの登場とNodeJSの採用の拡大に伴い、Javascript開発者にとって、並行処理を適切に処理することがこれまで以上に重要になっています。async/awaitは、何十年もJavascriptコードベースを悩ましていたバグを誘発する制御フローの問題の多くを軽減します。また、どんな非同期コードブロックも、大幅に短く、簡単に、自明にしてくれます。async/awaitは主流ブラウザとNodeJSにほぼ普遍的にサポートされているため、これらの技術を独自のコーディング手法やプロジェクトに取り入れるには、今が最高のタイミングです。
Patrick Triest
フルスタックエンジニア、データ愛好家、飽くことのない学習者、偏執的なビルダー。迷っていないふりをしながら、よく山道を歩き回っています。
https://github.com/triestpa
CREDIT:原著者の許諾のもと翻訳・掲載しています。
[原文]ASYNC/AWAIT WILL MAKE YOUR CODE SIMPLER (Posted Aug 11, 2017) by Patrick Triest