TypeScript で API 呼び出し結果をキャッシュするクラスを作ってみた

実装:

class CachedAsyncStore<T, Key = string> {
  private promiseMap: Map<Key, Promise<T>> = new Map();
  private fn: (key: Key) => Promise<T>;

  constructor(fn: (key: Key) => Promise<T>) {
    this.fn = fn;
  }

  get(key: Key): Promise<T> {
    const { promiseMap } = this;
    const cachedPromise = promiseMap.get(key);
    if (cachedPromise != null) {
      return cachedPromise;
    }
    const newPromise = this.fn(key);
    promiseMap.set(key, newPromise);
    return newPromise;
  }
  deleteCache(key: Key): boolean {
    return this.promiseMap.delete(key);
  }

  async getOrRetry(key: Key): Promise<T> {
    const { promiseMap } = this;
    for (;;) {
      const cachedPromise = promiseMap.get(key)
      if (cachedPromise == null) {
        break;
      }
      try {
        const result = await cachedPromise;
        return result;
      } catch (e) {
        // retry
        if (cachedPromise === promiseMap.get(key)) {
          promiseMap.delete(key);
        }
      }
    }
    const newPromise = this.fn(key);
    promiseMap.set(key, newPromise);
    return newPromise;
  }

  clearCache(): void {
    this.promiseMap = new Map();
  }
}


使い方:

// コンストラクタに取得関数を渡す
const store = new CachedAsyncStore<{ value: string }>(async (key) => {
  console.log(`API called: key: ${key}`);
  await new Promise<void>(resolve => setTimeout(resolve, 1000));
  if (key === 'error') {
    throw new Error('error!');
  }
  return {
    value: key,
  }
});

(async () => {
  // get で API を呼び出す
  console.log(await store.get('hoge'));
  // 既に呼び出されたことがあった場合はキャッシュが使われる
  console.log(await store.get('hoge'));

  // 並列で呼び出しても API は1回のみ呼ばれる
  console.log(await Promise.all([
    store.get('fuga'),
    store.get('fuga'),
    store.get('fuga'),
  ]));

  // エラーの場合
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }
  // get だと API 呼び出しは再試行されずにエラーになる
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }

  // エラーなら再試行したい場合は getOrRetry を使う
  // 並列で呼び出した場合でも直列化されるオマケつき
  console.log(await Promise.allSettled([
    store.getOrRetry('error'),
    store.getOrRetry('error'),
    store.getOrRetry('error'),
    store.getOrRetry('error'),
  ]));
})();


動機:

// 素朴な実装の場合
class SimpleCachedAsyncStore<T, Key = string> {
  private resultMap: Map<Key, T> = new Map();
  private fn: (key: Key) => Promise<T>;

  constructor(fn: (key: Key) => Promise<T>) {
    this.fn = fn;
  }

  async get(key: Key): Promise<T> {
    const { resultMap } = this;
    const cachedValue = resultMap.get(key);
    if (cachedValue !== undefined) {
      return cachedValue;
    }
    const newValue = await this.fn(key);
    resultMap.set(key, newValue);
    return newValue;
  }
  deleteCache(key: Key): boolean {
    return this.resultMap.delete(key);
  }

  clearCache(): void {
    this.resultMap = new Map();
  }
}

const store = new SimpleCachedAsyncStore<{ value: string }>(async (key) => {
  console.log(`API called: key: ${key}`);
  await new Promise<void>(resolve => setTimeout(resolve, 1000));
  if (key === 'error') {
    throw new Error('error!');
  }
  return {
    value: key,
  }
});

(async () => {
  // get で API を呼び出す
  console.log(await store.get('hoge'));
  // 直列なら問題ない(キャッシュされた値が使われる)
  console.log(await store.get('hoge'));

  // 並列で呼び出した場合に API が複数回実行されてしまう
  console.log(await Promise.all([
    store.get('fuga'),
    store.get('fuga'),
    store.get('fuga'),
  ]));

  // エラーの場合
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }
  // 直列でも毎回呼び出される(エラー処理してないため)
  try {
    await store.get('error');
  } catch (e: any) {
    console.error(e.message);
  }

  // 並列の場合はエラーになるのを待たずに API が実行される
  console.log(await Promise.allSettled([
    store.get('error'),
    store.get('error'),
    store.get('error'),
    store.get('error'),
  ]));
})();


既知の問題点:

  • fn を呼び出す際に this が CachedAsyncStore のインスタンスになってしまう(本来はコンストラクタなりで thisArg を受け渡すべきなのだが、面倒なのでやっていない)
  • key 以外の引数をコールバックに渡せない( Key をオブジェクトにすると中身は比較されずインスタンス毎に別の値としてキャッシュされてしまう)
  • 1つの Promise の値に対して何回も await することになるが、規格上それで問題ないのかを調べていない(筆者の母語C++ なので未定義動作が怖い)

他に問題点が ありましたら指摘をお願いします。