30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

Cloudflare Workers KV をキャッシュとして使う

Cloudflare Workers KV を使うと、ストアに保存した値を HTTP レスポンスとして使うことができる。
そのため、オリジンサーバに問い合わせて得られた結果をストアに保存しておくことで、キャッシュのように使うことができる。
この記事では、Workers KV をキャッシュとして使う Workers スクリプトのサンプルを書いていく。

Workers KV のセットアップについては以下を参照。

numb86-tech.hatenablog.com

処理の大まかな流れ

今回は、以下の仕様の Workers スクリプトを作ることにする。

  • 対象の URL は/js/foo.js/js/bar.js
  • 対象の URL にアクセスがあった場合、KV にデータが格納されていないか確認する
    • 格納されていた場合はそれをクライアントに返す
    • 格納されていなかった場合はオリジンサーバに問い合わせ、そのレスポンスをそのままクライアントに返す
      • その際にオリジンサーバからのレスポンスを KV に格納しておき、以降のアクセスに対してはそれを返せるようにしておく
  • KV にレスポンスを格納する際にx-from-kvというフィールドをヘッダに付与し、KV からのレスポンスだとクライアントが分かるようにしておく

コードで表現すると以下のようになる。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    // URL をキーにして KV の中身を探し、見つかればそれをクライアントにレスポンスする
    // 見つからなければ以降の処理を続行する

    const freshResponse = await fetch(url);

    // オリジンサーバからのレスポンス(freshResponse)をクローンして KV に保存する
    // その際に x-from-kv フィールドを付与しておく

    return freshResponse;
  }

  return fetch(url);
}

コメントで書いた 2 箇所を実装すれば、実際に動くようになる。

オリジンサーバからのレスポンスを KV に保存する

まずは KV への保存から実装する。

オリジンサーバからのレスポンスを元にして新しくレスポンスオブジェクトを作り、x-from-kvフィールドを付与する。

const freshResponse = await fetch(url);

let response = freshResponse.clone();
response = new Response(response.body, response);
response.headers.append("x-from-kv", "true");

そしてこのレスポンスオブジェクトを KV に保存すればよいのだが、KV に保存できるのはstreamarrayBuffertextのみ。
そのため、レスポンスオブジェクトをそのまま保存することはできない。そこで、ヘッダとボディをそれぞれで保存することにする。ヘッダはtextに変換できるし、ボディはstreamなのでそのまま保存できる。

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)), // 渡されたオブジェクトを文字列に変換して保存している
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

// ヘッダをオブジェクトに変換する
const headerObj = Array.from(response.headers.entries()).reduce(
  (acc, entry) => {
    return {
      ...acc,
      [entry[0]]: entry[1],
    };
  },
  {}
);

event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

これで、MY_KVという名前の KV に保存されるようになる。
https://example.com/js/foo.jsにアクセスがあった場合、https://example.com/js/foo.js::HEADERというキーでヘッダを、https://example.com/js/foo.js::BODYというキーでボディを、それぞれ保存する。

KV からレスポンスを取り出す

次に、保存したレスポンスを KV から取り出す。getメソッドにキーを渡せばよいだけなので、難しいことは何もない。
typeとして"json"を指定すると、JSON をオブジェクトに変換して返してくれる。

const cachedHeader = await MY_KV.get(`${url.href}${SUFFIX_OF_HEADER}`, {
  type: "json",
});
const cachedBody = await MY_KV.get(`${url.href}${SUFFIX_OF_BODY}`);

if (cachedHeader && cachedBody) {
  return new Response(cachedBody, {
    headers: cachedHeader,
  });
}

KV は全てのエッジサーバで共有されるので、保存を行ったエッジサーバ以外でも値を取り出すことができる。
ただ、変換が即時で反映されるわけではない。結果整合性なので、強整合性が求められるケースでは使えない。

KV achieves this performance by being eventually-consistent. Changes are immediately visible in the edge location at which they're made, but may take up to 60 seconds to propagate to all other edge locations.

https://developers.cloudflare.com/workers/learning/how-kv-works

完成形

ここまでの内容をまとめ、関数の切り出しなどを行って整理したのが、以下のコード。

const TARGET_PATHNAME_LIST = ["/js/foo.js", "/js/bar.js"];

const SUFFIX_OF_HEADER = "::HEADER";
const SUFFIX_OF_BODY = "::BODY";

async function getHeaderAndBody(key) {
  const cachedHeader = await MY_KV.get(`${key}${SUFFIX_OF_HEADER}`, {
    type: "json",
  });
  const cachedBody = await MY_KV.get(`${key}${SUFFIX_OF_BODY}`);
  return { cachedHeader, cachedBody };
}

function putHeaderAndBody(key, header, body) {
  return Promise.all([
    MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header)),
    MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body),
  ]);
}

addEventListener("fetch", (event) => {
  event.respondWith(handleRequest(event));
});

async function handleRequest(event) {
  const url = new URL(event.request.url);

  if (TARGET_PATHNAME_LIST.includes(url.pathname)) {
    const { cachedHeader, cachedBody } = await getHeaderAndBody(url.href);

    if (cachedHeader && cachedBody) {
      return new Response(cachedBody, {
        headers: cachedHeader,
      });
    }

    const freshResponse = await fetch(url);

    let response = freshResponse.clone();
    response = new Response(response.body, response);
    response.headers.append("x-from-kv", "true");

    const headerObj = Array.from(response.headers.entries()).reduce(
      (acc, entry) => {
        return {
          ...acc,
          [entry[0]]: entry[1],
        };
      },
      {}
    );
    event.waitUntil(putHeaderAndBody(url.href, headerObj, response.body));

    return freshResponse;
  }

  return fetch(url);
}

このコードをデプロイすると、/js/foo.js/js/bar.jsにアクセスがあった場合はオリジンサーバからのレスポンスを KV に保存し、以降のアクセスでは KV から取り出した値をクライアントに返すようになる。
KV から取り出した場合はレスポンスヘッダにx-from-kvフィールドが付与されているので、クライアント側で確認できる。

有効期限を設定する

上記のコードだと、明示的に削除しない限り、KV に保存したデータはそのまま残り続ける。
データを保存する際に有効期限を設定することで、有効期限を経過した際に自動的に KV から削除されるようになる。そうすることで、Cache-Controls-maxage相当のことを実現できる。

具体的には、putメソッドにexpirationTtlオプションを渡せばよい。
以下のように記述すると、レスポンスを保存してから60秒後に自動的に KV から削除される。

MY_KV.put(`${key}${SUFFIX_OF_HEADER}`, JSON.stringify(header), { expirationTtl: 60 }),
MY_KV.put(`${key}${SUFFIX_OF_BODY}`, body, { expirationTtl: 60 }),

キャッシュとの競合

ここまで、KV をキャッシュのように使う方法を書いてきたが、そもそも Cloudflare には KV とは別にキャッシュ機能が備わっている。KV をキャッシュとして使おうとするのなら、本来のキャッシュとの競合や二重管理について考えないといけない。

例えば、先程データの有効期限を60秒に設定した。このような設定をした場合、60秒間は同じデータを使い続け、それ以降はオリジンサーバに問い合わせを行うことが期待されていると思う。
しかし、60秒経過して KV からデータが削除されたとしても、その時点でエッジサーバにキャッシュが残っていれば、それが使われてしまう。オリジンサーバへの問い合わせは行われない。
具体的には以下のような処理になる。

  1. KV からデータを取り出そうとしたが、既に削除されており見つけられなかった
  2. fetch(url)でリクエストを発行する
  3. オリジンサーバにリクエストを送る前に、エッジサーバにキャッシュが残っていないか確認する
  4. キャッシュが残っていたため、それがfetch(url)の結果として返され、オリジンサーバへのリクエストは行われない

このような事態を避けるためには、エッジサーバにキャッシュさせないようにしたり、Cache API を使ってキャッシュを削除したりする必要がある。

Cache API による削除について検証してみたが、動作が不安定というか、意図通りに消されないことも多かったので、そもそもキャッシュさせないようにするのが確実だと思う。
ページルールで「キャッシュ レベル」を「スキップ」にしたり、Cache-Controlprivateを指定したりすることで、エッジサーバへのキャッシュを防ぐことができる。

参考資料