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