[ 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
よりも良い響きです) が重要なディテールとなりました。キャンセルを使えば、もう関係のないリクエストの実行を防ぐことが可能になり、クライアントやサーバの高速化につながります。
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}
以上です。この記事の残りの部分は、これが一番いい方法だということを書いています .
この単純な例では、すべての実装でトピックとなるいくつかの問題が強調されています。
- 誰がコントロールしているのか?この場合は内部的に停止しています。
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
いいね。 さて、最初の例で同じ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
の呼び出しが発生しないことを確認することができます。
しかし、そうはいきません! .aborted
は真になる可能性があります。で、ここで何が起こるかというと
constcontrol=newAbortController()control.abort()getList('users',control.signal)
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
ファイルだけでもいいのですが - は、signal
に abort-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)。これで将来的にも安心ですね!
ノイズが少なく、ミスが少ない
コードに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()}
まだちょっとうるさい。もっとうまくやればいいんだよ!
functioncheckpoint(signal){bubbleAbort(signal)returnnext=>{bubbleAbort(signal)returnnext?()}}asyncfunctiongetPage(list,page,{signal}={}){constcp=checkpoint(signal)constresponse=awaitfetch(...,{signal})returnawaitcp(()=>response.json())}
それはそれでいいようですね。よくできた!
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 パッケージに入れて公開してみましょう。
おっと、それはもう終わった!
$ npm install @consento/promise --save
const{AbortError,bubbleAbort,checkpoint}=require('@consento/promise')
最近、私たちのコードからこれらの関数を抽出してライブラリとして公開しました。
それ以上のフィーチャーがある!
一つは 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})
最後の言葉
現時点でのキャンセルについては以上となります。楽しんでいただけましたでしょうか?
あなたは私と同じように Node.js の進歩に興奮していますか?
これらのいくつかのトリックを使うと、キャンセル可能なAPIを作るのが楽になりました。もしかしたらあなたもそうするかもしれません。
ご質問があれば、日本語でも私たちの問題追跡システムでお知らせいただくか、コメントを追加してください!
また来年に!良いお年を!