简介
公共支持目录会反复收到针对相同语言和类别的请求。重复使用响应可以减少重复工作,但缓存绝不能混入客户专属数据,也不能把错误转换为缓存的公共内容。你将先观察未缓存的生成过程,然后添加明确的缓存策略,测试过期和定向失效,最后部署并验证缓存边界。
本独立实验从 /home/labex/project/public-cache 开始,环境包含 Node.js 22.22.0、项目本地的 Wrangler 4.131.1、用于隔离评估的 Miniflare 4.20260730.0,以及一个合成响应 fixture。使用你自己的学习账号,并沿用之前学习过的授权、部署和密钥操作流程。不需要之前创建的 VM、资源、已购买的域名、数据库或付费升级。请求会计入账号的正常使用量。
保持一个终端处于打开状态。所有目录数据和凭据都是合成数据。Cache API 中的内容只存在于某个服务位置;全球网络并不等于全球复制的缓存。完成实验后,删除 Worker,移除本地密钥并退出登录。
观察新生成的公共目录响应
在此步骤中,你将检查提供的合成目录,并确认它未使用缓存时的行为。该 fixture 会为每个响应生成新的 UUID,因此无需通过猜测时间或使用数据库,就能观察响应是否被重复使用。
cd /home/labex/project/public-cache
node --version
npx wrangler --version
cat src/catalog.js
预期输出为 Node.js v22.22.0 和 Wrangler 4.131.1。安装过程已经安装了准确的项目依赖;如果要根据锁定文件复现现有安装,请使用 npm ci。评估运行时也使用 Miniflare 4.20260730.0,并与兼容性日期匹配。该 fixture 会根据语言、类别和合成客户产生不同结果;设置 X-Demo-Failure: 1 可以模拟 503。这些只是测试输入,不是真实的身份凭据。
WORKER_NAME="labex-cache-$(openssl rand -hex 6)"
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false
}
CONFIG
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
return deliver(catalog(request, language, category), 'BYPASS');
}
};
JS
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
等待服务准备就绪后再发送请求。如果启动仍在进行,请再次运行 cat dev.log。保持此终端打开,以便继续使用其中的 shell 变量。
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
两个请求都应返回 200,且 audience 为 public,但 generation UUID 不同。X-Lab-Cache: BYPASS 表示此处理程序没有查询或写入缓存。面向客户端的 Cache-Control: no-store 可避免浏览器或客户端缓存影响实验。在替换基线行为之前,先完成验证。
只缓存符合条件的公共响应
在此步骤中,你将添加 Cache API 的查询和存储逻辑。先运行 jobs 查看当前的开发任务;以下示例假设任务编号为 1,然后停止该任务。
jobs
kill %1
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
缓存键使用当前来源,以及固定的路由、类别和语言。参数顺序经过规范化,而两个内容维度仍然彼此区分。未知参数和重复的维度会被拒绝,不会静默改变缓存键的含义。
在查询缓存之前先判断请求是否符合缓存条件。带有 Authorization、Cookie 或合成客户请求头的请求会绕过已有的公共缓存项。失败 fixture 也会绕过缓存查询,因此错误不会被缓存的成功响应隐藏。只有状态为 200 且不包含 Set-Cookie 的响应才会被存储。由于响应体是流,所以需要先克隆响应;存储的副本使用 10 秒 TTL,并等待写入完成。返回给客户端的响应仍然使用 no-store;内部 Cache API 项目有自己的缓存策略。
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
等待服务准备就绪后再发送请求。如果启动仍在进行,请再次运行 cat dev.log。保持此终端打开,以便继续使用其中的 shell 变量。
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?category=network&lang=en"
curl -i "http://127.0.0.1:8080/api/catalog?lang=fr&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=printer"
在 10 秒内执行前两个请求。第一次未命中缓存的响应应显示 MISS;重复请求应显示 HIT,并保留相同的 generation。调换查询参数顺序不会改变缓存键。法语和 printer 变体应使用请求中的维度,并拥有彼此独立的缓存项。如果阅读过程中 TTL 已经过期,请立即重新执行一对请求;不要假设缓存内容会永久存在。
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Cookie: demo=synthetic"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
带有客户或身份信息的请求应返回 BYPASS,并显示对应的合成 audience,绝不会返回其他客户的结果。即使公共数据已经在缓存中,模拟错误也应返回 503 BYPASS。之后的公共请求仍应返回公共数据,而不是错误。验证时保持本地服务器运行。这些操作还会在隔离的本地运行时中执行处理程序;不会修改云端缓存。
让本地缓存项过期并使其失效
在此步骤中,你将为相同的规范化缓存键添加一个经过身份验证的失效操作。这是删除某个本地数据中心中的缓存项,不是全球清除。编辑文件前,先停止实际运行的开发任务。
jobs
kill %1
umask 077
PURGE_TOKEN=$(openssl rand -hex 24)
printf 'PURGE_TOKEN=%s\n' "$PURGE_TOKEN" > .dev.vars
cat .gitignore
不要将合成密钥放入 Git、公共配置、URL 或日志中。它只用于保护本实验的 DELETE 操作,不是 Cloudflare API 令牌。
cat > src/index.js <<'JS'
import {catalog} from './catalog.js';
function deliver(response, cacheStatus) {
const headers = new Headers(response.headers);
headers.set('X-Lab-Cache', cacheStatus);
// This lab caches inside the Worker, not in the caller's browser.
headers.set('Cache-Control', 'no-store');
return new Response(response.body, {status: response.status, headers});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/health' && request.method === 'GET') {
return Response.json({status: 'ok'}, {headers: {'Cache-Control': 'no-store'}});
}
if (url.pathname !== '/api/catalog') {
return Response.json({error: 'not_found'}, {status: 404});
}
const language = url.searchParams.get('lang') || 'en';
const category = url.searchParams.get('category') || 'network';
if (!['en', 'fr'].includes(language) || !['network', 'printer'].includes(category) ||
[...url.searchParams.keys()].some(key => !['lang', 'category'].includes(key)) ||
url.searchParams.getAll('lang').length > 1 || url.searchParams.getAll('category').length > 1) {
return Response.json({error: 'invalid_query'}, {status: 400, headers: {'Cache-Control': 'no-store'}});
}
const keyUrl = new URL('/api/catalog', url.origin);
keyUrl.searchParams.set('category', category);
keyUrl.searchParams.set('lang', language);
const key = new Request(keyUrl, {method: 'GET'});
const cache = caches.default;
if (request.method === 'DELETE') {
if (!env.PURGE_TOKEN) return Response.json({error: 'purge_unconfigured'}, {status: 503});
if (request.headers.get('Authorization') !== `Bearer ${env.PURGE_TOKEN}`) {
return Response.json({error: 'unauthorized'}, {status: 401, headers: {'Cache-Control': 'no-store'}});
}
const invalidated = await cache.delete(key);
return Response.json({invalidated, scope: 'this-location'}, {
headers: {'Cache-Control': 'no-store', 'X-Lab-Cache': 'BYPASS'}
});
}
if (request.method !== 'GET') {
return Response.json({error: 'method_not_allowed'}, {status: 405, headers: {Allow: 'GET, DELETE'}});
}
// Decide eligibility before lookup: a warm public entry must not mask private work or errors.
const bypass = ['Authorization', 'Cookie', 'X-Demo-Customer', 'X-Demo-Failure']
.some(name => request.headers.has(name));
if (bypass) return deliver(catalog(request, language, category), 'BYPASS');
const cached = await cache.match(key);
if (cached) return deliver(cached, 'HIT');
const response = catalog(request, language, category);
if (response.status !== 200 || response.headers.has('Set-Cookie')) {
return deliver(response, 'BYPASS');
}
const stored = response.clone();
stored.headers.set('Cache-Control', 'public, max-age=10');
// Await completion here so the next request can observe the write.
await cache.put(key, stored);
return deliver(response, 'MISS');
}
};
JS
DELETE 会先验证凭据,然后使用与查询和存储相同的 GET 缓存键调用 cache.delete。返回的布尔值表示此位置是否存在对应缓存项。未授权的删除操作必须保持缓存项不变。发送到其他位置的请求仍可能遇到其他位置自己的缓存项。
npx wrangler dev --ip 0.0.0.0 --port 8080 > dev.log 2>&1 &
cat dev.log
等待服务准备就绪后再发送请求。如果启动仍在进行,请再次运行 cat dev.log。保持此终端打开,以便继续使用其中的 shell 变量。
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
curl -i -X DELETE "http://127.0.0.1:8080/api/catalog?lang=en&category=network" -H "Authorization: Bearer $PURGE_TOKEN"
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
未授权的 DELETE 应返回 401。有效的 DELETE 应返回 scope: this-location;如果缓存项仍未过期,通常还会返回 invalidated: true。如果较短的 TTL 已经过期,返回 false 也有意义。下一次 GET 应返回 MISS,并生成新的 generation。要演示 true,请在执行已授权的 DELETE 前立即执行一次 GET。
sleep 11
curl -i "http://127.0.0.1:8080/api/catalog?lang=en&category=network"
等待 11 秒后,新出现的 MISS 表明缓存无需显式删除也会过期。验证时保持服务器运行:隔离的运行时会检查缓存复用、维度分离、私有请求和错误请求排除、被拒绝的删除、成功的定向删除、其他缓存键的保留以及过期行为。这些受控的本地断言可以提供可重复的证据,而不依赖全球缓存状态。
Cache API 文档介绍了它的数据中心作用范围、响应头行为以及 cache.delete。Cache API 与跳过 Worker 执行的平台缓存是两种独立机制。
部署并检查缓存边界
在此步骤中,你将把完成的处理程序部署到学习账号。先停止实际运行的本地任务,然后为这台全新的 VM 授权,并检查账号身份。
jobs
kill %1
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_tail:read
在已登录的浏览器中完成终端显示的设备链接和代码,检查权限与 Background Access 未发生变化,然后选择你的学习账号。等待终端显示成功。
npx wrangler whoami --json
确认显示的是目标账号名称。将下面的 YOUR_ACCOUNT_ID 替换为实际账号 ID,同时保留你的唯一名称。
cat > wrangler.jsonc <<CONFIG
{
"name": "$WORKER_NAME",
"main": "src/index.js",
"compatibility_date": "2026-07-30",
"workers_dev": true,
"preview_urls": false,
"account_id": "YOUR_ACCOUNT_ID"
}
CONFIG
npx wrangler deploy
npx wrangler secret bulk .dev.vars
npx wrangler secret list
部署不会上传本地密钥文件;显式执行 bulk 命令后,才会将 PURGE_TOKEN 创建为 secret_text。部署后等待一小段传播时间。复制 Wrangler 在下方显示的实际公共 URL。
APP_URL="https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev"
curl -i "$APP_URL/api/catalog?lang=en&category=network"
curl -i "$APP_URL/api/catalog?category=network&lang=en"
curl -i "$APP_URL/api/catalog?lang=fr&category=network"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: alice"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Customer: bob"
curl -i "$APP_URL/api/catalog?lang=en&category=network" -H "X-Demo-Failure: 1"
公共响应必须包含请求的语言和类别,并且 audience 必须为 public。同一位置的请求在 TTL 内重复访问时可能显示 HIT 并保留相同的 generation;换到其他位置或缓存过期后显示 MISS 都是合理现象。不要根据两个请求断言缓存内容在全球范围内共享。私有请求必须始终绕过缓存,失败请求必须返回 503 BYPASS。
在同一个 Dashboard 账号中,打开 Compute → Workers & Pages,确认准确的 Worker 及其 workers.dev URL。使用验证步骤检查所有权、已部署的密钥绑定以及响应边界。该操作不会执行云端失效。失效行为已在本地测试;cache.delete 不是全球清除机制。如果部署仍在传播,请稍等片刻后重新检查响应;如果结果持续不匹配,应调查原因,不要直接接受该结果。
删除临时 Worker
在此步骤中,保持授权状态并移除本实验的部署。确认唯一名称和账号,然后只删除此 Worker。
cat wrangler.jsonc
npx wrangler delete
在名称匹配提示中按一次 y 键。删除后,Wrangler 4.131.1 可能会报告已知的旧版 Workers Sites KV 身份验证诊断信息。不要因此扩大权限,也不要把该错误当作删除成功的证据。刷新 Dashboard 并进行验证:经过身份验证的资源清单中必须不再显示这个准确名称的 Worker。保留学习账号及其子域名。删除 Worker 不代表所有缓存项都已在全球范围内清除;合成缓存项的 TTL 为 10 秒,并且不应再有正在运行的应用程序。
移除本地密钥并断开连接
在确认删除完成后,移除本地的临时凭据,然后断开此 VM。
rm .dev.vars
unset PURGE_TOKEN
npx wrangler logout
npx wrangler whoami --json
必须明确看到 loggedIn: false;未认证的结构化命令可能会以非零状态退出。完成验证后结束 VM。浏览器登录状态可以保留。退出登录或结束 VM 不会代替你删除云端部署。
总结
你已经将未缓存的目录生成过程替换为明确的公共响应缓存,同时保留语言和类别之间的缓存键分离,并在查询缓存之前绕过私有请求和失败请求。你在受控的本地运行时中测试了短时缓存项和经过身份验证的失效操作,然后验证了已部署应用的身份与响应边界,而没有假设缓存内容在全球范围内共享。
存储副本的 TTL 与调用方的缓存策略用途不同。明确的缓存资格判断、完整的缓存键和可观察的响应 generation,使这种区别可以被测试。断开 VM 前,你已经删除了临时部署和本地凭据。

