JavaScript 再入門(その20) 非同期処理 1 非同期処理とPromise

非同期処理とPromise

非同期処理とは?

まずは、通常の処理方法、同期処理から説明します。
通常のプログラムは上から順番にコードを処理して、処理が終わると次の処理を実行していきます。

console.log(1) ;
console.log(2) ;
console.log(3) ;
console.log(4) ;
/*
1
2
3
4
*/

同期処理ですので処理された順番で 1 2 3 4 と表示されます。

次に、非同期処理です。
では、途中に setTimeout() を入れてみます。setTimeout 関数は指定された時間経過後に、指定されたコールバック関数を実行します。

setTimeout( コールバック関数 , 時間(ミリ秒) ) ;

setTimeout 関数は非同期処理の代表的な関数で、指定された時間の待機中も、コードは次々と処理されていきます。

console.log(1) ;
setTimeout( () => { console.log(2) ; } , 1000 ) ;
console.log(3) ;
console.log(4) ;
/*
1
3
4
2 <--- 1秒後に表示される
*/

コードは上から順に処理され 1 を表示後に setTimeout が処理されます。setTimeout で 1秒間の待機が指定されているので、次の処理を実行した結果 3 4 を表示後、待機時間が経過したのでコールバック関数で 2 を表示しています。

非同期処理はホストからデータを取得する時などの時間のかかる処理を実行しても、他の処理は止めずに結果が戻ってきたときに処理を再開するという非常に効率的な仕組みなのですが、処理の順序が重要となる処理もあります。例えば、ホストのDBからマスターデータを取得後、マスターデータをキーにしてトランザクションデータを取得するなどです。

次の例はコールバックだらけで、わかりづらいかと思いますが辛抱してください。
関数 getReqData は、与えられたリクエスト( carName:車名 / firstName:名前 / lastName:苗字 )に対して延滞後にデータをコールバック関数に渡します。これは、一般的な非同期通信でのホストとのやり取りをシュミレートしています。

ホストにリクエストしてから、延滞後(延滞時間はランダムに設定)にホストから結果が戻ってくると考えてください。結果が戻ってくると結果を引数にしてコールバック関数を呼び出します。これらの非同期の関数を上から順にコードを並べても、それぞれの関数がコールバックされるタイミングが違うので、車名+苗字+名前の順に並ぶ保証はありません。(偶然並ぶことはあります。)

let profile = '' ; // プロフィール
let dataSet = { // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後にコールバック関数へ返す関数
let getReqData = function( argReq , argCallback ) {
	setTimeout(
		() => { argCallback( dataSet[ argReq ] ) ; }  ,  // データをコールバックに渡す
		Math.random() * 1000 // 延滞時間はランダムに設定
	) ;
}
// コールバック関数(プロフィールの編集)
let editProfile = ( argProfData ) => {
	profile += argProfData ; // プロフィールに返された値を付加する。
	console.log( profile ) ; // プロフィールを表示
}
// 非同期で 車名+苗字+名前 のプロフィールを取得したい。 しかし...
getReqData( 'carName' , editProfile ) ; // 車名取得
getReqData( 'lastName' , editProfile ) ; // 苗字取得
getReqData( 'firstName' , editProfile ) ; // 名前取得
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/* 表示される順序はランダム
<TOYOTA>
Takumi 
Takumi AE86 SPRINTER TRUENO GT-APEX 3door
Takumi AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara 
*/

コールバック地獄

では、順序を保証して非同期処理を実行したい場合の従来のコードでは、コールバック内にコールバックを入れ子にする。俗に”コールバック地獄”と呼ばれる煩雑なコードを書かなければなりません。

let profile = '' ; // プロフィール
let dataSet = { // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後に返す関数
let getReqData = function( argReq , argCallback ) {
	setTimeout(
		() => { argCallback( dataSet[ argReq ] ) ; }  ,  // データをコールバックに渡す
		Math.random() * 1000 // 延滞時間はランダムに設定
	) ;
}
// コールバック関数(プロフィールの編集)
let editProfile = ( argProfData ) => {
	profile += argProfData ;
	console.log( profile ) ;
}
// 非同期で 車名+苗字+名前 のプロフィールを取得する
getReqData( 'carName' , ( retArg ) => { // 車名取得
	editProfile( retArg ) ;
	getReqData( 'lastName' , ( retArg ) => { // 苗字取得
		editProfile( retArg ) ;
		getReqData( 'firstName' , ( retArg ) => { // 名前取得
			editProfile( retArg ) ;
		});
	});
});
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/*
<TOYOTA>
AE86 SPRINTER TRUENO GT-APEX 3door
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara 
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara Takumi
*/

車名の取得のコールバックに苗字の取得をネスト(入れ子)にし、苗字の取得のコールバックに名前の取得をネストすることにより、車名+苗字+名前 のプロフィールは取得できましたが、立派な”コールバック地獄”のコードができあがりました。実務のコードでは、これにエラー処理等が付加されるので、ますます地獄の底は深くなるばかりです。

let profile = '' ; // プロフィール
let dataSet = { // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後に返す関数 エラー処理あり
let getReqData = function( argReq , argCallback , argFailCallback ) {
	setTimeout(
		() => {
			if ( argReq in dataSet ) { // リクエストがプロフィールデータに存在するか
				argCallback( dataSet[ argReq ] ) ; // 正常終了
			} else {
				argFailCallback( argReq ) ; // リクエストが不正なためエラー
			}
		}  ,  // データをコールバックに渡す
		Math.random() * 1000 // 延滞時間はランダムに設定
	) ;
}
// コールバック関数(プロフィールの編集)
let editProfile = ( argProfData ) => {
	profile += argProfData ;
	console.log( profile ) ;
}
// エラー時のコールバック関数
let errorProfile = ( argFailReq ) => {
	throw 'Request ' + argFailReq + ' is not found!' ;
}
// 非同期で 車名+苗字+名前 のプロフィールを取得する
try {
	getReqData( 'carName' , ( retArg ) => { // 車名取得
		editProfile( retArg ) ;
		getReqData( 'lastNameX' , ( retArg ) => { // 苗字取得 <--- リクエストエラー
			editProfile( retArg ) ;
			getReqData( 'firstName' , ( retArg ) => { // 名前取得
				editProfile( retArg ) ;
			} , errorProfile );
		} , errorProfile );
	} , errorProfile );
} catch (e) {
	console.error(e) ;
}
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/*
<TOYOTA>
AE86 SPRINTER TRUENO GT-APEX 3door
Uncaught Request lastNameX is not found!
*/

リクエストが不正なときはエラーにする、簡単なエラー処理を入れただけですが、もう絶望的なコードとなりました。

そして Promise *ES2015

“コールバック地獄”を解消するために、非同期処理の実行順序を保証(Promise)する仕組み Promise*ES2015 が考案されました。

プロトタイプ継承もそうでしたが、思考が崇高すぎて非常にとっつきにくいのです。マニュアルを読んでもいまいちピントきません。とりあえず小難しい説明は後にして、Promise を使ってみます。

let profile = '' ; // プロフィール
let dataSet = { // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後に返す関数
let getReqData = function( argReq , argCallback ) {
	return new Promise( ( resolve ) => { // 非同期処理の結果を Promise で返します
		setTimeout(
			() => {
				argCallback( dataSet[ argReq ] ) ; // 取得したデータををコールバックに渡す
				resolve() ; // 正常終了したよ!
			} ,
			Math.random() * 1000 // 延滞時間はランダムに設定
		) ;
	}) ;
}
// コールバック関数(プロフィールの編集)
let editProfile = ( argProfData ) => {
	profile += argProfData ; // プロフィールに返された値を付加する。
	console.log( profile ) ; // プロフィールを表示
}
// 非同期で 車名+苗字+名前 のプロフィールを取得する
getReqData( 'carName' , editProfile ).then( () => {	// 車名の取得
	getReqData( 'lastName' , editProfile ) ;		// 苗字の取得
}).then( () => {
	getReqData( 'firstName' , editProfile ) ;		// 名前の取得
}) ;
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/*
<TOYOTA>
AE86 SPRINTER TRUENO GT-APEX 3door
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara 
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara Takumi
*/

説明を簡略化するために、エラー処理は行っていません。
まずはプロミスの動作を見るところからはじめましょう。

説明上の表現として、”プロミスを返す側” と “プロミスを利用する側” とに分類します。

プロミスを返す側は、”非同期でリクエストに対するデータをX秒後に返す関数” getReqData() となります。
getReqData() は、return で Promise 型オブジェクトを返しています。

	return new Promise( ( resolve ) => { // 非同期処理の結果を Promise で返します
      ...... 
	}) ;

この例では、成功時の処理しかないので new Promise へ渡される関数の仮引数は resolve の1つだけですが、失敗時の処理の仮引数も指定することもできます。この引数をメソッドとして呼び出すことにより、処理の成功と失敗の状態を利用する側へ返すことができます。また同時に値を返すこともできます。慣習で成功時は resolve、失敗時は reject とすることが多いです。

return new Promise( ( resolve , reject ) => {
	非同期処理
	if ( 非同期処理が成功した ) {
    	非同期処理成功時の処理
		resolve( 返り値 ) ;	// 非同期通信が成功した状態と、成功時の値を利用者に知らせます。
    } else {
      	非同期処理失敗時の処理
		reject( 返り値 ) ;		// 非同期通信が失敗した状態と、失敗時の値を利用者に知らせます。
    }
}

従来の非同期処理はコールバック関数を登録してコールバック関数に値を返していましたが。
Promise は状態と値を返します。

Promise はコンストラクタで渡された関数を実行しますので、new でインスタンスを生成したと同時に渡された関数を実行します。

ちょっと解りにくくなってきましたね。
ここまでが、”プロミスを返す側”の簡単な説明です。

次は Promise 型オブジェクトを返す関数の利用者、”プロミスを利用する側”です。
“プロミスを利用する側”のコードです。

// 非同期で 車名+苗字+名前 のプロフィールを取得したい
getReqData( 'carName' , editProfile ).then( () => {	// 車名の取得
	getReqData( 'lastName' , editProfile ) ;		// 苗字の取得
}).then( () => {
	getReqData( 'firstName' , editProfile ) ;		// 名前の取得
}) ;

“プロミスを返す側”の関数 getReqData() を呼び出している後ろに .then() がついています。
これが”プロミスを利用する側”がプロミスの状態を受け取る部分です。
.then() の部分には、成功時の関数( 状態が resolve 時に呼ばれる関数 )と成功時の値( resolve の返り値 )と、失敗時の関数( 状態が reject 時に呼ばれる関数 )と失敗時の値( reject の返り値 )が、設定参照できます。

プロミスを返す側の関数().then( 成功時の関数(成功時の値) , 失敗時の関数(失敗時の値) ) ;

この例では、成功時の関数のみを設定しています。
.then() は、値が返されると次の .then() を実行します。

最初に getReqData() をリクエスト ‘carName’ で実行します。
getReqData() 関数では、return 句の Promise 関数内で、タイムアウト後に resolve() が実行されます。
resolve により、then() 内の成功関数が実行されるので、 getReqData() をリクエスト ‘lastName’ で実行します。
こうして、順番に resolve が実行される毎に then() が処理されます。

プロミスは、非同期の処理が終わると resolve (または reject )を引き金(トリガー)として then() に書かれた処理を順序を保証(promise)して実行します。

旧来のコールバック渡とは異なる、プロミスを強調したしたかたちに書き換えてみます。

let profile = '' ; // プロフィール
let dataSet = {    // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後に返す関数
let getReqData = function( argReq ) {
	return new Promise( ( resolve ) => { // 非同期処理の結果を Promise で返します
		setTimeout(
			() => { resolve( dataSet[ argReq ] ) ;} , // 正常終了して値も返すよ! 
			Math.random() * 1000 		// 延滞時間はランダムに設定
		) ;
	}) ;
}
// プロフィールの編集
let editProfile = ( argProfData ) => {
	profile += argProfData ;			// プロフィールに返された値を付加する。
	console.log( profile ) ;			// プロフィールを表示
}
// 非同期で 車名+苗字+名前 のプロフィールを取得したい
getReqData( 'carName' )					// 車名の取得
.then( ( argResolve ) => {				// 車名の取得が成功(resolve)したときの処理 then() の引数は resolve で渡される値
	editProfile( argResolve ) ;			// 車名をプロフィールに編集
	return getReqData( 'lastName' ) ;	// 苗字の取得 return によって結果は次の then() の引数になる
}).then( ( argResolve ) => {			// 苗字の取得が成功(resolve)したときの処理
	editProfile( argResolve ) ;			// 苗字をプロフィールに編集
	return getReqData( 'firstName' ) ;	// 名前の取得 return によって結果は次の then() の引数になる
}).then( ( argResolve ) => {			// 名前の取得が成功(resolve)したときの処理
	editProfile( argResolve ) ;			// 名前をプロフィールに編集
}) ;
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/*
<TOYOTA>
AE86 SPRINTER TRUENO GT-APEX 3door
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara 
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara Takumi
*/

どうでしょうか、かなりスッキリとしてきましたね。

この様に then() が連続してつながる処理をプロミスチェーン(保証の連鎖)と呼びます。

オマケですが、getReqData をコンストラクタ関数としてインスタンスを生成してプロミスを利用します。

let profile = '' ; // プロフィール
let dataSet = { // プロフィールデータ
	carName : 'AE86 SPRINTER TRUENO GT-APEX 3door ' , // 車名
	firstName : 'Takumi ' , // 名前
	lastName : 'Fujiwara ' , // 苗字
}
// 非同期でリクエストに対するデータをX秒後に返す関数
let getReqData = function( argReq ) {
	return new Promise( ( resolve ) => {	// 非同期処理の結果を Promise で返します
		setTimeout(
			() => { resolve( dataSet[ argReq ] ) ;} , // 正常終了して値も返すよ! 
			Math.random() * 1000			// 延滞時間はランダムに設定
		) ;
	}) ;
}

// コールバック関数(プロフィールの編集)
let editProfile = ( argProfData ) => {
	profile += argProfData ;				// プロフィールに返された値を付加する。
	console.log( profile ) ;				// プロフィールを表示
}
//
let car = new getReqData( 'carName' ) ;		// 車名取得インスタンス生成
let lname = new getReqData( 'lastName' ) ;	// 苗字取得インスタンス生成
let fname = new getReqData( 'firstName' ) ;	// 名前取得インスタンス生成
// 非同期で 車名+苗字+名前 のプロフィールを取得したい
car.then( ( argResolve ) => {				// 車名の取得と車名の取得が成功(resolve)したときの処理
	editProfile( argResolve ) ;				// 車名をプロフィールに編集
	return lname ;							// 苗字の取得 return によって結果は次の then() 関数の引数になる
}).then( ( argResolve ) => {				// 苗字の取得が成功(resolve)したときの処理
	editProfile( argResolve ) ;				// 苗字をプロフィールに編集
	return fname ;							// 名前の取得 return によって結果は次の then() 関数の引数になる
}).then( ( argResolve ) => {				// 名前の取得が成功(resolve)したときの処理
	editProfile( argResolve ) ;				// 名前をプロフィールに編集
}) ;
console.log( '<TOYOTA>') ; // 一番最初に表示される。
/*
<TOYOTA>
AE86 SPRINTER TRUENO GT-APEX 3door <--- X秒後に表示
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara <--- X秒後に表示
AE86 SPRINTER TRUENO GT-APEX 3door Fujiwara Takumi <--- X秒後に表示
*/

参考リンク

MDN 開発者向けのウェブ技術 > 標準組み込みオブジェクト > Promise
MDN 開発者向けのウェブ技術 > プロミスの使用
MDN 開発者向けのウェブ技術 > 標準組み込みオブジェクト > Math.random
MDN 開発者向けのウェブ技術 > JavaScript「再」入門