介绍
文档查看器通常只需要接下来的几个字节,或者只需要确认缓存的副本仍然是最新版本。每次请求都下载整个文件会浪费资源。你将为一个受保护的 Worker 添加 HTTP 验证器和单字节范围下载功能。该 Worker 使用私有 R2 存储作为后端。
请先完成「通过 Worker 流式传输文档」。本实验从一台全新的 VM 开始,其中已安装 Node.js 22.22.0、Wrangler 4.131.1 和提供的令牌检查模块;你需要创建一个新存储桶并部署一个新的 Worker。你的 R2 订阅和学习账户权限必须已经准备好。有关操作和存储费用,请查看 R2 定价。不需要自定义域名。本实验只存储合成文本;离开前请完成清理。
连接应用存储桶
在此步骤中,你将授权这台 VM,并为应用创建一个独立的私有存储桶。设备授权用于确认你的学习账户。R2 存储桶管理使用另一个仅限该账户的 API 令牌。
启动 Bash,以便使用下面的命令语法,然后进入准备好的项目并检查工具。保持这个终端打开,以便资源名称变量继续有效:
bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version
在你自己的浏览器中授权显示的设备代码。授予同意前,确认学习账户以及请求的账户和用户读取权限:
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json
确认输出中的 loggedIn: true。即使列表中只有一个账户,也要读取账户名称。将下面的 YOUR_ACCOUNT_ID 替换为该账户实际的 32 个字符 ID。openssl rand -hex 6 会生成 12 个随机十六进制字符,因此本实验不会与之前的运行产生名称冲突。下面的 here-document 会写入标准配置文件;Shell 会将变量值替换到文件中。
ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r03-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","main":"src/index.js","workers_dev":true,"compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON
管理存储桶时,打开 Cloudflare 配置文件中的 API Tokens 页面,并创建一个以本实验命名的自定义令牌。授予 Account → Workers R2 Storage → Edit 权限,并将 Account Resources 限制为你保存了 ID 的学习账户。设置较短的过期时间。不要包含其他账户或无关权限。此管理令牌用于管理存储桶,包括创建和删除。在本实验中,Worker 通过 DOCUMENTS 绑定访问 R2 对象。
将令牌复制一次到这个隐藏的 VM 提示符中。umask 077 会将文件权限限制为当前用户;read -s 会隐藏输入内容。该文件使用 Wrangler 的标准令牌变量,并且会被 Git 排除。
umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN
仅在 R2 管理命令中使用 --env-file=.env.management;普通的 whoami 命令仍会检查 VM 的设备授权。
将 --env-file 放在每条 Wrangler 命令的末尾,避免它的文件参数列表把命令名称也当作参数。创建每个存储桶后,如果 Wrangler 询问是否向配置添加绑定,请输入 n 并按 Enter。配置中已经包含所需的绑定。
npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management
列出你的存储桶,并找到刚刚生成的确切名称。其他存储桶属于其他任务,请不要修改。
npx wrangler r2 bucket list --env-file=.env.management
在 Dashboard 中打开 Storage & databases → R2 → Overview,选择这个确切的存储桶,并检查其中为空的对象列表。在存储桶设置中,保持公共开发 URL 和自定义域名处于禁用状态。Dashboard 中显示的存储桶名称可以确认资源身份;后续的下载检查会验证其中存储的字节。
Worker 脚本权限用于支持部署。KV 权限用于支持 Wrangler 的删除记录;本实验不会创建 KV 命名空间。R2 管理令牌仍然是一个独立的、限定账户范围的凭据。
实现条件读取和部分读取
在此步骤中,你将使用 R2 元数据来决定是否需要返回正文。ETag 类似于文件版本标签。客户端已有文件副本时,会在 If-None-Match 中发送该标签,以询问文件是否发生变化。如果标签匹配,服务器会返回不带正文的 304 Not Modified,从而避免再次下载相同的字节。Range 请求允许查看器获取大文件的一部分,或继续下载中断的文件。它请求一段包含起止位置的字节范围,并返回 206 Partial Content,同时通过 Content-Range 标头描述这段数据。
使用下面的处理程序。head() 只读取元数据,不读取字节。后续的 get() 包含 onlyIf.etagMatches,因此如果对象在两次调用之间发生变化,就不会根据过期的元数据返回对象。此端点支持单个范围和基于 ETag 的 If-Range;不支持的多范围语法会返回 400。当 If-Range ETag 不匹配时,会返回完整的 200 响应,让客户端替换旧副本。
cat > src/index.js <<'JS'
import { authorized } from "./auth.js";
export default {
async fetch(request, env) {
const path = new URL(request.url).pathname;
if (path === "/health") return new Response("ok");
if (!await authorized(request, env)) return new Response("Unauthorized", { status: 401 });
if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
if (path !== "/documents/report.txt") return new Response("Not found", { status: 404 });
const key = path.slice(1);
const metadata = await env.DOCUMENTS.head(key);
if (!metadata) return new Response("Not found", { status: 404 });
const headers = new Headers({ "ETag": metadata.httpEtag,
"Last-Modified": metadata.uploaded.toUTCString(), "Accept-Ranges": "bytes",
"Cache-Control": "private, no-store" });
metadata.writeHttpMetadata(headers);
// GET validators use weak comparison: W/"value" and "value" can match.
const noneMatch = request.headers.get("If-None-Match");
if (noneMatch && noneMatch.split(",").some(tag => tag.trim() === "*" || tag.trim().replace(/^W\//, "") === metadata.httpEtag))
return new Response(null, { status: 304, headers });
const since = Date.parse(request.headers.get("If-Modified-Since") || "");
const uploadedSeconds = Math.floor(metadata.uploaded.getTime() / 1000) * 1000;
if (!noneMatch && Number.isFinite(since) && uploadedSeconds <= since)
return new Response(null, { status: 304, headers });
let range = request.headers.get("Range");
const ifRange = request.headers.get("If-Range");
if (ifRange && ifRange !== metadata.httpEtag) range = null;
let start = 0, end = metadata.size - 1;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
// This endpoint supports exactly one range, not multipart ranges.
if (!match || (!match[1] && !match[2]))
return new Response("Invalid range", { status: 400 });
if (!match[1]) { start = Math.max(0, metadata.size - Number(match[2])); }
else { start = Number(match[1]); if (match[2]) end = Math.min(Number(match[2]), end); }
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= metadata.size) {
headers.set("Content-Range", `bytes */${metadata.size}`);
return new Response("Range not satisfiable", { status: 416, headers });
}
headers.set("Content-Range", `bytes ${start}-${end}/${metadata.size}`);
}
// Do not mix a HEAD result with bytes from an object replaced in between.
const object = await env.DOCUMENTS.get(key, { onlyIf: { etagMatches: metadata.etag },
...(range ? { range: { offset: start, length: end - start + 1 } } : {}) });
if (!object) return new Response("Not found", { status: 404 });
if (!("body" in object)) return new Response("Object changed; retry", { status: 412 });
headers.set("Content-Length", String(range ? end - start + 1 : metadata.size));
return new Response(object.body, { status: range ? 206 : 200, headers });
}
};
JS
请求的起始位置从 0 开始计数。例如,bytes=-3 表示最后 3 个字节。超出对象范围的起始位置会返回 416,并包含 Content-Range: bytes */SIZE。条件验证优先于范围选择。如果同时存在两者,If-None-Match 优先于日期验证。
创建本地应用密钥并检查打包结果:
umask 077
printf "ACCESS_TOKEN=%s\n" "$(openssl rand -hex 24)" > .dev.vars
npx wrangler deploy --dry-run
比较完整正文和范围正文
在此步骤中,你只向本地存储写入数据,并检查真实的 HTTP 标头。本地对象与之后的远程对象相互独立,尽管两者使用相同的键。
npx wrangler r2 object put "$BUCKET/documents/report.txt" --local --file document.txt --content-type text/plain
npx wrangler dev --ip 127.0.0.1 --port 8787 > dev.log 2>&1 &
DEV_PID=$!
等待 dev.log 中出现就绪消息,然后加载合成的应用密钥:
cat dev.log
set -a
source .dev.vars
set +a
分别保存完整响应的标头和正文。-D 会将标头写入文件:
curl -fsS -D full.headers -H "Authorization: Bearer $ACCESS_TOKEN" http://127.0.0.1:8787/documents/report.txt -o full.txt
cmp document.txt full.txt
cat full.headers
确认响应为 200,内容类型是存储的内容类型,ETag 带有引号,并且包含 Accept-Ranges: bytes。复制确切的 ETag,包括双引号,并将它放入下面单引号中的 ETAG:
ETAG='"COPY_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" http://127.0.0.1:8787/documents/report.txt
确认响应为 304 且没有正文。使用最新的验证器可以避免完整传输,但不会让存储桶变为公共存储桶。
curl -sS -D range.headers -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" http://127.0.0.1:8787/documents/report.txt -o range.txt
head -c 5 document.txt > expected-range.txt
cmp expected-range.txt range.txt
cat range.headers
确认响应为 206,包含 Content-Range: bytes 0-4/SIZE,并且恰好有 5 个匹配的字节。现在请求一个无法满足的起始位置:
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" http://127.0.0.1:8787/documents/report.txt
确认响应为 416,包含 bytes */SIZE 标头和 Range not satisfiable。平台检查会独立重复这些读取操作。
验证远程条件交付
在此步骤中,你将独立创建远程测试对象并发布处理程序。先停止本地服务器,然后使用明确的 --remote 标志上传相同的测试文件:
kill "$DEV_PID"
wait "$DEV_PID" 2>/dev/null || true
npx wrangler r2 object put "$BUCKET/documents/report.txt" --remote --file document.txt --content-type text/plain --env-file=.env.management
npx wrangler deploy
npx wrangler secret bulk .dev.vars
将部署后的 URL 复制到 BASE_URL。等待健康检查返回 ok;如果新部署仍在传播,最多重试 1 分钟的读取请求。
BASE_URL=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
curl -i "$BASE_URL/health"
curl -fsS -D remote.headers -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL/documents/report.txt" -o remote.txt
cmp document.txt remote.txt
cat remote.headers
使用 remote.headers 中的远程 ETag,不要使用记忆中的本地值。重复执行条件请求和部分读取请求:
ETAG='"COPY_REMOTE_ETAG_HERE"'
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "If-None-Match: $ETAG" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=0-4" "$BASE_URL/documents/report.txt"
curl -i -H "Authorization: Bearer $ACCESS_TOKEN" -H "Range: bytes=99999-" "$BASE_URL/documents/report.txt"
确认响应分别为:不带正文的 304、包含测试文件前 5 个字节的 206,以及带有正确大小边界的 416。在 Dashboard 中检查确切的 Worker 绑定和存储桶对象。保持存储桶的公共 URL 和自定义域名处于禁用状态;HTTP 标头和正文比较结果是范围请求的权威证据。

此示例显示 DOCUMENTS 连接到对应的私有桶。你生成的名称后缀会不同。

对象行显示 report.txt 的类型为 text/plain、存储类别为 Standard、大小为 41 B,同时 Public Access 保持 Disabled。生成的名称和日期仅为示例。汇总的 Bucket Size 可能因延迟仍显示 0 B;对象行和字节比较证明文件存在。HTTP 响应头和响应体比较用于确认条件请求与范围请求的行为。
删除远程应用和存储桶
在此步骤中,你将在仍处于授权状态时,只删除本实验的 Worker 和对象。删除 Worker 后,私有存储桶不会自动消失。
npx wrangler delete
确认确切生成的 Worker 名称。明确删除已上传的对象,然后删除存储桶:
BUCKET=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).r2_buckets[0].bucket_name")
npx wrangler r2 object delete "$BUCKET/documents/report.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management
远程只创建了 documents/report.txt。如果存在其他对象,请检查这个确切的存储桶并确认对象归属,然后再删除。
刷新 Dashboard 中的 Worker 和存储桶列表,并运行平台清理检查。身份验证或网络失败只能说明结果不确定,不能视为删除成功。
撤销剩余凭据
在此步骤中,你将在个人的 API Tokens 页面撤销本实验的管理令牌,删除本地应用密钥,并关闭 VM 授权。只有在前一步的清理检查通过后,才能执行这些操作。
rm .env.management .dev.vars
unset ACCESS_TOKEN
npx wrangler logout
npx wrangler whoami --json || true
确认输出中的 loggedIn: false。撤销管理令牌是 Dashboard 中需要手动完成的独立检查点;仅删除本地文件不会撤销令牌。保留普通的 Dashboard 登录状态和其他实验的令牌不变。
总结
使用 R2 元数据实现条件响应,流式传输单字节范围,处理无法满足的请求,并清理私有下载服务。



