Promise のキャンセルについて

[ [English version](https://qiita.com/martinheidegger/items/3e6355e96e85fc1c841e) ]

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

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

## 歴史

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

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

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

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

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

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

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

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

[AbortController-node]: https://github.com/nodejs/node/commit/a8b26d72c5
[gen-fn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator
[prop-cancellation]: https://github.com/tc39/proposal-cancellation/
[CAF]: https://github.com/getify/CAF

## 問題の概要

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

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

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

```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` に切り替えることを決めた場合はどうなるでしょうか？

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

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

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

```javascript
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()][readfile] に新しいオプションであるsignalが追加されていることに気づくかもしれません。また、実験的な [require('timers/promises')][timers-promises] API では、`setTimeout(200, { signal })` も可能になっています（他の例にも注目してください！）。

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

```javascript
// 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を使うのはどうでしょうか？

```javascript
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][.aborted]を使えば可能です。

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

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

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

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

:scream:

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

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

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

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

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

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

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

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

## 良好な API

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

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

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

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

```typescript
import { AbortSignal } from 'abort-controller'

interface MyApiOptions {
  signal?: AbortSignal
}

declare function myApi (opts?: MyApiOptions): void
```

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

```javascript
// 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](https://qiita.com/rithmety)）。これで将来的にも安心ですね！ :dark_sunglasses:

[aborterr-browser]: https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError
[aborterr-nodejs]: https://github.com/nodejs/node/blob/master/lib/internal/errors.js#L735-L741

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

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

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

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

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

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

```javascript
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()`][bind]を使ってみよう。

[bind]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind

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

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

```javascript
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: 

```javascript
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()`][promise-race] を使って、2つのプロセスのうち遅い方をアボートさせてみましょう。

```javascript
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` されています。ここまでは順調です。しかし、まだ発信者に制御を与えたい場合はどうなるのでしょうか?


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

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

```javascript
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` ヘルパーを使うと、最初のタスクに戻ることができます。

```javascript
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:

```shell
$ npm install @consento/promise --save
```

```javascript
const { AbortError, bubbleAbort, checkpoint } = require('@consento/promise')
```

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

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

```javascript
const { raceWithSignal } = require('@consento/promise')

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

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

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

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

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

```javascript
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を作るのが楽になりました。もしかしたらあなたもそうするかもしれません。

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

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

[abort-controller-npm]: https://www.npmjs.com/package/abort-controller
[dts]: https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html
[typescript]: https://www.typescriptlang.org/
[promises-new-issue]: https://github.com/consento-org/promise/issues/new/choose
[timers-promises]: https://nodejs.org/api/timers.html#timers_timerspromises_settimeout_delay_value_options
[promise-race]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race
[abort-event]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/abort_event
[.aborted]: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/aborted
[readfile]: https://nodejs.org/api/fs.html#fs_fs_readfile_path_options_callback
[prop-cancel]: https://github.com/tc39/proposal-cancellation
[async-await]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
[Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise
[bluebird]: http://bluebirdjs.com/
[bluebird-cancel]: http://bluebirdjs.com/docs/api/cancel.html
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
[AbortController]: https://developer.mozilla.org/en-US/docs/Web/API/AbortController