javascriptの非同期処理(Promise/async/await)仲良く出来ていますか?
自分は、結構苦戦していました。
非同期処理は、プログラミングをみっちり構造的に理解している人ほど、罠に陥りやすいんじゃないかと思っています。
特に、構造化プログラミングを信奉していてフローチャート的に考える習慣のある人は、たちまち破綻して地獄を見ます。
javascriptの場合、外部とのやり取りをしていない場合は比較的平穏にコーディングを進められるんですが、外部とのやり取り、つまりajaxリクエスト・DBへのアクセスetcを扱うようになると非同期の世界へ一気に引き摺り込まれます。
自分の場合、ajax処理なんかは従来のjsのコールバック処理で何とこなすことが出来ていたのですが、nodejsでDBやファイルを扱うようになってからハマるようになって来ました。
始終jsを使う仕事なら非同期処理などは身体に叩き込まれるんでしょうけど、使ったり使わなかったりして、その度に知識がリセットされるのも悔しいし無駄なので、自分の忘備録として用意しておきます。
場合によっては他人様にも役に立つかも知れません。
ただし、技術的に厳密なものではないので、すでに知見のある人は他の資料を見た方が良いです。
非同期処理を最速で理解するには?
jsの非同期処理の基本はPromise
オブジェクトであり、 Promise
を表に出さず同期処理のように見せかけるための書き方ができるのがasync/await
キーワードです。
そのため
async/await
だけ覚えれば話は簡単なんじゃないか?と思ったこともありました。
しかし、async/await
を使えばそれだけで同期言語的に書けるかというとそうではなく、予想外の挙動に悩まされるケースが出て来ます。
この際Promise
に対する知識がないとデバッグもままならないため、やはりPromise
から理解するしかないというのが結論です。
ではPromise
って?
元々の意味は 「約束」 ですよね?
そしてjsのPromiseオブジェクトとは何かというと、 「約束手形」 のような意味合いを持ちます。
例えば、あなたが太郎さんに仕事を頼んだとします。太郎さんはデザイナーです。
「最高にイカしたWebデザインを制作してください・・・」
みたいに頼むのですが、太郎さんだって瞬時に納品できるわけがないですよね?
かと言って、出来上がるまでに黙ったままだと頼んだ方も「本当に作ってくれるのか?」不安になります。
// デザインを作る(作らせる)関数
function makeDesign(specs){
//略
return design
}
const design = makeDesign(specs)
// これだと不安
そこで、太郎さんは依頼を受けたら 「約束手形」 を先に発行します。
別にそこには大したことは書いてなくて、 「現在絶賛制作中、しばしお待ちを」 くらいなものです。
(具体的には、Promiseオブジェクトというものを生成してreturnします。)
// デザインを作る(作らせる)関数
function makeDesign(specs){
return new Promise( // とりあえず約束手形発行
(resolve)=>{
//略 デザインを作る過程
resolve(design) // デザインを納品
}
)
}
const design = makeDesign(specs)
// まずは約束手形を即座に受け取る
この方が幾分安心できます。
では、実際の「デザインを描く仕事」はどこで行われるのでしょうか?
Promise
オブジェクト内のコールバック関数内の処理がそれに当たります。
最後に、resolve()
に戻り値をセットします。
resolve
はPromise
を 「解決」 するための関数ですが、このケースでの 「解決」 は 「デザインを納品する」 という行為にあたります。「約束を果たす」わけですね。
では、依頼者が 「約束手形」 を受け取った後はどうすれば良いんでしょうか?最終的には納品物が手に入らなければ意味がありません。
そのためのPromise
のメソッドが.then()
です。
モノが出来上がった時に、受け取る側の動作をthen()
内に記述することが出来るのです!
design.then(data=>{
// 納品物をチェックして
// サーバにアップロード
// もちろん報酬をきっかり支払い!
// みたいな
})
この際の無名関数の引数data
が納品されて来たデータ(=resolve()
の引数)になりますので、このdata
(名前は何でも良いのですが)を使って好きに後処理すれば良いわけです。
良く出来てますよねえ。
さらに良いことに、色んな人に色んな作業を 「同時に」 頼むことも出来るんです!
例えば、今度は次郎さんがいるとします。次郎さんにはプログラムを書いてもらうことになりました。
// デザインを作る(作らせる)関数
function makeDesign(){
return new Promise( // とりあえず約束手形発行
(resolve)=>{
//略 デザインを作る過程
resolve(data) // デザインを納品
}
)
}
// プログラムを作る(作らせる)関数
function makeProgram(){
return new Promise( // とりあえず約束手形発行
(resolve)=>{
//略 プログラムを作る過程
resolve(data) // プログラムを納品
}
)
}
const design = makeDesign()
const program = makeProgram()
// まずは約束手形を即座に受け取る
design.then(data=>{
// サイズ調整して
// ブログにアップロード
// もちろん報酬をきっかり支払い!
// みたいな
})
program.then(data=>{
// 動作を検証して
// サーバにアップロード
// もちろん報酬をきっかり支払い!
// みたいな
})
じゃ、この場合、 太郎さんと次郎さんはどういう順番で仕事をしてくれるんですかね?
太郎さんがデザインを作り終わるのを待ってから、次郎さんが取り掛かる?
いや違うんです。太郎さんも次郎さんも 同時に掛かります 。早く終わった方から納品です。
効率的ですよね!
ここまで聞いて、まだ非同期処理と聞いていやーな感じがしますか?
むしろ 「もっと仲良くしたい!」 って思い始めてませんか??
何でも仕事はスマートに、早く終わった方が気分良いですよね!
では、上記のコードを、実際に動くものに置き換えます。
この際、呼ばれる関数の処理内容は時間がかかるものをシミュレートしたいので、jsのSetTimeout()
を使って、何秒後にレスポンスを返す形にします。
そして太郎さんの仕事は1秒
、次郎さんは2秒
で終わるものとします。
// デザインを作る(作らせる)関数
function makeDesign(){
return new Promise( // とりあえず約束手形発行
resolve=>{
setTimeout(function() {
resolve("はい、こちらデザインです")
}, 1000); // 制作時間1秒
}
)
}
// プログラムを作る(作らせる)関数
function makeProgram(){
return new Promise( // とりあえず約束手形発行
resolve=>{
setTimeout(function() {
resolve("はい、こちらプログラムです")
}, 2000); // 制作時間2秒
}
)
}
const design = makeDesign() // 太郎さんにデザインを依頼する
const program = makeProgram() // 次郎さんにプログラムを依頼する
// まずは約束手形を即座に受け取る
design.then(data=>{
console.log(data + " デザイン受け取った!")
})
program.then(data=>{
console.log(data + " プログラム受け取った!")
})
さあ、これを実行すると(実行環境はChromeのコンソールでも何でも良いのですが、
https://paiza.io/ja
とか便利なのでお試しください)
はい、こちらデザインです デザイン受け取った!
はい、こちらプログラムです プログラム受け取った!
となるはず。
しかも、太郎さんにも次郎さんにも 同時発注 しているので、2秒後 には太郎さんのデザインも、次郎さんのプログラムも受け取れています。1 + 2 = 3秒後、ではないんですね。
ここが非同期処理のウマイ所です。
いや、しかし!
太郎さんにデザインを作ってもらって、それを次郎さんに渡したいケースもありますよね!
デザインを作る→→→(デザインデータ)→→→プログラムを作る
こういう流れです。
非同期処理の困る点は、こうした当たり前の順次動作(同期処理)が却って面倒臭い所にあります。
まず考えられるのが、.then()
の中に次の動作を含めるという方法です。
design.then(designData=>{
console.log(designData + " デザイン受け取った!")
const program = makeProgram(designData) // 次郎さんにプログラムを依頼する
program.then(programData=>{
console.log(programData + " プログラム受け取った!")
})
})
しかしこれはご覧の通り、キモいです。
可読性最悪ですね。これだったら、Promiseが登場する前から使われていたコールバック関数
を使う書き方の方がはるかにマシです。
実はthen()
はメソッドチェーンに対応しているので、以下のような書き方も出来ます。
design
.then(data=>{
console.log(data + " デザイン受け取った!")
const program = makeProgram(data) // 次郎さんにプログラムを依頼する
return program
.then(data=>{
console.log(data + " プログラム受け取った!")
})
})
だいぶ良い感じになってきました。
.then()
をつなげる事によって、連続動作をしている様子がコードからイメージしやすいです。
この際、then()
ブロック内でreturnされた戻り値が次のthen()
ブロックに伝達される、という点がポイントになります。
こうして次々とデータをバトンタッチすることが出来るわけですね!
ちなみに、Promise
には、複数の処理を走らせて出揃った時点で次の処理に移る
Promise.all()
や、複数の処理を走らせていずれかの処理がゴールインした時に次の処理に移る
Promise.race()
などのテクニックもありますが、async/await
の説明に移りたいので割愛します。
async / await
さて、これまでの知識だけでも、jsによる非同期処理・同期処理を使いこなすが出来ます。
ただ、Promise
オブジェクトという概念自体が掴みづらく、コードの可読性を下げてしまっているのが欠点です。
そこで、非同期処理を、PHPやpythonなどと同じように同期的に書けるようにしたasync/await
構文を使う事にしましょう。
ここに、ある「普通の」関数があったとします。
function myFunc(){
// 略(時間のかかる処理)
return "done!"
}
処理内容は省略していますが、「そこそこ時間のかかる処理」が来るものと考えてください。
次に、関数定義の前にasync
キーワードを付け加えます。
async function myFunc(){
// 略(時間のかかる処理)
return "done!"
}
キーワードasync
が付いただけなので、見た目はさほど変わりませんが、実は関数の仕様が大きく変わります。
具体的には、async
の方のmyFunc()
は、"done!"という 文字列をreturnしているのにかかわらず、戻り値はPromiseオブジェクト になります。
上記コードをコンソールなどで実行してみると、
Promise {<fulfilled>: 'done!'}
というような反応が得られると思います。
実はこのコードは
function myFunc(){
return new Promise( // とりあえず約束手形発行
resolve=>{
// 略(時間のかかる処理)
resolve("done!")
}
)
}
と同義です。
処理内容はasync
を付けた方と従来とまったく同じなのですが、コード上ではPromise
オブジェクトが現れず、隠蔽されています。一種の簡略構文と思ってもらって(多分)OKです。
なので、この関数を使いたい時は、受け取ったPromiseオブジェクトの解決後の動作を設定しておく必要があります。
そのための1つ目の方法が、Promise.then()
を使う方法、そして2つめの方法が、
await
キーワードを使う方法になります。
await
キーワードを使用すると、このような呼び出し方が可能になります。
const r = await myFunc()
console.log(r)
このawait
を外してみるとどのような動作になるでしょうか?
おそらくrがundefined
として出力されると思います。
通常の呼び出し方だと「約束手形」が発行されるだけなので、期待していた"done!"が出力されません。
await
を付けて、解決されるまで 「待つ」 ことで、戻り値である"done!"を捕まえることが出来るのです。
つまり、このコードは以下と同義です。
const r = myFunc()
r.then(data=>{console.log(data)})
やはりasync
に対してはawait
を使った方がスッキリしますよね!
しかし、async/await
を使うにあたって、大変重要なルールがあります。
awaitはasync関数の中でしか使えない
というものです。
Chromeのコンソールなどでは、特別に直での(グローバルスコープでの)awaitの使用は認められますが(Top Level Await)、それ以外の場合では、async
関数の中に書かれていないawait
はエラーになります。
await
による呼び出しは、(時間のかかる)動作を同期的に実行する場合に使われるので、その呼び出しをラップする関数も同じくawait
によって呼び出される前提になります。
await
によって呼び出されるのはasync
関数だけなので、
awaitはasync関数の中でしか使えない
というルールは至極当然ということになります。
(ただ、このルールを撤廃した方が良いんじゃないかという議論もあるようです)
このルール通りに記述すると以下のようになります。
async function myFunc(){
// 略(時間のかかる処理)
return "done!"
}
async function main(){
const r = await myFunc()
console.log(r)
}
main() //実行
そして、「時間のかかる処理」をシミュレートするために、先ほどのsetTimeout()
による方法に替わり、「ただ時間を消費する関数」doSomething()
を用意します。
なぜこうするのかというと、setTimeout()
自体が非同期処理で戻り値を得るのが難しく、説明用としては無駄なコードが増えてしまうためです。
// msecミリ秒間何もしない関数
function doSomething(msec) {
var start = new Date();
while (new Date() - start < msec);
}
※上は説明用のコードです。CPUを酷使するので、 本番には使わないでください。
まとめると以下になります。
// msecミリ秒間何もしない関数
function doSomething(msec) {
var start = new Date();
while (new Date() - start < msec);
}
async function myFunc(){
doSomething(1000)
return "done!"
}
async function main(){
const r = await myFunc()
console.log(r)
}
main() //実行
実行すると、1秒後に"done!"と出るはずです。
そして、最後のmain()
にはawait
をつける必要はありません。
async
を呼ぶときは必ずしもawait
を付ける必要はなく、動作上後続処理が必要かどうかで判断します。
実行することだけが目的であればawait
は不要です。(そもそもTop Level Awaitが認められている実行環境でないとエラーになる)
以下の記事が参考になるかも知れません:
https://qiita.com/jun1s/items/4ec151b1c2562f6b0f26
以上を踏まえ、太郎さんと次郎さんのコードに当てはめると、以下のようになります。
(データの引き継ぎは簡略のため無しにしてあります)
// msecミリ秒間何もしない関数
function doSomething(msec) {
var start = new Date();
while (new Date() - start < msec);
}
// デザインを作る(作らせる)関数
async function makeDesign(){
doSomething(1000)
return "はい、こちらデザインです"
}
// プログラムを作る(作らせる)関数
async function makeProgram(){
doSomething(2000)
return "はい、こちらプログラムです"
}
async function main(){
const design = await makeDesign()
console.log(design + " デザイン受け取った!")
const program = await makeProgram()
console.log(program + " プログラム受け取った!")
}
実行してみると、
はい、こちらデザインです デザイン受け取った!
はい、こちらプログラムです プログラム受け取った!
ということでPromise
を使った場合と同じ動作であることが確認できると思います。
そしてコードも直感的に流れを理解することが出来、少ない手数でコーディングをすることが出来ます。
さらに、スコープがネストする事なく、非常に理解しやすいコードになっていることが見て取れると思います。
ここまで理解できれば、大分非同期処理についての苦手意識が軽くなっているんじゃないでしょうか?ちょっと大変ですが、この部分が理解できないと、APIをガンガン活用したり、流行のVueやReact、あるいはnodeJsを使いこなすのは難しいので、ぜひ乗り越えてください( ^ω^ )
その他注意点
- 無名関数の中で
await
を使う場合も、親関数(つまりはその無名関数)がasync
でないといけません。この場合は、引数部の前にasync
キーワードを付けます - forEach()メソッドを使用する時も、上記の状況になります。通常のforループにすると、このような問題は発生しません。
- 何も考えずに やたらと
async/await
を付けまくるのはハマる原因になるので禁物 です。例えば、上記doSomething()
なども「時間がかかる処理だからasync付けとけ」などと考えてしまうと挙動がおかしくなります。 -
Promise
の解決の際にresolve関数
を渡しましたが、その時にreject関数
も合わせて定義して渡し、.then
だけでなく.catch
ブロックを作って 「失敗時の処理」 を設けておくことが推奨されています。この事についてはいずれ補足したいと思います。