Deno Deploy で Web Cache API がベータサポートされたので、試す

2024年8月、Deno Deploy が Web Cache API をベータサポートしました。

Introducing Web Cache API support on Deno Deploy

これまで、「cliでは使える」という状況でした。
Deno Deploy でも使えるようになります。

確認がてら、micro CMS との連携を試みてみます。

参考

できなかったこと

実装の中でできなかったことがあります。
できなかったことを踏まえて実装しているので、先にその点を共有します。

Deno の Web Cache API には、実装がないものがある

Deno の Web Cache API に実装されているAPIは、以下の3種です。

  • put
  • match
  • delete

参照先: dosc.deno.com - Deno APIs > Web > Cache

対して、mdnを参照すると、以下のAPIも存在します。

  • add
  • addAll
  • keys
  • matchAll

参照先: mdn- Web API Cache

特に、keysが無いことで、一括でキャッシュを削除することが少し難しく感じられたので、キャッシュ自体を切り替えることで対応します。

また、Deno の Web Cache API は、キャッシュ保持期間の設定が効いていないようです。

この点については、Introducing Web Cache API support on Deno Deploy と記述とも違うので、Issueを立てました。

Web cache API expirenation is not work?

改善に期待です。

実装

Fresh をべ―スに実装します。
先に、挙動の内容を共有します。

sequenceDiagram
participant b as ブラウザ
participant d as Fresh(Deno Deploy)
participant m as micro CMS
    b ->> d: アクセス

    alt is キャッシュがある
        d ->> d: キャッシュからレスポンスを取得
        d -->> b: レスポンスを返す
    else is キャッシュがない
        d ->> m: コンテンツを取得
        m -->> d: コンテンツを返す
        d ->> d: キャッシュに保存
        d -->> b: レスポンスを返す
    end

環境変数参照

環境変数の参照は、Deno.env.getを使いますが、型の補完が効きにくいのもあるので、1枚かませます。
これ自体は、趣味の範囲と考えます。

utils/consts.ts
1
2
3
4
5
6
7
8
9
10
export const CONSTS = {
microCms: {
serviceDomain: Deno.env.get("MICRO_CMS_SERVICE_DOMAIN")!,
apiKey: Deno.env.get("MICRO_CMS_API_KEY")!,
contentsExpiresIn: Number(
Deno.env.get("MICRO_CMS_CONTENTS_CACHE_EXPIRES_IN")!,
),
},
} as const;

キャッシュ切り替えのための、KV でデータ保持

先の通り、Web Cache API の有効期限の動作がうまく動いていないようなので、cache 自体の切り替えするキーをKV で保持します。

utils/kvStorage.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/// <reference lib="deno.unstable" />

import { CONSTS } from "./consts.ts";

const WEB_CACHE_VERSION = "web-cache-version" as const;

async function getKvStorage() {
return await Deno.openKv();
}

export async function getCacheVersion() {
const kvStorage = await getKvStorage();
const version = await kvStorage.get<string>([WEB_CACHE_VERSION]);

if (!version.value) {
console.log(`${WEB_CACHE_VERSION} not found`);
const newVersion = crypto.randomUUID();
await setCacheVersion(newVersion);
return newVersion;
}
console.log(`${WEB_CACHE_VERSION} found: ${version.value}`);

return version.value;
}

export async function setCacheVersion(version: string) {
const kvStorage = await getKvStorage();
return await kvStorage.set([WEB_CACHE_VERSION], version, {
expireIn: CONSTS.microCms.contentsExpiresIn * 1000,
});
}

micro CMS からのデータ取得

micro CMS からのデータ取得の処理も集約します。
すべてのコンテンツ取得をDeno Deploy で行い、キャッシュをキャッシュする必要があります。
そのため、文字列中にあるhttps://images.microcms-assets.io/は、/api/resource/にあるものとして扱うため、変換します。

utils/microCms.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { createClient, MicroCMSListResponse } from "microcms-js-sdk";
import { CONSTS } from "./consts.ts";
import { convert } from "html-to-text";

export type News = {
id: string;
title: string;
content: string;
publishedAt: string;
updatedAt: string;
createdAt: string;
revisedAt: string;
category: {
id: string;
name: string;
createdAt: string;
updatedAt: string;
publishedAt: string;
revisedAt: string;
};
};

export function getMicroCmsClient() {
return createClient({
serviceDomain: CONSTS.microCms.serviceDomain,
apiKey: CONSTS.microCms.apiKey,
});
}

export async function getNewsList(
page: number,
): Promise<
(MicroCMSListResponse<{ contents: News[] }> & { status: true }) | {
contents: [];
status: false;
}
> {
const client = getMicroCmsClient();
try {
const res = await client.getList<{ contents: News[] }>({
endpoint: "news",
queries: {
limit: 2,
offset: page <= 0 ? 0 : (page - 1) * 2,
},
});

return {
status: true,
...res,
};
} catch (e) {
console.error(e);
return {
status: false,
contents: [],
};
}
}

export async function getNews(id: string) {
const client = getMicroCmsClient();
try {
const res = await client.get<News>({ endpoint: "news", contentId: id });
return {
status: true,
contents: res,
};
} catch (e) {
console.error(e);
return {
status: false,
contents: {},
};
}
}

// html に含まれる microを取得対象にされている cms のリソースのドメインを変換する
export function resourceDomainConvert(src: string) {
const regex = new RegExp(`https://images.microcms-assets.io/`, "g");
return src.replace(regex, "/api/resource/");
}

export function resourceDomainConvertBack(src: string) {
const regex = new RegExp(`/api/resource/`, "g");
return src.replace(regex, "https://images.microcms-assets.io/");
}

export function contentDigest(src: string) {
const text = convert(src);
return text.slice(0, 50);
}

Web Cache API の操作本体

先に作ったキーをもとに、キャッシュを取得します。

utils/cache.ts
1
2
3
4
5
import { getCacheVersion } from "./kvStorage.ts";

export async function getWebCache() {
return await caches.open(await getCacheVersion());
}

こちらを使った。/ のハンドラは以下のようになります。
後の確認のために、console.timeを使って処理時間を計測を入れておきます。

routes/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { FreshContext, PageProps } from "$fresh/server.ts";
import { Pagination } from "../components/Pagination.tsx";
import { CONSTS } from "../utils/consts.ts";
import { contentDigest, getNewsList, News } from "../utils/microcms.ts";
import { getWebCache } from "../utils/webCache.ts";

interface Data {
newsList: News[];
currentPage: number;
totalCount: number;
}

export const handler = {
GET: async function (req: Request, ctx: FreshContext) {
console.time("GET /");

const cache = await getWebCache();
const cached = await cache.match(req.url);
if (cached) {
console.log(`cache hit ${req.url}`);
console.timeEnd("GET /");
return cached;
}
console.log(`cache miss ${req.url}`);

const page = Number(ctx.url.searchParams.get("page")) || 1;

const newsListRes = await getNewsList(page);
if (!newsListRes.status) {
return ctx.renderNotFound({});
}

const res = await ctx.render({
newsList: newsListRes.contents,
currentPage: page,
totalCount: newsListRes.totalCount,
});

res.headers.set(
"Expires",
new Date(Date.now() + CONSTS.microCms.contentsExpiresIn * 1000)
.toUTCString(),
);

await cache.put(req.url, res.clone());
console.timeEnd("GET /");

return res;
},
};

export default function Home(props: PageProps<Data>) {
return (
<div class="px-8 py-8 mx-auto">
<h1 class="text-2xl font-bold">News</h1>
<div class="px-4">
{props.data.newsList.map((news) => {
return (
<div class="mb-2">
<a href={`/news/${news.id}`}>
<div class="card bg-base-100 w-full shadow-xl">
<div class="card-body">
<h2 class="card-title">{news.title}</h2>
<p class="ml-5">{contentDigest(news.content)}...</p>
</div>
</div>
</a>
</div>
);
})}
</div>
<div class="flex justify-center">
<Pagination
baseUrl={"/"}
currentPage={props.data.currentPage}
totalCount={props.data.totalCount}
>
</Pagination>
</div>
</div>
);
}

同様に、/news/:id のハンドラも作成します。

routes/news/[id].tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { FreshContext, PageProps } from "$fresh/server.ts";
import { getNews, News, resourceDomainConvert } from "../../utils/microcms.ts";
import { getWebCache } from "../../utils/webCache.ts";
import { CONSTS } from "../../utils/consts.ts";

interface Data {
news: News;
}

export const handler = {
GET: async function (req: Request, ctx: FreshContext) {
const cache = await getWebCache();

const cached = await cache.match(req.url);
if (cached) {
console.log(`cache hit ${req.url}`);
return cached;
}
console.log(`cache miss ${req.url}`);

const newsRes = await getNews(ctx.params.id);

if (!newsRes.status) {
return ctx.renderNotFound({});
}

const res = await ctx.render({
news: newsRes.contents,
});

res.headers.set("Expires", new Date(Date.now() + CONSTS.microCms.contentsExpiresIn * 1000).toUTCString());

await cache.put(req.url, res.clone());

return res;
},
};

export default function Home(props: PageProps<Data>) {
return (
<div class="px-4 py-8 mx-auto">
<h1 class="text-2xl font-bold"></h1>
<div class="">
</div>
<div class="card bg-base-100 w-full shadow-xl">
<div class="card-body">
<h2 class="card-title">{props.data.news.title}</h2>
<div
dangerouslySetInnerHTML={{
__html: resourceDomainConvert(props.data.news.content),
}}
/>
</div>
</div>
</div>
);
}

また、ブログコンテンツ中に含まれるhttps://images.microcms-assets.io//api/resource/に変換していました。
これに対応するハンドラを用意します。

routes\api\resource\[...path].ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { FreshContext } from "$fresh/server.ts";
import { getWebCache } from "../../../utils/webCache.ts";
import { resourceDomainConvertBack } from "../../../utils/microcms.ts";
import { CONSTS } from "../../../utils/consts.ts";

export const handler = {
GET: async function (req: Request, _ctx: FreshContext) {
const cache = await getWebCache();
const cached = await cache.match(req.url);
if (cached) {
console.log(`cache hit ${req.url}`);
return cached;
}
console.log(`cache miss ${req.url}`);

// 本来のURLに戻して、リソースを取得
const res = await fetch(
resourceDomainConvertBack(new URL(req.url).pathname),
);

const blob = await res.blob();

const newResponse = new Response(blob, {
headers: {
...res.headers,
"Expires": new Date(Date.now() + CONSTS.microCms.contentsExpiresIn * 1000).toUTCString(),
},
});

await cache.put(req.url, newResponse.clone());

return newResponse;
},
};

動作確認

以上実装で、基本的なところが完了しています。
その他CSSの調整をしていますが、ここでは割愛します。

以下の様に表示されます。

このとき、Deno Deploy のログを確認すると、以下のようになります。

1回目は、キャッシュがないので、cache miss ~となり、micro CMS からデータを取得します。
2回目以降は、キャッシュがあるので、cache hit ~となり、キャッシュからレスポンスを返します。

GET / のリクエストには、処理時間の計測を入れていました。
キャッシュがない場合は、cache miss ~の後に、GET /: 626msとなっています。
キャッシュがある場合は、cache hit ~の後に、GET /: 246msとなりました。

処理時間を半分程度まで高速化できたようです。
何度か試していくと、キャッシュが取れていても、300ms台後半から、200ms台前半まで結構揺れるようです。

ちなみにこちら、ローカルでは、300ms近くから10ms 以下になっていて、Deno Deploy で実行した結果よりも良いものでした。

詳細を開いた際の動作は以下のようになります。
それぞれキャッシュがヒットしていることを確認できます。

追加実装

micro CMS には Webhook 機能があるので、それに合わせて、キャッシュを削除する機能を追加します。

まず、webhook を設定。

以下実装です。
主に、Signatureの検証が必要です。
Node.js 向けの実装は掲載されているので、参考に書き換えています。

routes/api/update.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { FreshContext } from "$fresh/server.ts";
import { updateWebCache } from "../../utils/webCache.ts";
import { CONSTS } from "../../utils/consts.ts";
import { timingSafeEqual } from "@std/crypto";

export const handler = {
POST: async function (req: Request, _ctx: FreshContext) {
const encoder = new TextEncoder();
const data = encoder.encode(await req.text());
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(CONSTS.microCms.webHookSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);

const expectedSignatureArrayBuffer = await crypto.subtle.sign(
"HMAC",
key,
data,
);
const expectedSignature = Array.from(
new Uint8Array(expectedSignatureArrayBuffer),
)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");

const signature = req.headers.get("X-MICROCMS-Signature");
if (
!timingSafeEqual(
new TextEncoder().encode(signature!),
new TextEncoder().encode(expectedSignature),
)
) {
throw new Error("Invalid signature.");
}

console.log("Webhook received. updateWebCache");
await updateWebCache();

return new Response("OK");
},
};

改めて展開しコンテンツを書き換えると、Webhook received. updateWebCache の出力後にcache miss ~ が続きます。

コンテンツの変更に伴うキャッシュの更新ができていることが確認できました。


というわけで、Deno Deploy で Web Cache API を使ってみました。
現状ベータサポートということで、いささか工夫は必要ですが、十分実用に耐えるように感じられました。

では。