この記事は, gumi Inc. Advent Calendar 2018 の 16 日目の記事です.
モチベーション
業務で JavaScript を使うことが久しぶりにあったのですが, 私が JavaScript を書いていた頃から非同期処理のあたりが大きく変化しており, 再学習が必要だったことから, 少し備忘録を残してみようと思いました.
とはいえ, async/await を理解するには Promise の理解が必要であり, Promise を理解するには, その登場背景を少し理解しておく必要があるため, 一記事で収まるように, これらを駆け足で解説していきます.
Promise 以前
私が JavaScript を業務で使用していたころは, XHR などの非同期処理はコールバックで結果を受ける書き方しかありませんでした. 以下にコード例を示します.
setTimeout(() => {
console.log('callback');
}, 0);
ES2015 から導入されたアロー関数式を使っていますが, そこは執筆時点が 2018年12月 ということで勘弁してください.
このようなコールバックを利用した書き方をするデメリットとして, 主に次の2つがあります.
- ルールが定まっておらず, 様々な流派が乱立しやすい
- 実業務では, 必要に迫られてコールバックを多段ネストすることが多い
結果, コードが非常に読み難くなります.
ルールが定まっておらず, 様々な流派が乱立しやすい
これが何かというと, 例えばコールバック関数の渡し方や, エラー処理の方法などです.
// 流派1: コールバックを関数を onXXX に入れる
const sa = new SomethingAsync();
sa.onsuccess = (result) => {console.log(result)};
sa.onfailure = (error) => {console.error(error)};
sa.do_something();
// 流派2: コールバック関数の第一引数に Error オブジェクトを渡す
do_something_async((error, result) => {
if (error) {
console.error(error);
} else {
console.log(result);
}
});
コールバック関数の中で Error オブジェクトを throw してもコールバック関数を設定したとこまで例外が伝播するわけではないため, このようにエラー情報を渡すような形式になります.
開発中のプロダクトでこれらを統一しようとチームでコーディングルールを決めたとしても, 使用するライブラリによって流派が異なることがあり, そちらに引きずられてしまうこともあります.
ちなみに, Node.js には Domain という廃止になった仕組みもあり, 非同期処理とエラー処理の試行錯誤の歴史が感じられます. 1
実業務では, 必要に迫られてコールバックを多段ネストすることが多い
これは分かりやすいですよね? 手続きとして順番を保証したいが, 非同期処理が挟まっているため, 辛いコールバックを多段でネストせざるを得ない状態は多々あります.
setTimeout(() => {
console.log('callback 1');
setTimeout(() => {
console.log('callback 2');
setTimeout(() => {
console.log('callback 3');
setTimeout(() => {
console.log('callback 4');
}, 0);
}, 0);
}, 0);
}, 0);
Promise 登場
Promise は非同期処理の幾つかある流派のうち, 覇権をとった流派です. コールバック関数の渡し方やエラー処理の方法は, Promise によって統一されました. 2
また, 多段にネストされたコールバックのネストを浅くすることにも成功しています.
非同期処理のルールを統一化
Promise を使用した, 非同期処理の記述例を示します.
function getPromise() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('success');
}, 0);
});
}
非同期処理を無名関数で包み, クロージャによって渡された「成功時の結果を渡す resolve」 と, 「失敗時の Error オブジェクトを渡す reject」を非同期処理の中で使用します.
他の例を示します.
// 流派1: コールバックを関数を onXXX に入れる
function getPromise1() {
return new Promise((resolve, reject) => {
const sa = new SomethingAsync();
sa.onsuccess = (result) => {resolve(result)};
sa.onfailure = (error) => {reject(error)};
});
}
// 流派2: コールバック関数の第一引数に Error オブジェクトを渡す
function getPromise2() {
return new Promise((resolve, reject) => {
do_something_async((error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
}
次に Primise を使用した, 非同期処理の呼び出し側の例を示します.
const promise = getPromise();
promise.then((result) => {
console.log(result);
}).catch((error) => {
console.error(error);
});
Promise オブジェクトに対して, 成功時の処理が記述されたコールバック関数と, 失敗時の処理が記述されたコールバック関数を渡します.
どの流派であったとしても, 全ての非同期処理を Promise で包んでしまえば, 利用者には then と catch という統一された I/F が提供されます.
多段コールバックからの解放
then と catch を, もう少し詳しく見ていきましょう.
then
then の定義は, Promise#then(onFulfilled, onRejected)
となっています. onFulfilled には成功時のコールバック関数を, onRejected には失敗時のコールバック関数を渡します.
getPromise().then((result) => {
console.log(result);
}, (error) => {
console.error(error);
});
これだけの説明だと catch など不要なのでは?という疑問を持ちますよね.
実は, onRejected は一つ前の処理のエラーを捕捉するものであり, メソッドチェーンで記述しておかなければ, then の中で発生したエラーを捕捉できません.
getPromise().then((result) => {
throw new Error('Do not catch.');
}, (error) => {
console.error(error); // getPromise() の中で発生したエラーを捕捉する
});
catch
catch の定義は, Promise#catch(onRejected)
となっており, 実は then(undefined, onRejected)
と意味が等価です.
getPromise().then((result) => {
console.log(result);
}).then(undefined, (error) => {
console.error(error);
});
getPromise().then().catch()
は getPromise().then().then()
と等価であり, then をメソッドチェーンで呼び出しているだけです.
promise のメソッドチェーン
then と catch は Promise オブジェクトを返すため, メソッドチェーンを繋げていけます.
function getPromise(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value);
}, 0);
});
}
getPromise(1).then((value) => {
return value + 1;
}).then((value) => {
return value * 10;
}).then((value) => {
console.log(value); // 20 を表示する
}).catch((error) => {
console.error(error);
});
ご覧の通り, コールバック内で return で返した値が, 次の then で指定したコールバックへ渡っています.
もし処理の途中で例外が発生した場合, それ以降の途中のコールバックは実行されずに, catch に渡したコールバックが実行されます.
getPromise(1).then((value) => {
return value / 0; // ここで例外が発生する
}).then((value) => {
return value * 10; // 実行されない
}).then((value) => {
console.log(value); // 実行されない
}).catch((error) => {
console.error(error); // エラー情報を表示する
});
よって, catch は単独で一番最後のメソッドチェーンで指定するのが良さそうです.
ネストからの解放
そして本題です. then に登録するコールバック関数を Promise オブジェクトを返すようにすると, 実行順が依存しあっている非同期処理のネストが浅くなります.
function getPromise1(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value);
}, 0);
});
}
function getPromise2(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value + 1);
}, 0);
});
}
function getPromise3(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value * 10);
}, 0);
});
}
getPromise1(1).then(getPromise2).then(getPromise3).then((value) => {
console.log(value);
}).catch((error) => {
console.error(error);
});
async/await 登場
Promise は JavaScript にとっては非常に有益であるため, 非同期処理は順調に Promise に包まれていったのではなかろうかと思います. [要出典]
前述したとおり, Promise の登場は, 非同期処理を読みやすくするためであったと私は考えています. しかし, Promise には Promise 特有の読み難さがあります. 非同期処理を Promise で包むとネストが深くなりますし, then のメソッドチェーンを読みこなすのも大変です.
そこで, async/await という Promise のシンタックスシュガーが登場しました. 3
async で Promise に包む処理の可読性を上げる
async は, Promise を包む処理のシンタックスシュガーです.
// これは
function somethingAsync() {
return new Promise((resolve, reject) => {
resolve('success');
});
}
// こう記述するのと等価
async function somethingAsync() {
return 'success';
}
元が何であったか忘れるくらい短くなっています.
しかし, 次の例はどうでしょうか?
function getPromise(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value + 1);
}, 0);
});
}
誤解を恐れずに言い切ると, 非同期処理を Promise で包む処理は, async で簡単にはなりません. これは, async/await を理解しようとするとき, Promise も理解する必要があると私が思い至った理由の一つでもあります.
async/await で then メソッドチェーンの可読性を上げる
await は, then のシンタックスシュガーです.
then にふれる前に, 後述する例に備えて, setTimeout と四則演算の処理を分離し, sleep 処理として切り出しておきます.
// 分離前
function getPromise(value) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(value + 1);
}, 0);
});
}
// sleep として切り出す
function mySleep(msec) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, msec);
});
}
// 前述の getPromise と等価の処理を記述した例
async function getPromise(value) {
await mySleep(0); // 0ms 待つ
return value + 1;
}
mySleep は Promise を返していますが, await を利用することで, 同期処理のように見えます.
続けて「ネストからの解放」で示した例を async/await に置き換えた例を示します.
async function getPromise1(value) {
await mySleep(0);
return value;
}
async function getPromise2(value) {
await mySleep(0);
return value + 1;
}
async function getPromise3(value) {
await mySleep(0);
return value * 10;
}
(async () => {
try {
let value = await getPromise1(1);
value = await getPromise2(value);
value = await getPromise3(value);
console.log(value);
} catch (error) {
console.log(error);
}
})();
await は async の中でのみ利用可能であるため, ネストが一つ深くなっていますが, 随分と素直で読みやすくなっています. エラー処理も try-catch 句で記述できています.
さらに非同期処理の間に同期処理が入るとどうなるでしょう?
then メソッドチェーンの例を示します.
getPromise1(1).then((value) => {
return value + 10;
}).then(getPromise2).then((value) => {
return value - 10;
}).then(getPromise3).then((value) => {
console.log(value);
}).catch((error) => {
console.error(error);
});
簡単な四則演算であれば, まだ読めないこともありませんが, そろそろ辛い状態です. 実世界であれば四則演算で済むわけがなく, さらに読み難さに拍車がかかると思われます.
次に async/await の例を示します.
(async () => {
try {
let value = await getPromise1(1);
value += 10;
] value = await getPromise2(value);
value -= 10;
value = await getPromise3(value);
console.log(value);
} catch (error) {
console.log(error);
}
})();
一件すると全てが同期処理に見えます.
Promise に立ち戻る
これは私が実際に業務でこのようなコードを書いてしまったという例です.
const _ = require('lodash');
async function do_process(orders) {
return _.map(orders, async (order) => {
return await do_something(order);
});
}
do_process に期待していたことは,「orders というリストを渡すから, 処理結果をリストで返して欲しい」でした. しかし, 処理には DB アクセスが伴うため async/await を駆使することにしました. 結果得られたのは, Promise オブジェクトのリストであり, 結果のリストではありませんでした.
ここで, Promise のことを思い出しましょう. Promise には, all という便利なメソッドがあります. これは, 引数として Promise オブジェクトのリストをとり, それらが全てが resolve されたときに, resolve される Promise オブジェクトを返してくれます.
do_process を Promise.all で使用する例を示します.
const results = await Promise.all(do_process(orders));
async/await で可読性の高いコードを書けるようにはなりましたが, この例のように Promise が出現することは避けられません. Promise と上手に付き合っていきましょう.
-
6年前, Domain を必死に調べたのは良い思い出 ↩
-
Promise Pipelining というものが他の言語にあり, そこから着想を得たらしい ↩
-
イベントドリブンを限定継続で綺麗に見せているように思えてならない ↩