API を 1 回だけ呼んであとは同じ結果を得たい場合、値ではなく Promise をキャッシュする

REST API の呼び出し結果を、集中的に何度も使いたいような場合、API のレスポンスをキャッシュするのではなく、Promise をキャッシュすることで、API が 1 回だけ呼ばれることを保証できます。

## 何もキャッシュしない場合

まず最初に、何もキャッシュしない場合を見てみます。

```js
import axios from 'axios'

class App {
  /**
   * API に GET リクエストを送信するメソッド。何もキャッシュしないバージョンです。
   */
  async request(url) {
    return await axios.get(url)
  }

  /**
   * API を使ってなにかするメソッドです。
   */
  async doSomethingWithAPI(url, index) {
    const result = await this.request(url)
    console.log(index, result.data)
  }

  async main() {
    const url = 'http://localhost:8000/api/test.json'

    // API の結果を使いたい処理が 10 個あるものとします。
    for (let i = 0; i < 10; i++) {
      // 挙動をわかりやすくするため少しタイミングをずらします。
      await this.sleep(1) 
      // 並列で実行したいので await しません。
      this.doSomethingWithAPI(url, i)
    }
  }

  /**
   * 非同期の sleep 実装。記事の本題とは関係ありません。
   */
  async sleep(msec) {
    return new Promise(resolve => setTimeout(resolve, msec))
  }
}

const app = new App()
app.main()
```

API は、リクエストするたびにカウントを1ずつ増やした結果を返すものとします。(サーバのコードは後述します)

実行結果は以下のようになります。何もキャッシュしていないので、10 回 API が呼ばれて、カウントが 10 まで増えます。

```
0 { count: 1 }
1 { count: 2 }
2 { count: 3 }
3 { count: 4 }
4 { count: 5 }
5 { count: 6 }
6 { count: 7 }
7 { count: 8 }
8 { count: 9 }
9 { count: 10 }
```

## 値をキャッシュすると何が起きるか

`request(url)` メソッドを以下で置き換えてみます。API レスポンスの値をキャッシュするバージョンです。

```js
  constructor() {
    this.cache = {}
  }

  /**
   * 渡された URL をキーにして API レスポンスをキャッシュします。
   */
  async request(url) {
    if (!this.cache[url]) {
      this.cache[url] = await axios.get(url)
    }
    return this.cache[url]
  }
```

これを実行した結果は、以下のようになります。

```
0 { count: 1 }
7 { count: 1 } // キャッシュにヒット
1 { count: 2 }
2 { count: 3 }
3 { count: 4 }
4 { count: 5 }
8 { count: 5 } // キャッシュにヒット
5 { count: 6 }
9 { count: 6 } // キャッシュにヒット
6 { count: 7 }
```

`await axios.get(url)` でレスポンスを待っている間に、どんどん次のメソッド呼び出しに処理が移ってしまうため、全部で 7 回 API リクエストが発生しています。

左端の番号 `7` の呼び出しが、番号 `0` のキャッシュにヒットしています。`7` は `await` する必要がないので、レスポンスを待っている `1` 〜 `6` より先に処理が終わります。

このように、値をキャッシュすると、不要な API 呼び出しが発生してしまうことがあります。

API 結果が短期間に変化しないのであれば、 7 回も API を呼ぶ意味がありません。同じ結果を毎回返せばいいはずです。

## Promise をキャッシュする

不要な API 呼び出しを避けるには、API の値ではなく `Promise` をキャッシュします。

```js
  /**
   * 渡された URL をキーにして API 呼び出しの Promise をキャッシュします。
   */
  async request(url) {
    if (!this.cache[url]) {
      this.cache[url] = axios.get(url)
    }
    return this.cache[url]
  }
```

もう一度、値をキャッシュするバージョンを見てみましょう。

```js
  /**
   * 渡された URL をキーにして API レスポンスをキャッシュします。
   */
  async request(url) {
    if (!this.cache[url]) {
      this.cache[url] = await axios.get(url)
    }
    return this.cache[url]
  }
```

ほとんど一緒です！違いは、 `await axios.get(url)` の `await` を削った一箇所だけです。

たったこれだけの修正で、API が 1 回だけ呼ばれることを保証できます。`request()` メソッドの呼び出し側は何も変えていません。

```
0 { count: 1 }
1 { count: 1 }
2 { count: 1 }
3 { count: 1 }
4 { count: 1 }
5 { count: 1 }
6 { count: 1 }
7 { count: 1 }
8 { count: 1 }
9 { count: 1 }
```

Promise が、API のレスポンスが得られるまで待ってから、一斉に値を返す役目を果たします。また、API レスポンスが返った後も、同じ `Promise` を使い続けることができます。

## 現実世界の例

GraphQL で、各ノードの子が一斉にリゾルバを実行しようとするのに、REST API の呼び出しが 1 回しか発生しないのはどういう仕組みになっているんだろう、というのを調べていて知りました。

https://github.com/apollographql/apollo-server/blob/9d0188ed0f41a342942eb7572567b654a4f59851/packages/apollo-datasource-rest/src/RESTDataSource.ts#L268-L278

```ts
    if (request.method === 'GET') {
      let promise = this.memoizedResults.get(cacheKey);
      if (promise) return promise;

      promise = performRequest();
      this.memoizedResults.set(cacheKey, promise);
      return promise;
    } else {
      this.memoizedResults.delete(cacheKey);
      return performRequest();
    }
```

なるほど、こうすればいいのか！と、ちょっと感動しました。

これほど大事そうな実装パターンであれば名前が付いていても良さそうなものですが、決まった名前が見つけられていません。[Memoized Async Function](https://www.google.com/search?q=memoized+async+function) という感じでしょうか。

## テスト用の API サーバ

以下は、上のテストコードで使った、カウントをインクリメントする API サーバです。

```js
import http from 'http'

const port = 8000

let count = 0
http.createServer(async (req, res) => {
  // 少しタイミングを遅らせるため、console.log() を出力します。
  console.log(new Date(), req.url)

  count += 1
  res.writeHead(200, {'Content-Type': 'application/json'})
  res.write(`{"count": ${count}}`)
  res.end()
}).listen(port)

console.log(`Server running at http://localhost:${port}`)
```

`package.json` を含むサンプルを以下に置きました。使っている Node.js のバージョンは 14.16.1 です。

 * https://gist.github.com/koseki/c964ec15719a18e02d0f3576be6634de

## 参考にした記事

 * [Promiseを一回だけ実行する（非同期メモ化）](https://qiita.com/norami_dream/items/16e57db6d984696303d7)
 * [Memoize JavaScript Promises for Performance](https://medium.com/globant/memoize-javascript-promises-for-performance-1c77117fb6b8)