Promiseでスッキリ

 
Web Tips.
Booskanium's
Booskanium's Web Tips.

Promise、それは旧式プログラマの落書き人としては『非同期処理の組み合わせ(直列,並列,レース等)を、シングルスレッドのJavaScript実行を阻害せずに同期処理っぽく(Promise,then,catch,finaly等)書く構文』だと解釈しました。
非同期処理にコーディングレベルでの対応を強いるJavaScriptって可愛い。嫌味じゃなくて、この様な泥臭いプログラミングが嫌いじゃないという意味です。JavaScriptの中級者以上を目指すなら、この泥臭さと付き合う必要があります。ここの落書き人はJavaScriptを至れり尽せりな高級言語だとは思っていません。m(__)m。
ここのメモも多分に泥臭い(^_^;)、だってここの落書き人は、例えばオブジェクト指向文献が抽象的な用語の羅列で理解できなかった旧式脳ですので・・・。

非同期対応に迫られたのは「ちゅんラヂ」の選局機能でした。
利用者はローディングに時間がかかると待ちきれずに再選局(クリック)します。つまり非同期なaudioタグが重複してリクエストされて奇っ怪な現象が発生していました。
そこで選局機能全体を非同期処にすることで、高速選局連打(クリック連打)にも耐えられるタフな「ちゅんラヂ」になりました。
これ、最初はCallback地獄なコーディングでした。それがPromise構文で整理整頓したらスッキリしました。
話は飛びますが、この利用者にとっては当たり前で簡単で違和感がない操作感というのは、プログラミングとしては手間が掛かっています。

いきなりですがPromiseを目で確認して理解したつもりになる

Promiseの文献が睡眠導入剤(文献が悪いのじゃなくて、落書き人の理解力不足)だったので、百聞は一見になんとか、とにかく動かして目で確認して理解したつもりになる。そんな落書き人の実践メモです。
単発のPromiseの事例はたくさんありますが、複数の非同期処理の組み立て方(直列,並行,早い者がち勝ち,それらの組み合わせ)がわからないと、実用的な非同期処理がかけません。
そこで、いきなりですが非同期処理を目視で確認してみました。これらのソースをリバースエンジニアリングすると何をしているかが解る?


1.5秒の非同期処理を2つ直列実行させる場合

  
ここに動作状況が表示でされます。
非同期直列処理の留意点

Start連打をどうるす。悩んだ末に、連打はqueue配列に受け付けて、処理中の非同期処理をキャンセルさせて、次のStart押下を受け付ける仕様にしてみました。これで連打されても大丈夫。
2つ目の非同期処理中でCancelすると「abortabort」と表示される理由は、AbortContlloerの発火が処理完了した非同期処理の中でも発火するからです。しかし処理完了した非同期処理のrejectはPromiseのcatchには影響しません。これが気持ち悪いのなら、各非同期処理毎に別のAbortContlloerを割り当てる等の対応が必要です。
catchで拾われるのはPromiseのrejectに限らず、Scriptエラーも拾われるので、それらを判別するような考慮が必要でした。
この動作のリバエンは「js/promise_cancel.js」を参照。


3秒と1.5秒の非同期処理を並列実行させる場合

  
ここに動作状況が表示されます。
非同期並列処理の留意点

複数の非同期処理を並列実行(all)させるとさせると、総ての非同期処理が完了するまで、並列処理の次の処理に遷移しない。
複数の非同期処理を並列実行(all)が総て完了すると、それぞれの非同期処理のresolveの戻り値が配列で、次の処理(then)に引き渡される。
複数の非同期処理で終了(resolve)している非同期処理が有っても、まだ実行中の非同期処理でリジェクト(reject)されると並列処理の次の処理(then)に遷移せずにcatchに遷移する。
先に処理完了した後にCancelすると、その処理完了したところにも「abort」と表示される理由は、AbortContlloerの発火が処理完了した非同期処理の中でも発火するからです。そのしかし処理完了したrejectはPromiseのcatchには影響しません。これが気持ち悪いのなら、各非同期処理毎に別のAbortContlloerを割り当てる必要があります。
catchで拾われるのはPromiseのrejectに限らず、Scriptエラーも拾われるので、それらを判別するような考慮が必要。
この動作のリバエンは「js/promise_cancel_all.js」を参照。
落書き人が興味を持ったことは、シングルタスクのJavaScritがどの様に並列実行を実現しているかです。OS機構でマルチスレッドにしているのか、言語レベルで擬似的にマルチスレッドにしているかに興味を持ちました。その事について書かれている文献が見つかりませんでした。


3秒と1.5秒の非同期処理をレースさせる場合

  
ここに動作状況が表示されます。
非同期レース処理の留意点

レースで複数の非同期処理をさせると、何れかの非同期処理が完了した段階で、レースの次の処理(then)に遷移する。
この事例は非同期処理をリエントラントにしていないので結果がごちゃごちゃになる。これでは困るので、レース終了でAbortContlloerを無効にしている。レース終了後に完了していない非同期を処理を強制Cancelしたい場合は、レース終了時にAbortContlloerを発火させる。
注意点はレース終了後も遅い方の非同期は最後まで処理が続くことです。これを認識せずに重複実行させてしまうと不可解な不具合に繋がります。レースをバンバン実行させたい場合はリエントラントな非同期処理にする必要があります。
上記の説明では解りづらいですが、対処方針を利用ケースに応じて対処しないと、理解不能な不具合の元になるケースになり得るので、レースを利用するなら見て考えてみる。急がないの並列処理させるべきです。
catchで拾われるのはPromiseのrejectに限らず、Scriptエラーも拾われるので、それらを判別するような考慮が必要。
この動作のリバエンは「js/promise_cancel_race.js」を参照。


非同期処理に1秒の非同期処理を入れ子にした場合

  
ここに動作状況が表示されます。
非同期処理の入れ子の留意点

非同期処理の中から非同期処理を呼び出したい場面があります。
落書きしてみたは良いけれどパズルを組んでいるような感じで、ちょっと見では理解不能。
async/awaitを使った方が良いと思われます。
この動作のリバエンは「js/promise_cancel_nest.js」を参照。


Prommiseの分岐ケースの場合

   
ここに動作状況が表示されます。
分岐ケースの留意点

[Start 1]と[Start 2]は、非同期処理の順番を入れ替える分岐をしている。

この動作のリバエンは「js/promise_cancel_case.js」を参照。


リエントラントなPrommise処理を同時実行【まだ実装前】

  
ここに動作状況が表示されます。
リエントラントなPromiseの留意点

まだ、実装していません
並列実行は別のPromiseを複数同時実行ですが、こちらはリエントラントなPromiseの処理を複数同時実行させるケースです。
リエントラントにするにはclass定義(prototypeの糖衣構文)してインスタンス化して複数のPromiseを複数キックする場合です。これでJavaScriptによる複数トランザクション処理の可能性について試しました。
この動作のリバエンは「js/promise_cancel_reentrant.js」を参照。


一連のPromise処理を重複実行させない仕掛けはキューイングで

Startボタン連打で複数Promise実行させると、訳が分からなくなります。そこでStrat連打で、実行中のPromise処理があったら処理Canselさせてから、リクエストのStartを実行させるように、キューイングの仕掛けが必要でした。

一連のPromise処理を重複実行させたい場合はclass(prototype)にする

リエントラントな非同期処理にする必要があります。
この事例では踏み込んでいませんが、これが必要になった時に加筆します。

AbortControllerは1回限り【重要】

abort()メソッドでsignalのイベントが発火するのは1回限りです。
その理由は一度発火すると、signalのEventListenerが削除されるからです。
一連のPromise処理を重複実行させたいケース(マルチトランザクション的な)では、リエントラントな処理(class定義(prototype))にして、AbortControllerの実装を、それぞれのインスタンスオブジェクトの中で、別々にするという考慮が必要です。つまりclassの中にAbortControllerを定義しておく必要があります。

リバースエンジニアリングは

詳しくはこの上述で確認ボタンの処理を記述したJavaScript「js/promise_cancel.js」「js/promise_cancel_race.js」「js/promise_cancel_all.js」「js/promise_cancel_branch.js」をリバースエンジニアリングしてください。UTF-8(UNICODE)でデコードしないと文字化け化けです。

AbortControllerはDOMです

PromiseのCancelにDOMのイベント処理を利用するのは、名案ですが強引な気もします。
PromiseのCalcelにAbortControllerを使うのが実質的にスタンダードな方法らしい。
※純粋なJavaScriptだけで「cancelable promises」が議論されたこともあったようですが頓挫した様です。

デバッカーで実行の流れを追うとresolve→thenがCallback地獄の糖衣構文なのがわかる

デバッカーによるかもしれませんが、最初の実行の流れでは、Promiseの処理に入らずに、さっさとthenに遷移してなにもせずに抜けてゆくのが見て取れます。
その後にPromiseの中に流れが入り、resolveでPromiseの処理を脱して、thenの記述に遷移する。これ、まさにCallbackそのものです。
この様に泥臭い目視をすることでPromiseの実体が理解できました。
JavaScriptにPromiseを実装したプログラマーさんが、Callback地獄を肩代わりして便利にしてくれている。ご苦労さまです。

Promiseで最初に試した事例

下記の2点に気づいたらスッキリと理解した気になった。
・非同期な処理をPromiseで包む。この包んだ部分はresolveかrejectを呼び出すまで復帰しない。
・Promiseで包んだ関数をthen等で組み立てる。thenにはresolve呼び出し時の引数が戻される。chathにはreject呼び出し時の引数が戻される。分かりづらいですね、要はCallbackをスマートに書くための構文です。
例:非同期なXMLHttpRequest(XHR)をPromise化

//XHRをPromiseで部品化
function fetchXHR(URL) {
	return new Promise((resolve, reject) => {
		let req = new XMLHttpRequest();
		req.open('GET', URL, true);
		req.onload = function() {
			if (req.status === 200) {
				resolve(req.responseText);
			} else {
				reject(new Error(req.statusText));
			}
		};
		req.onerror = function() {
			reject(new Error(req.statusText));
		};
		req.send();
	});
}

//部品化したXHRを利用する
let URL = 'http://hogehog.xyz/webdata';
fetchXHR(URL).then((value) => {
	console.log(value);
	・・・読み込んだデータを処理
}).catch((error) => {
	console.error(error);
	・・・読み込みエラーの処理
});
※上記は事例と同じことはfetchで記述する方が簡素ですので、この事例はご愛嬌です。

部品のresolveと操作のthenの関係

Promiseの中で記述してあるresolveにXHRのonload(XHRのcallback)に戻ったデータを渡すと、fetchXHR呼び出ししている操作部分のthenの所に書いた関数に結果が渡されてくる。

部品のrejectと操作のcatchの関係

Promiseの中で記述してあるrejectにXHRのonerror(XHRのcallback)に戻されたエラー理由を渡すと、fetchURL呼び出ししている操作部分のcatchの所に書いた関数にエラー内容が渡されてくる。

結局はcallbackを逐次型っぽく、

その操作の流れを解りやすく記述できるのがPromise。
上記のXHRの例のように単純なものなら、従来のcallbakでコーディングしても可読性には大差ありません。

Promise.resolve(value)とPromise.reject(error) はPromiseの糖衣構文

ネット上の実例を見ると「new Promise」や「Promise.resolve」いう書き方があり、落書き人は混乱してしまいました。調べてみると後者は糖衣構文だと判明しました。
さらに後者はPromise非同期処理じゃない関数が呼び出されていたり、落書き人の頭の中はぐ~るぐる、落書き人の頭はHALT状態に陥りました。

例えば

Promise.resolve(42);
new Promise((resolve) => {
	resolve(42);
});
の糖衣構文です。
前者のPromise.resolveはthenableなオブジェクトをpromiseオブジェクトに変換するという機能があります。φ(..)メモメモ

例えば

Promise.reject(new Error("エラー"))
new Promise((resolve, reject) => {
    reject(new Error("エラー"));
});
の糖衣構文です。

Promiseを理解するのに読んだ文献
async/await

async/awaitの要点

asyncの中のawait箇所で結果が戻るまでasyncの処置が一時停止する。つまり逐次処理で有るかのように記述できるので、直感的でわかりやすい記述ができる。Promise記述が難解であると感じる場合は、このasync/awaitで記述するほうが良いかもしれない。
なおECMAScript 2017(ES8)からの機能だが、各モダンブラウザはほぼ対応済

async / awaitについて

Promiseでラッピングしてawait

asyncにてcallbackを代入するonプロパティをPromiseでラッピングしてawait処理させる。
一見して難解に見えるが、イベント発火を待ち逐次処理の様に記述できるので至極便利。

this.socket.onmessage = async (data) => {
    //受信したblobデータをUnit8Arrayに変換
    this.fileReader.readAsArrayBuffer(data.data);
    await new Promise(resolve => this.fileReader.onload = () => resolve());
    let u8a = new Uint8Array(this.fileReader.result);
    //oggコンテナからwebmコンテナに載せ替えてchunkBufへ格納
    let wrapWebm = this.audioWrapper.iterator(u8a);
    for (const wrappedAudio of wrapWebm) {
	    this.chunkBuffer.push(wrappedAudio);
	}
}