JavaScript Promise デザインパターン - エラーハンドリング , reduce を使った可変長非同期逐次処理 , キャンセル可能なPromise , イベントループなど -

  • 18
    Like
  • 0
    Comment

JavaScript Promise デザインパターン - reduce を使った可変長非同期逐次処理 , キャンセル可能なPromise など -

Promise の中では throw してはならない

このコードは何が表示されるだろうか?

new Promise((resolve)=>{
  setTimeout(()=>{
    throw new Error("foo");
    resolve("bar");
  });
})
.then((a)=>{ alert(a); })
.catch((err)=>{ alert(err); });

答えは 何も起きない である。

setTimeout の中の関数のような非同期実行コンテキストで throw したエラーはグローバルエラーとなるため
window.onerrorprocess.on('uncaughtException', (err) => {}) でしか拾うことができない。

代わりに reject を使って

new Promise((resolve, reject)=>{
  setTimeout(()=>{
    return reject("foo");
    resolve("bar");
  });
})
.then((a)=>{ alert(a); })
.catch((err)=>{ alert(err); });

のように書かなければならない。

ちなみに Promise の引数関数 (resolve, reject)=>{} は同期的に実行される ため、

console.log("a");
new Promise((resolve)=>{
  console.log("b");
  throw new Error("foo");
  resolve("bar");
})
.then((a)=>{ alert(a); })
.catch((err)=>{ alert(err); });

a, b が表示されてから alert(err) が発生し、問題なくエラーハンドリングできる。

しかし、ややこしいため Promise の中では throw は使わず必ず reject する ことを心がけるべきである。

new Promise(resolve =>{ ... }) というコードは書かない。

new Promise(resolve =>{ ... }) というコードを見かけたら要注意である
そのコードはエラーハンドリングがされていない可能性が濃厚だからである。
reject されない Promise はエラーを catch することはできない。デバッグなどほとんど不可能なコードになってしまう。

あなたのリポジトリも git grep "new Promise" で resolve だけPromise がないかチェックしてみよう。

例外的に失敗がありえない非同期処理(例: setTimeoutなど)は resolve だけでも良い。

投げっぱなしの非同期処理はコメントに書いておく

処理の失敗を気にせず投げっぱなしの非同期処理を書きたいときもある(例: サーバにテレメトリを定期的に投げる)。

その場合はコメントで この非同期処理は投げっぱなしであることを明記 すべきである

setInterval(()=>{
  // 定期的にサーバにアクセスして生存報告するがフロント側では特になにもしない
  post("http://api.foo.com/post")
    .then(()=>{/* 特に何もしない */})
    .catch((err)=>{/* うっとうしい */});
}, 10 * 1000);

catch した 例外をさらに reject したい

次のように reject された Promise を catch してエラー理由を表示したい時があります。

Promise.reject(new Error("foo"))
  .catch((err)=>{ console.error(err); })
  .then((bar)=>{ console.log(bar); });

この .catch((err)=>{ console.error(err); }) はエラーを表示した後、 Promise<void> を返すので
.then((bar)=>{ console.log(bar); }); では undefined と表示してしまいます。

Promise.reject(new Error("foo"))
  .catch((err)=>{ console.error(err); return Promise.reject(err); })
  .then((bar)=>{ console.log(bar); });

こういう場合は .catch((err)=>{ console.error(err); return Promise.reject(err); }) として catch の中であらためて reject 値を作りましょう

可変長の非同期タスクを逐次実行

function runAsyncSerialTasks(tasks: (()=> Promise<void>)[]): Promise<void> {
    return tasks.reduce((prm: Promise<void>, task: () => Promise<void>) =>
        prm.then(task), Promise.resolve(void 0));
}

reduce で Promise.resolve(void 0).then(tasks[0]()).then(tasks[1]())... という promise chain を作る。

例: x-www-form-urlencoded で順番にPOST

function toURLEncoded(o: {[key: string]: string}): string {
    return Object.keys(o).map((key)=> key+"="+encodeURIComponent(o[key])).join("&"); 
}
const urlAndDatas = [
  { url: "http://api.example.com/user/create", data: {hello: "world"}}
];
const urlAndDataStrs = urlAndDatas.map(({url, data})=> ({url, data: toURLEncoded(data)})) 
const tasks = urlAndDataStrs.map(({ url, data }) =>
    () =>
        fetch(url, {
            method: "POST",
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'
            },
            body: data }).then(() => void 0));

runAsyncSerialTasks(tasks)
    .then(()=>{ console.log("finish"); })
    .catch((err) => { console.error(err); });

例:node.js で順番にファイルIO

function runAsyncSerialTasks(prmTasks: (()=> Promise<void>)[]): Promise<void> {
    return prmTasks.reduce((prm: Promise<void>, task: () => Promise<void>) =>
        prm.then(task), Promise.resolve(void 0));
}
const fs = require("fs");
const filenameAndContents = [
    { name: "foo.txt", content: "foo" },
    { name: "bar.txt", content: "bar" }
];
const tasks = filenameAndContents.map(({ name, content }) =>
    () =>
        new Promise<void>((resolve, reject) => {
            fs.writeFile(name, "utf8", content, (err) => {
                if (err != null) { return reject(err); }
                else { return resolve(void 0); }
            });
        })
);
runAsyncSerialTasks(tasks)
    .then(() => { console.log("finish"); })
    .catch((err) => { console.error(err); });

可変長の非同期タスクを逐次実行して結果も取得

Promise の結果も取得した場合。逐次GETや逐次ファイル読み込みなどの場合につかう。

function runAsyncSerialTasks<T>(tasks: (()=> Promise<T>)[]): Promise<T[]> {
    return tasks.reduce((prm, task) =>
        prm.then((lst)=> task().then((ret)=> lst.concat(ret))), Promise.resolve<T[]>([]));
}

reduce の中で task().then((ret)=> lst.concat(ret)) として結果のリストを構成している。

runAsyncSerialTasks([
    () => new Promise((resolve) => resolve(0)),
    () => new Promise((resolve) => resolve(1)),
    () => new Promise((resolve) => resolve(2))
]).then((lst)=> console.log(lst))

結果 [0,1,2] と表示される。

問題点:リトライがしたい

Promise.resolve(void 0).then(tasks[0]()).then(tasks[1]())... という promise chain ではどれかひとつエラーが起きたときに残りのタスクが強制終了されてしまう。
せめてリトライがしたくなる。

リトライ付きの可変長の非同期タスクを逐次実行

function runAsyncSerialTasksWithRetry(tasks: (() => Promise<void>)[], count = 0): Promise<void> {
    let i = 1;
    return tasks.reduce((prm: Promise<void>, task: () => Promise<void>) =>
        prm.then(function recur(){
          return task().catch((err)=>{
            if(i >= count){ return Promise.reject(err); }
            else {
                i += 1;
                return recur();
            }
          });
        }), Promise.resolve(void 0));
}

runAsyncSerialTasks 関数の引数にリトライ回数をつけ、reduce の中身を再帰的にリトライするようにした。

逐次実行中の Promise Chain をキャンセルしたい

JS の Promise は C# の Task とは違ってキャンセルできない。
そこで reduce の中で task がひとつ終わるごとに次を続けるかどうかを確認する処理を入れる。

function runCancelableAsyncSerialTasks(tasks: (() => Promise<void>)[]): { cancel: () => void, promise: Promise<void> } {
    const ret = {
        promise: Promise.resolve(void 0),
        cancel: () => { ret.canceled = true; },
        canceled: false
    };
    ret.promise = tasks.reduce((prm, task) =>
        prm
            .then(task)
            .then(() =>
                ret.canceled ? Promise.reject(new Error("canceled"))
                             : Promise.resolve(void 0) )
    , Promise.resolve(void 0));
    return ret;
}

const cancelable = runCancelableAsyncSerialTasks([
    () => new Promise((resolve) => setTimeout(() => { console.log("a"); resolve(); }, 1000)),
    () => new Promise((resolve) => setTimeout(() => { console.log("b"); resolve(); }, 1000)),
    () => new Promise((resolve) => setTimeout(() => { console.log("c"); resolve(); }, 1000)),
]);

cancelable.promise
    .then(() => { console.log("finish"); })
    .catch((err) => { console.error(err); });

setTimeout(() => { cancelable.cancel(); console.log("cancel"); }, 1100);

実行結果

a
cancel
b
Error: canceled
    at <anonymous>:11:50
    at <anonymous>

この場合 b の出力待ち中にキャンセルが入るので b が表示されてからキャンセルされる。
すでに実行中の b を止めることはできない。

こうなると非同期 task にもキャンセルを入れたくなってくる。

キャンセル可能な Promise を作る

setTimeout を Promise 化する例はよく見られるが、やはり clearTimeout にも対応しつつ Promise で包みたい、ということは多々ある。

type CancelablePromise<T> = { promise: Promise<T>, cancel: () => void };

function cancelableSleep(ms: number): CancelablePromise<void> {
    let tid = 0;
    let _reject: (err: Error)=> void;
    const ret = {
        cancel: () => {
            clearInterval(tid);
            _reject(new Error("canceled"));
        },
        promise: new Promise<void>((resolve, reject) => {
            tid = setTimeout(resolve, 1000);
            _reject = reject;
        })
    };
    return ret;
}

const cancelable = cancelableSleep(1000)
cancelable.promise
    .then(() => { console.log("finish"); })
    .catch((err) => { console.error(err); });
setTimeout(() => { cancelable.cancel(); console.log("cancel"); }, 1500);

このように { promise: Promise<T>, cancel: () => void } という型を CancelablePromise<T> と名付ける。
そして cancel() を呼ぶと reject(new Error("canceled")) されるように関数を作れば良い。

JSではキャンセル可能な非同期APIは比較的珍しいのであまり使うことはないかもしれない( XMLHttpRequest.abort() とか? )

逐次実行中の Cancelable Promise をキャンセルする

CancelablePromise<T> を定義したので reduce でこれを逐次実行してみよう。

function runCancelableAsyncSerialTasks(tasks: (() => CancelablePromise<void>)[]): CancelablePromise<void> {
    const ret = {
        cancel: () => void 0,
        promise: tasks.reduce((prm, task) =>
            prm
                .then(task)
                .then(({ promise, cancel }) => {
                    ret.cancel = cancel;
                    return promise;
                }), Promise.resolve(void 0)),

    }; 
    return ret;
}

const cancelable = runCancelableAsyncSerialTasks([
    () => (console.log("a"), cancelableSleep(1000)),
    () => (console.log("b"), cancelableSleep(1000)),
    () => (console.log("c"), cancelableSleep(1000)),
]);

cancelable.promise
    .then(() => { console.log("finish"); })
    .catch((err) => { console.error(err); });
setTimeout(() => { cancelable.cancel(); console.log("cancel"); }, 1500);

出力

a
b
cancel
Error: canceled
    at Object.cancel (<anonymous>:7:21)
    at <anonymous>:39:37

今度は文字を表示してから sleep するようにしたので b が表示された後の sleep がキャンセルされている。

その他 Tips

ECMAScript ではイベントループや setTimeout は定義されていない

  • そのため setTimeout は ECMAScript 仕様外である
  • 実際 Java の JS 処理系である Rhino はイベントループはなく当然 setTimeout もない
  • イベントループおよび setTimeout の仕様は HTMLnode.js でそれぞれ別に定義されている
  • にも関わらず Promise は ECMAScript2015 で定義されている。わけがわからないよ

Promise の引数関数は同期的に実行される

上述の通り Promise とイベントループは仕様上は直接関係がないので

console.log("a");
new Promise(()=>{
  console.log("b");
});
console.log("c");

a, b, c の順番に同期的に実行される

async-await と yield と Promise と callback の違い

Promise の all と race の使いかた

まとめ

  • Promise の中で throw しない
  • Promise には必ず reject する条件も書く
  • 可変長の非同期逐次処理は reduce を使うことでうまく制御できる
  • reduce の中の処理を文脈の必要に応じて変えることでリトライやキャンセル可能な非同期逐次処理が作れる
  • ブラウザ JavaScript や node.js のイベントループは ECMAScript 仕様外だが Promise は仕様内である