Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8896

Promise のキャンセルについて

$
0
0

[ English version ]

JavaScript と Node.js についてのこの徹底した投稿では、Promisesのキャンセルの歴史、なぜNode.jsに関係があるのか、そして async/await APIで使おうとしたときに注意すべきことについて学ぶことができます。

この投稿は、JavaScript の Promise API をよく理解していて、 Node.jsの経験がある方のためのものです。

歴史

2014 年に Promise API がブラウザに導入されて以来、人々は Promise で他に何ができるかを調べていました。ブラウザに最初に登場した関連APIは、HTTPリクエストのための fetch()でした。

HTTPリクエストの問題は、サーバーのリソースを消費することであり、サーバーに送信されるリクエストの数が多い場合はお金がかかります。このため、特に Cancellation (これは Abortingよりも良い響きです) が重要なディテールとなりました。キャンセルを使えば、もう関係のないリクエストの実行を防ぐことが可能になり、クライアントやサーバの高速化につながります。:rocket:

bluebird.jsライブラリは Promiseがブラウザに搭載される前から、非常に高速な Promise の実装を提供していました。そして、それが v3 で変更され、有効にする必要があるにもかかわらず、今日まで .cancel()メソッドを提供していますが、それが標準になったわけではありません。

ブラウザが fetch()リクエストをキャンセルするための努力として DOM クラス AbortControllerを広く利用できるようになるまでには、さらに 4 年かかりました。async/await が利用可能になったのとほぼ同時にです。(追記: DOM は JavaScript の仕様には含まれていません)

全体像を見るには Promises は 2015 年に v0.12 で Node.js に登場しました。 async/await は2017年のv8で安定しましたが、AbortController は 2020年の v15 で利用できるようになっただけで、この記事を書いている時点では安定していません!

Node.js の v15 を使う前に AbortController や AbortSignal を使いたい場合は、2018年から npm パッケージの abort-controllerが利用できるようになりました。

このトピックを取り巻く取り組みは他にもあります。最も顕著なのは、新しい提案された tc39 仕様 (JavaScript を開発している人たちのグループ) で、proposal-cancellationを当てたものがあります。

非同期ワークフローを処理するために「async/await」の代わりにジェネレータ関数を使用する CAFでは、もう一つの興味深いアプローチが提示されています。

問題の概要

proposal-cancellationの紹介文にあるように、キャンセルで利益を得ることができる多くのプロセスがあります。

  • async 関数またはイタレーター
  • リモートリソースの取得(HTTP, I/O, など)
  • バックグラウンドタスクとの連動(Web Workers、フォークドプロセス、など)
  • 長時間稼働(アニメーション など)
  • 同期観測(例:ゲームループ)
  • 非同期観測(例:XMLHttpRequest の中止、アニメーションの停止)

非常に理論的な視点です。問題がどこにあるのか、具体的な JavaScript のサンプルコードを見てみましょう。

asyncfunctiongetPage(list,page){constresponse=awaitfetch(`/data?list=${list}&page=${page}`)returnawaitresponse.json()}asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]do{constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page!==loaded.lastPagepage++}while(hasMore)returndata}constpromiseA=getList('users')promiseA.then(entries=>{/* do something */},error=>{console.error(error)})

この例では、サーバーから「ユーザー」リストをロードします。しかし、ユーザーが UI を usersから messagesに切り替えることを決めた場合はどうなるでしょうか?

constpromiseB=getList('messages')promiseB.then(entries=>{/*...*/})

現在、promiseBはまだ終わっていないかもしれませんが、promiseAが開始されています。両方のリクエストが並行して実行されているため、どちらが先に解決するかわからず、サーバは両方のリクエストを処理する必要があります。

キャンセルの仕組みを理解するために、特別なAPIを使わずに promiseAを停止させてみましょう。

letcurrentRequest=0asyncfunctiongetList(list){letpage=0,hasMore=false,data=[]constrequest=++currentRequestdo{console.log(`ゲット ${list}/${page}`)if(request!==currentRequest){thrownewError('新しいリクエストがきました、キャンセルしました。')}constloaded=awaitgetPage(list,page)data=data.concat(loaded.data)hasMore=page<loaded.lastPageconsole.log({hasMore,page,last:loaded.lastPage})page++}while(hasMore)returndata}

以上です。この記事の残りの部分は、これが一番いい方法だということを書いています :smiley_cat:.

この単純な例では、すべての実装でトピックとなるいくつかの問題が強調されています。

  • 誰がコントロールしているのか?この場合は内部的に停止しています。AbortSignalは外部ですが、他の概念が制御を共有している可能性があります。
  • すべてを停止できるわけではありません。この例では、HTTPのリクエストは出て行きますし、fetchも停止していません。私たちができることは、可能な限り将来のアクションを防ぐことです。
  • abort()の後に解決できます。この例では、次のリクエストを出す前に停止しているだけです。つまり、リクエストを中止したとしても、それは正常に解決される可能性があるということです!

AbortSignalを使用する

Node.js v15 API をよく見ると、fs.readFile()に新しいオプションであるsignalが追加されていることに気づくかもしれません。また、実験的な require('timers/promises') API では、setTimeout(200, { signal })も可能になっています(他の例にも注目してください!)。

使い方を見てみましょう。

// Node.js v15を想定しています。const{setTimeout}=require('timers/promises')constcontrol=newAbortController()constpromise=setTimeout(()=>console.log('hello world'),500,{signal:control.signal})control.abort()

これにより、以下のようなエラーが発生します。

Uncaught:
DOMException [AbortError]: The operation was aborted

いいね。 :wink:さて、最初の例で同じAPIを使うのはどうでしょうか?

asyncfunctiongetPage(...,signal){constresponse=awaitfetch(...,{signal})returnawaitresponse.json()}asyncfunctiongetList(...,signal){// ...do{constloaded=awaitgetPage(...,signal)// ...}while(hasMore)returndata}constcontrol=newAbortController()constpromiseA=getList('users',control.signal)control.abort()

これで getList(...., signal)getPage(...., signal)にシグナルを渡して fetchに渡しますが、await response.json()はどうすればいいのでしょうか? これはシグナルをサポートしていませんが、.abortedを使えば可能です。

asyncfunctiongetPage(...,signal){// ...if(signal?.aborted){thrownewError('aborted')}returnawaitresponse.json()}

これで、不要な .jsonの呼び出しが発生しないことを確認することができます。

しかし、そうはいきません! :thinking:.abortedは真になる可能性があります。で、ここで何が起こるかというと

constcontrol=newAbortController()control.abort()getList('users',control.signal)

:scream:

signalは、関数の開始時に abortedすることが可能です。つまり、signalを受け入れる関数は、最初に signalをチェックする必要があります。

asyncfunctiongetList(...,signal){if(signal?.aborted){thrownewError('aborted')}// ..}

私はこのことを、abortイベントを使おうとしたときに苦労しました。

functionwaitForAbort(signal){returnnewPromise(resolve=>{signal.addEventListener('abort',resolve)})}

これは、signalがアボートされて渡されたときには、決して解決されませんでした。これはデバッグするのが大変だったので、これを変更しなければなりませんでした。

functionwaitForAbort(signal){returnnewPromise(resolve=>{if(signal.aborted)resolve()elsesignal.addEventListener('abort',resolve)})}

ここで学んだことをまとめると

  • 誰が制御しているかがわかります:controlインスタンスを保持しているコードの一部!
  • これは "ベストエフォート "です:終了したコードを停止することはありません。
  • 信号が中断される場合があります:我々は早期に中断する必要があります。

良好な API

fetch()-APIは賢明にも signalをオプションにしています。シグナルを渡さなくても動作します。また、signalをオプションにするとよいでしょう。私が現在推奨しているAPIは

asyncfunctionmyApi({signal}={}){}

これにより、シグナルはオプションの optionsプロパティの一部となります。

TypeScriptを使用している場合 - .d.tsファイルだけでもいいのですが - は、signalabort-controllerが提供するシグネチャを与えた方がいいかもしれません。

import{AbortSignal}from'abort-controller'interfaceMyApiOptions{signal?:AbortSignal}declarefunctionmyApi(opts?:MyApiOptions):void

しかし、エラーの場合はどうでしょうか?どのようにしてプロミスをエラーのあるプロミスとアボートされたプロミスを区別することができるでしょうか?最も安全な方法は Errorインスタンスに .codeプロパティを追加することです。

// Assuming top-level awaittry{awaitmyApi()}catch(error){if(error.name==='AbortError'){// do nothing}Handleerror}

.name === 'AbortError'を使用しています。これは、Node.js`ブラウザで使用されているものと同じ .nameです(ありがとう @rithmety)。これで将来的にも安心ですね! :dark_sunglasses:

ノイズが少なく、ミスが少ない

コードにif-causeや複雑なエラー文を追加すると、確実にミスをしてしまいます。これらのミスを防ぐために、一緒にコードをリファクタリングしてみましょう!

最初のステップ。Object.assign()の代わりに Errorを拡張します。

classAbortErrorextendsError{constructor(){super('The operation was aborted.')this.code="ABORT_ERR"this.name='AbortError'}}if(signal?.aborted){thrownewAbortError()}

これにより、タイプする量が減り、正しいエラーを確認することができます。次のステップは、ifブロック全体を関数に抽出します。

functionbubbleAbort(signal){if(signal?.aborted)thrownewAbortError()}asyncfunctiongetPage(list,page,{signal}={}){bubbleAbort(signal)constresponse=awaitfetch(...,{signal})bubbleAbort(signal)returnawaitresponse.json()}

余談ですが、私は bubbleAbortという名前が好きです。

バブルバスのような響きがいいですね。それを簡単にするために、bind()を使ってみよう。

asyncfunctiongetPage(list,page,{signal}={}){constcheckpoint=bubbleAbort.bind(null,signal)checkpoint()constresponse=awaitfetch(...,{signal})checkpoint()returnawaitresponse.json()}

この方がよさそうです。さらに、このブロックを関数に抽出します。

functioncheckpoint(signal){bubbleAbort(signal)return()=>bubbleAbort(signal)}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})cp()returnawaitresponse.json()}

まだちょっとうるさい。もっとうまくやればいいんだよ! :muscle:

functioncheckpoint(signal){bubbleAbort(signal)returnnext=>{bubbleAbort(signal)returnnext?()}}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})returnawaitcp(()=>response.json())}

それはそれでいいようですね。よくできた! :thumbsup:

AbortSignalが必要なら

別のユースケースを少し見てみましょう。Promise.race()を使って、2つのプロセスのうち遅い方をアボートさせてみましょう。

const{setTimeout}=require('timers/promises')functionresolveRandom(data,opts){returnsetTimeout(Math.random()*400,data,opts)}functionstartTwoThings(){constcontroller=newAbortController()constopts={signal:controller.signal}returnPromise.race([resolveRandom('A',opts),resolveRandom('B',opts),]).finally(()=>controller.abort())}

これでゆっくりした方が abortされています。ここまでは順調です。しかし、まだ発信者に制御を与えたい場合はどうなるのでしょうか?

functionstartTwoThings({signal}={}){constcontroller=newAbortController()// ...}

2つの信号があるのか?これには創造的な問題解決が必要だ...

functioncomposeAbort(signal){bubbleAbort(signal)// signal could be abortedconstcontroller=newAbortController()letaborted=falseconstabort=()=>{if(aborted)returnaborted=truesignal?.removeEventListener('abort',abort)controller.abort()}signal?.addEventListener('abort',abort)return{abort,signal:controller.signal}}

これは強力ですね! これで、アボート可能なコントローラのような構造を手に入れました。単体でも、親プロセスを経由しても。

この新しい composeAbortヘルパーを使うと、最初のタスクに戻ることができます。

functionstartTwoThings({signal}={}){const{abort,signal}=composeAbort(signal)constopts={signal}returnPromise.race([resolveRandom('A',opts),resolveRandom('B',opts),]).finally(abort)}

いいね!レース機能が使えるようになったんだ!これはきっと便利になるよ!

NPMに掲載

うわー。ここまで来たので、AbortError, bubbleAbort, checkpoint, composeAbortをまとめて npm パッケージに入れて公開してみましょう。

おっと、それはもう終わった! :wink:

$ npm install @consento/promise --save
const{AbortError,bubbleAbort,checkpoint}=require('@consento/promise')

最近、私たちのコードからこれらの関数を抽出してライブラリとして公開しました。 :tada:
それ以上のフィーチャーがある!

一つは raceWithSignal()という関数です。それを使って前の例がさらに簡単になります。

const{raceWithSignal}=require('@consento/promise')functionstartTwoThings(opts){returnraceWithSignal(signal=>[resolveRandom('A',{signal}),resolveRandom('B',{signal}),],opts?.signal)}

また、タイムアウトに達したときに約束をキャンセルするというよくある問題を解決する wrapTimeoutも付属しています。

const{wrapTimeout}=require('@consento/promise')awaitwrapTimeout(signal=>startTwoThings({signal}),{timeout:100})

そして、今日の最後の仕上げとして cleanupPromiseがあります。これは new Promiseの代替です。これは cancellation, timeouts, cleanupメソッドをサポートしています。

const{cleanupPromise}=require('@consento/promise')constcontroller=newAbortController()constp=cleanupPromise((resolve,reject,signal)=>{// Like regular promise but with added `signal` propertyreturnfunctioncleanup(){// Cleanup operation is called in place of finally}},{timeout:500,signal:controller.signal})

最後の言葉

現時点でのキャンセルについては以上となります。楽しんでいただけましたでしょうか? :smile_cat:

あなたは私と同じように Node.js の進歩に興奮していますか?
これらのいくつかのトリックを使うと、キャンセル可能なAPIを作るのが楽になりました。もしかしたらあなたもそうするかもしれません。

ご質問があれば、日本語でも私たちの問題追跡システムでお知らせいただくか、コメントを追加してください! :heart:

:wave:また来年に!良いお年を!


Viewing all articles
Browse latest Browse all 8896

Trending Articles