45
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Node.jsAdvent Calendar 2020

Day 3

Promise のキャンセルについて

Last updated at Posted at 2020-12-03

[ 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 のサンプルコードを見てみましょう。

async function getPage (list, page) {
  const response = await fetch(`/data?list=${list}&page=${page}`) 
  return await response.json()
}

async function getList (list) {
  let page = 0, hasMore = false, data = []
  do {
    const loaded = await getPage(list, page)
    data = data.concat(loaded.data)
    hasMore = page !== loaded.lastPage
    page++
  } while (hasMore)
  return data
}

const promiseA = getList('users')
promiseA.then(
  entries => { /* do something */ },
  error => { console.error(error) }
)

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

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

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

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

let currentRequest = 0
async function getList (list) {
  let page = 0, hasMore = false, data = []
  const request = ++currentRequest
  do {
    console.log(`ゲット ${list}/${page}`)
    if (request !== currentRequest) {
      throw new Error('新しいリクエストがきました、キャンセルしました。')
    }
    const loaded = await getPage(list, page)
    data = data.concat(loaded.data)
    hasMore = page < loaded.lastPage
    console.log({ hasMore, page, last: loaded.lastPage })
    page++
  } while (hasMore)
  return data
}

以上です。この記事の残りの部分は、これが一番いい方法だということを書いています :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')

const control = new AbortController()
const promise = setTimeout(
  () => console.log('hello world'),
  500,
  { signal: control.signal }
)

control.abort()

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

Uncaught:
DOMException [AbortError]: The operation was aborted

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

async function getPage (..., signal) {
  const response = await fetch(..., { signal }) 
  return await response.json()
}

async function getList (..., signal) {
  // ...
  do {
    const loaded = await getPage(..., signal)
    // ...
  } while (hasMore)
  return data
}

const control = new AbortController()
const promiseA = getList('users', control.signal)
control.abort()

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

async function getPage (..., signal) {
  // ...
  if (signal?.aborted) {
    throw new Error('aborted')
  }
  return await response.json()
}

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

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

const control = new AbortController()
control.abort()
getList('users', control.signal)

:scream:

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

async function getList(..., signal) {
  if (signal?.aborted) {
    throw new Error('aborted')
  }
  // ..
}

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

function waitForAbort(signal) {
  return new Promise(resolve => {
    signal.addEventListener('abort', resolve)
  })
}

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

function waitForAbort(signal) {
  return new Promise(resolve => {
    if (signal.aborted) resolve()
    else signal.addEventListener('abort', resolve)
  })
}

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

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

良好な API

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

async function myApi ({ signal } = {}) {}

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

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

import { AbortSignal } from 'abort-controller'

interface MyApiOptions {
  signal?: AbortSignal
}

declare function myApi (opts?: MyApiOptions): void

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

// Assuming top-level await
try {
  await myApi()
} catch (error) {
  if (error.name === 'AbortError') {
    // do nothing
  }
  Handle error
}

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

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

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

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

class AbortError extends Error {
  constructor() {
    super('The operation was aborted.')
    this.code = "ABORT_ERR"
    this.name = 'AbortError'
  }
}

if (signal?.aborted) {
  throw new AbortError()
}

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

function bubbleAbort(signal) {
  if (signal?.aborted) throw new AbortError()
}

async function getPage(list, page, { signal } = {}) {
  bubbleAbort(signal)
  const response = await fetch(..., { signal })
  bubbleAbort(signal)
  return await response.json()
}

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

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

async function getPage(list, page, { signal } = {}) {
  const checkpoint = bubbleAbort.bind(null, signal)
  checkpoint()
  const response = await fetch(..., { signal }) 
  checkpoint()
  return await response.json()
}

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

function checkpoint (signal) {
  bubbleAbort(signal)
  return () => bubbleAbort(signal)
}

async function getPage(list, page, { signal } = {}) {
  const cp = checkpoint(signal)
  const response = await fetch(..., { signal }) 
  cp()
  return await response.json()
}

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

function checkpoint (signal) {
  bubbleAbort(signal)
  return next => {
    bubbleAbort(signal)
    return next?()
  }
}

async function getPage(list, page, { signal } = {}) {
  const cp = checkpoint(signal)
  const response = await fetch(..., { signal })
  return await cp(() => response.json())
}

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

AbortSignal が必要なら

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

const { setTimeout } = require('timers/promises')

function resolveRandom (data, opts) {
  return setTimeout(Math.random() * 400, data, opts)
}

function startTwoThings () {
  const controller = new AbortController()
  const opts = { signal: controller.signal }
  return Promise.race([
    resolveRandom('A', opts),
    resolveRandom('B', opts),
  ]).finally(
    () => controller.abort()
  )
}

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

function startTwoThings ({ signal } = {}) {
  const controller = new AbortController()
  // ...
}

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

function composeAbort (signal) {
  bubbleAbort(signal) // signal could be aborted
  const controller = new AbortController()
  let aborted = false
  const abort = () => {
    if (aborted) return
    aborted = true
    signal?.removeEventListener('abort', abort)
    controller.abort()
  }
  signal?.addEventListener('abort', abort)

  return { abort, signal: controller.signal }
}

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

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

function startTwoThings ({ signal } = {}) {
  const { abort, signal } = composeAbort(signal)
  const opts = { signal }
  return Promise.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')

function startTwoThings (opts) {
  return raceWithSignal(signal => [
    resolveRandom('A', { signal }),
    resolveRandom('B', { signal }),
  ], opts?.signal)
}

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

const { wrapTimeout } = require('@consento/promise')

await wrapTimeout(
  signal => startTwoThings({ signal }),
  { timeout: 100 }
)

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

const { cleanupPromise } = require('@consento/promise')

const controller = new AbortController()
const p = cleanupPromise(
  (resolve, reject, signal) => {
    // Like regular promise but with added `signal` property
    return function cleanup () {
      // Cleanup operation is called in place of finally
    }
  },
  { timeout: 500, signal: controller.signal }
)

最後の言葉

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

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

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

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

45
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?