どうもこんにちは。うめきです。
これはアドベントカレンダー「厚木の民」の3日目の記事です。
本日はJavaScriptにおけるコールバック地獄の回避方法について書きます。JavaScriptを既にある程度理解している方向けの記事となっています。そもそもJavaScriptってなんぞやって人は直接会ったときに聞いてみてください。ちゃんと教えます笑
##この記事を読んでわかること
- JavaScriptの非同期処理って具体的にどんなもの?
- コールバック地獄ってなに?
- コールバック地獄を抜け出すためのPromiseってなに?
- Promiseを使いやすくしてくれるasync/awaitってなに?
並列処理はできないが非同期処理はできるJavaScript
JavaScriptにおける同期・非同期処理を語るうえで重要な特性が2つあります。
1. JavaScriptはシングルスレッドで動いており、キューにたまった関数を1つずつ処理する
2. 関数がキューに登録される順番が同期的であったり非同期的であったりする
まず1についてです。以下のソースコードを例にすると、キューにはaの計算をせよという命令の次に、bの計算をせよという命令が入っていることになります。そして、aの計算が終わった後でbが計算され、aとbが同時に計算されることはありません。これは簡単ですね。
a = 1 + 2;
b = 3 + 4;
次に2についてです。以下のソースコードにおいてはどのようにキューに追加されていくでしょうか。まず、同期的にconsole.log(1)、setTimeout(...)、console.log(3)が順番にキューに追加されます。そして、setTimeout(...)が実行されたタイミングでconsole.log(2)がタイマーに渡されます。タイマーは関数によって指定された時間(ここでは0秒)が経過後にconsole.log(2)をキューに追加します。このタイミングでは先にconsole.log(3)がキューに追加されています。
console.log(1);
setTimeout(function(){console.log(2)}, 0);
console.log(3);
したがって、このソースコードの実行結果は以下のようになるわけです。
1
3
2
0秒だから1,2,3と表示されるんじゃ?と思っていた方もいたのではないでしょうか?console.log(2)は、ソースコードに書かれた順番通りにキューに追加されていません。非同期的にキューに追加されています。
このように、JavaScriptのスレッド以外にタスクを投げたとき、関数が非同期的にキューに追加されることがあり、結果としてJavaScriptは非同期処理であるという言い方をするのです。具体的には、タイマー処理、HTTP通信、データベース処理などが非同期的なキューの追加を発生させます。
コールバック地獄との遭遇
前章から、JavaScriptにおいては同期処理や非同期処理に気を付けなければならないことがわかったと思います。実際に私も、JavaScriptを扱うようになってから同期処理と非同期処理を意識して使わなければならない場面が多く出てきました。例えば、複数のデータの読み込みが完了してから読み込んだデータに対して処理をかけたいという場合です。非同期処理の完了後に特定の処理を行いたい場合、コールバック関数に次の処理を書いていきます。テキストファイルを複数読み込んで、そのあとに処理をかける場合以下のように書けます。
const fs = require('fs');
fs.readFile('data1.txt', function(data1) {
fs.readFile('data2.txt', function(data2) {
fs.readFile('data3.txt', function(data3) {
fs.readFile('data4.txt', function(data4) {
fs.readFile('data5.txt', function(data5) {
console.log(data1 + data2 + data3 + data4 + data5);
})
})
})
})
})
どうでしょう?このソースコードのキモさがわかるでしょうか?
- 深いネストにより可読性が下がる → コードを書く領域がどんどん狭くなっていく…
- エラーをまとめて受け取れない → 記入漏れありそう…バグの温床では…?
- 処理の追加/削除が大変 → メンテナンスが大変…
これがいわゆるコールバック地獄というやつですね。JavaScriptを書くことはコールバック地獄との戦いであります。
ダメ押しですが、上記のソースコードにエラー処理を付けた場合どのようになるのか一応載せておきます。
const fs = require('fs');
fs.readFile('data1.txt', function(error, data1) {
if(error) {
console.log(error);
return;
}
fs.readFile('data2.txt', function(error, data2) {
if(error) {
console.log(error);
return;
}
fs.readFile('data3.txt', function(error, data3) {
if(error) {
console.log(error);
return;
}
fs.readFile('data4.txt', function(error, data4) {
if(error) {
console.log(error);
return;
}
fs.readFile('data5.txt', function(data5) {
if(error) {
console.log(error);
return;
}
console.log(data1 + data2 + data3 + data4 + data5);
})
})
})
})
})
もはや吐き気を催すレベルです。こんなコードを世の中に生み出してはいけません。。
##Promise先生との出会い
コールバック地獄を解決してくれるのがPromiseです。Promiseは「非同期処理の最終的な完了もしくは失敗を表すオブジェクト」です。Promiseを使えば、非同期処理が終わるのを待ってから次の処理を始めてね的なことが簡単にできます。具体的なメリットは以下の通り。
- 非同期処理を連結する際、コールバック地獄から解放される
- エラー処理をうまく記述できる
- 一連の非同期処理を関数化して再利用しやすくできる
ここからは少しややこしくなりますが頑張りましょう!
Promiseオブジェクトはnew Promise(fn)によってインスタンス化されます(関数fnには非同期処理が書かれます)。
let promise = new Promise(fn);
インスタンス化されたPromiseオブジェクトは、以下の3つを持っています。
- Promiseオブジェクトの状態(Fullfilled、Rejected、Pending)
- 非同期処理が成功したときに実行されるコールバック関数(onFullFilled)
- 非同期処理が失敗したときに実行されるコールバック関数(onRejected)
上記のonFullFilled関数およびonRejected関数をPromiseオブジェクトに登録するためには以下のメソッドを使います。
promise.then(onFullFilled, onRejected);
インスタンス化直後は、Promiseオブジェクト内の非同期処理が成功も失敗もしていないPending状態になっており、非同期処理が成功(FullFilled状態)するとonFullFilled関数が実行され、非同期処理が失敗(Rejected状態)するとonRejected関数が実行されます。つまりこんな感じで非同期処理をつなげて書くことができます。
let promise = new Promise(fn);
promise
.then(onFullFilled_1, onRejected_1)
.then(onFullFilled_2, onRejected_2)
.then(onFullFilled_3, onRejected_3)
.then(onFullFilled_4, onRejected_4)
.then(onFullFilled_5, onRejected_5)
なんだか良さげにみえますね。もう少し詳しく見てみます。
実は、onFullFilled関数およびonRejected関数はPromiseオブジェクトから処理結果の値やエラーオブジェクトを受け取ることが可能です。Promiseオブジェクトのコールバック関数fnは引数にresolve関数とreject関数をもっています。fn内の非同期処理が正常に処理された場合はresolve関数が結果の値をonFullFilled関数に渡し、処理がエラー終了した場合はreject関数がエラーオブジェクトをonRejected関数に渡しています。
これだけではわからないと思いますので具体例を見てみましょう。
さきほどのテキストファイル読み込み処理をPromiseオブジェクトを返す関数にしてみました。
const fs = require('fs');
function readFile(file) {
return new Promise(function(resolve, reject){ // new Promise()でPromiseオブジェクトを生成
fs.readFile(file, function(error, data){
if (error) reject(error); // errorがあればreject関数を呼び出す(引数はエラーオブジェクト)
resolve(data); // errorがなければ成功とみなしresolve関数を呼び出す(引数は返却したい値)
});
});
}
上記のソースコードで、reject関数が呼び出された場合はエラーオブジェクトerrorがonRejected関数に渡されます。また、solve関数が呼び出された場合は処理結果の値dataがonFullFilled関数に渡されます。ここまで大丈夫そうでしょうか?あとちょっと話します。
エラー処理をまとめて書きたいときには、promise.then(undifined, onRejected)と等価な以下の記法が使えます。
promise.catch(onRejected)
なお、thenメソッドの引数はオプショナルであるので以下のように書くことが多いです。
promise
.then(onFullFilled)
.catch(onRejected)
なお、全体に共通したエラー処理もcatchメソッドで処理可能です。
promise
.then(onFullFilled_1)
.then(onFullFilled_2)
.then(onFullFilled_3)
.then(onFullFilled_4)
.then(onFullFilled_5)
.catch(onRejected)
ここまでくるとコールバック地獄を回避して最初のコードを書き換えられそうですね。実際に書き換えました。
let data1, data2, data3, data4, data5
readFile('data1.txt')
.then(function(_data1) {
data1 = _data1;
return readFile('data2.txt');
})
.then(function(_data2) {
data2 = _data2;
return readFile('data3.txt');
})
.then(function(_data3) {
data3 = _data3;
return readFile('data4.txt');
})
.then(function(_data4) {
data4 = _data4;
return readFile('data5.txt');
})
.then(function(_data5) {
data5 = _data5;
console.log(data1 + data2 + data3 + data4 + data5);
})
.catch(function(error) {
console.log(error);
})
んんん!!!!なんか思ったよりきれいじゃない!
エラー処理は一か所にまとめることができていますがdata1 = _data1みたいな行はなんぞや?となってると思います。結局Promiseオブジェクトが返す値はsolve関数を介して次のコールバックに渡してあげないと参照できなくなってしまうからこんな残念な感じになっているんですね。。
いやいやでももうちょっときれいに書けるだろ!って批判が各方面から来そうなので書き直しました。
function subReader(file) {
return function(previous) {
return new Promise(function(resolve, reject){
fs.readFile(file, function(error, data){
if (error) reject(error);
resolve(previous + data);
});
});
}
}
reader('data1.txt')
.then(subReader('data2.txt'))
.then(subReader('data3.txt'))
.then(subReader('data4.txt'))
.then(subReader('data5.txt'))
.then(function(data) {console.log(data)})
.catch(function(error) {console.log(error)})
あれ?意外といいかも!さすがPromise!
じゃあ今度はsubReader関数の処理だけじゃなくて、各ファイルで取得したデータに異なる処理をかけてみましょう!
あれ、、?ちょっと処理が違うだけでも全部Promiseを返す関数を書かなきゃいけないの?
メソッド間での変数の引き渡しも最初から考え直さなきゃいけない?めんどくさいよ。。。Promise先生。
関数を再帰的に呼び出せばいいじゃん!と思う方がいるかもしれません。上記のケースでは確かに再帰呼び出しによりソースコードをコンパクトにまとめることが可能です。しかしながら、処理A→処理B→処理C→処理Dのように異なる非同期処理を連続して実行する場合にはきれいにまとめて書くことができません。また、処理Aの結果を処理Dにおいて使いたい場合には、thenチェーンによって処理Aの結果を渡し続けるか、thenチェーンの外側に変数を退避させる必要があります。このようなプログラムを書いた場合、処理の追加や削除のたびにそこそこ面倒な修正が必要になってしまいます。
じゃあ、どのような書き方ができるのが理想でしょうか?こんな感じになるのではないかと思います。
// data1 -> data2 -> data3 -> data4 -> data5 の順番に取得
let data1 = readFile('data1.txt');
let data2 = readFile('data2.txt');
let data3 = readFile('data3.txt');
let data4 = readFile('data4.txt');
let data5 = readFile('data5.txt');
// 全データを取得後に実行
console.log(data1 + data2 + data3 + data4 + data5);
もちろんこのままでは正常に動作しません。しかし、仮に上のような書き方ができたなら、より簡潔に非同期処理を書くことができますし、thenチェーン間の変数の受け渡しという面倒な制約を考えなくてよいことになります。結果として、処理の追加や削除にも柔軟に対応できるプログラムになります。
ちなみにsubReader関数の引数に違和感を覚えた方はこちらを参照してください。
##async/await大先生の登場
理想形はわかったけども結局どうすればいいのやら、、と思って探してみるとasync/awaitなるものがありました!
async/awaitの最大の魅力は、Promiseを利用した構文よりも簡潔に非同期処理が書けることです。async/awaitはPromiseを使いやすくしてくれます。以下、詳しく見てみましょう。
async functionにより、非同期関数を宣言することができます。async functionは暗黙的にPromiseオブジェクトを返します。
async function readFiles() {}
async functionの中では、await式を使うことができます。
async function readFiles() {
let data1 = await readFile('data1.txt');
}
awaitはasync関数の処理を一時停止し、awaitの後ろに書かれた関数のPromiseが解決するまで(FullFilledかRejectedになるまで)待ちます。Promiseが解決後に、async関数の処理を再開し、解決された値を返します。
ではこれまで何度もみてきたソースコードはasync/awaitを使ってどのように生まれ変わるのでしょうか?
async function readFiles() {
let data1 = await readFile('data1.txt');
let data2 = await readFile('data2.txt');
let data3 = await readFile('data3.txt');
let data4 = await readFile('data4.txt');
let data5 = await readFile('data5.txt');
console.log(data1 + data2 + data3 + data4 + data5);
}
readFiles()
.catch(error) {console.log(error)}
やばい、神なのか!!これがずっと望んでいた最終形態ともいえる形なんじゃないでしょうか。さきほどの理想的なソースコードと比較してください。理想に限りなく近い書き方が可能になっていることがわかります。async/awaitを使えば、thenメソッド間のデータの処理を気にする必要もありませんし、非同期処理内容の変更にも柔軟に対応することができます。
もうちょっと汎用的な形にしましょう。
let files = ['data1.txt', 'data2.txt', 'data3.txt', 'data4.txt', 'data5.txt'];
async function readFiles(files) {
let data = [];
for(let i = 0; i < files.length; i++) {data.push(await readFile(files[i]))}
console.log(data.reduce((x, y) => x + y));
}
readFiles(files)
.catch(function(error) {console.log(error)})
ついに非同期処理にfor文を使えるところまできました。とでも感慨深いですね。
ついでに、5というマジックナンバーを消して、配列の和を取る部分はreduceメソッドを使ってみました(蛇足ですが、ちょっとでも速度を上げたいのであれば、files.length の部分はfor文の外で変数に格納しておく方がよいと思います)。
コールバック地獄との長い戦いの末にasync/awaitが問題の大部分を解決してくれることが分かりました。
実は例外処理については気をつけなきゃいけないのですがこちらはまた今度にしましょう。
##まとめ
- 並列処理はできない、非同期処理はできる(JavaScriptはシングルスレッド)
- コールバック地獄を解決するにはasync/awaitを使おう
- async/awaitを使うにはPromiseの理解が必要
- 例外処理には気を付けること
##参考文献