203
167

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.

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

Last updated at Posted at 2021-04-10

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

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

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

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 レスポンスの値をキャッシュするバージョンです。

  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 のキャッシュにヒットしています。7await する必要がないので、レスポンスを待っている 16 より先に処理が終わります。

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

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

Promise をキャッシュする

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

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

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

  /**
   * 渡された 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 回しか発生しないのはどういう仕組みになっているんだろう、というのを調べていて知りました。

    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 という感じでしょうか。

テスト用の API サーバ

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

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 です。

参考にした記事

203
167
3

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
203
167

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?