跳到主要内容

《林一的 Cloudflare 通关记》第 7 篇-用户的文件往哪放——R2 对象存储

· 阅读需 21 分钟

《林一的 Cloudflare 通关记》第 7 篇

周一早上,林一刚坐下,就收到小王的消息:

"林一,用户反馈说生成的图片刷新页面就没了,能不能保存下来?还有用户想下载之前生成的图,也找不到入口。"

故事引入

林一拍了拍脑袋。上周做完 Workers AI 图片生成功能后,他光顾着高兴了,完全没考虑存储问题。现在图片生成后直接以二进制流返回给前端,用户一刷新,图就没了。

"得存起来,"林一自言自语,"用户生成图片 → 存到存储服务 → 生成访问 URL → 前端展示。标准的对象存储流程。"

他打开某云厂商的对象存储购买页,扫了一眼价格表:

  • 存储费:0.12 元/GB/月
  • 请求费:0.01 元/万次
  • 出口流量费:0.5 元/GB

出口流量费。又是流量费。

林一默算了一下:假设每月存储 50GB 图片,用户读取 100GB,存储费 6 元,流量费 50 元。流量费是存储费的 8 倍多。这跟之前 CDN 那篇遇到的"带宽刺客"如出一辙——存储本身不贵,但数据一旦被读取,流量费就蹭蹭往上涨。

"张哥,"林一转头看向老张,"存图片是不是又得花一笔流量费?跟之前 CDN 一样,存着不贵,读起来贵?"

老张正在泡茶,头也没抬:"你还没被流量费坑够?Cloudflare 有个服务叫 R2 对象存储,零出口流量费。"

"零?"

"零。存进去的数据,读出来不收流量费。一分钱都不收。"

林一沉默了两秒,然后把某云的购买页关了。


技术讲解

对象存储到底是什么?

"在讲 R2 之前,先搞清楚一个基本概念——什么是对象存储。"老张在白板上写了三种存储模式:

存储三大类:

1. 文件存储(File Storage)
├── 代表:NFS、SMB、本地文件系统
├── 特点:有目录层级结构(/photos/cat.png)
└── 场景:共享文件系统、局域网文件服务器

2. 块存储(Block Storage)
├── 代表:云硬盘、EBS、本地磁盘
├── 特点:裸设备,按块读写,没有文件系统
└── 场景:数据库、虚拟机系统盘

3. 对象存储(Object Storage)
├── 代表:AWS S3、Cloudflare R2、阿里云 OSS
├── 特点:扁平结构,Key-Value 模型,HTTP API 访问
└── 场景:图片/视频/文档存储、网站静态资源、数据备份

三种存储方式对比:文件存储=文件柜,块存储=空地,对象存储=仓库+快递

"三种存储的区别,打个比方:"

  • 文件存储就像一个文件柜——有抽屉、有文件夹、有标签,你能按目录一层层翻着找。适合多人共享文件,但规模上不去,一个文件柜能放多少文件?
  • 块存储就像一块空地——没有结构,你想怎么画线就怎么画线。数据库喜欢这种裸设备,因为可以自己管理存储结构。但你得自己建房子(装文件系统),自己维护。
  • 对象存储就像一个超大仓库+快递服务——你把东西交给仓库(写入),拿到一个取件码(Key)。要取的时候,报取件码(HTTP GET),仓库把东西送到你手上。没有目录层级,就是一堆取件码和对应的物品。

"Web 应用为什么用对象存储?"老张问。

林一想了想:"因为图片、视频这些文件,不需要复杂的目录结构,存进去、读出来就行。而且对象存储通过 HTTP API 访问,天然适合 Web 场景。"

"没错。还有两点:对象存储容量几乎无限,你不用担心'磁盘满了';而且它是扁平结构,不像文件系统层级深了性能会下降。一个 bucket 里放几千万个文件,毫无压力。"

"国内常用的对象存储有阿里云 OSS、腾讯云 COS,它们的 API 跟 AWS S3 基本兼容。这就要说到下一个重点了。"

S3 兼容 API:为什么它是"事实标准"

"你有没有发现一个现象,"老张说,"不管是阿里云 OSS、腾讯云 COS、还是 Cloudflare R2,都声称自己'兼容 S3 API'。为什么?"

"因为 AWS S3 是最早的?"

"不光是最早,而是它成了事实标准(De facto standard)。"

老张解释道:AWS 在 2006 年推出了 S3(Simple Storage Service),是第一个大规模商用的对象存储服务。S3 定义了一套 HTTP API——怎么上传对象、怎么下载、怎么列出文件、怎么删除。这套 API 设计得简洁实用,很快成了行业标配。

后来的存储服务商面临一个选择:自己设计一套 API,还是兼容 S3?

"自己设计 API 意味着用户要学新东西、改代码。兼容 S3 意味着用户现有的代码、工具、SDK 几乎不用改就能迁移过来。"老张说,"答案显而易见——大家都选了兼容 S3。"

"所以'兼容 S3'到底意味着什么?"

意味着三件事:

  1. API 接口一致:S3 定义的操作(PutObjectGetObjectListObjectsDeleteObject 等),在 R2 上也能用,参数格式一样。
  2. SDK 通用:你可以直接用 AWS 的官方 SDK(@aws-sdk/client-s3boto3 等)操作 R2,不用装专用 SDK。
  3. 工具生态共享aws-clirclones3cmd 这些工具,改个 endpoint 就能操作 R2。

"打个比方,S3 API 就像 USB 接口。当初 USB 只是一种接口标准,但因为用的人太多,所有外设都兼容它。你现在买个鼠标、键盘、U盘,不用管是什么品牌,插上就能用。S3 API 就是存储界的 USB。"

S3 API 就像存储界的 USB:不同厂商的设备插上即用

"Cloudflare R2 实现了 S3 API 的核心功能,"老张补充道,"端点地址是 https://<你的Account ID>.r2.cloudflarestorage.com,区域填 auto。用 AWS SDK 连接时,只需要把 endpoint 和 credentials 换成 R2 的就行。"

// 用 AWS SDK v3 连接 R2
import { S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
region: "auto",
endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
credentials: {
accessKeyId: "<R2_ACCESS_KEY_ID>",
secretAccessKey: "<R2_SECRET_ACCESS_KEY>",
},
});

"不过 R2 也有不支持的 S3 功能,比如版本控制(Versioning)、存储桶策略(Bucket Policy)、对象标签(Object Tagging)等。核心的读写、列表、删除、分片上传都是完整支持的。日常使用完全够了。"

Cloudflare R2 的核心卖点:零出口流量费

"现在说 R2 最重要的特点——也是让你关掉某云购买页的原因。"老张在白板上写下加粗大字:

零出口流量费(Zero Egress)

"这是什么意思?在传统云存储中,数据写入不贵,但数据读取(即'出口')要收流量费。"

老张画了一张对比图:

传统云存储费用结构:

写入数据 ──→ 存储 ──→ 读取数据 ──→ 用户
| |
存储费(便宜) 出口流量费(贵!)
¥0.12/GB/月 ¥0.5/GB

Cloudflare R2 费用结构:

写入数据 ──→ 存储 ──→ 读取数据 ──→ 用户
| |
存储费(便宜) 出口流量费
$0.015/GB/月 $0 ← 免费!

零出口流量费对比:传统云存储的出口流量费贵,R2 读取免费

"来算笔实际的账。假设星火AI 每月产生以下用量:"

项目用量传统云存储费用R2 费用
存储50 GB50 × ¥0.12 = ¥6/月50 × $0.015 = $0.75/月
写入(Class A)10 万次¥0.110万/100万 × $4.5 = $0.45
读取(Class B)100 万次¥1100万/100万 × $0.36 = $0.36
出口流量200 GB200 × ¥0.5 = ¥100$0
合计≈ ¥107/月≈ $1.56/月(约 ¥11)

"看到没?传统云存储的费用里,出口流量费占了 90% 以上。而 R2 直接把这个最大的成本项归零了。"

"为什么 R2 能做到零出口流量费?"林一问。

"因为 Cloudflare 自己就是 CDN 厂商。"老张说,"传统云厂商的网络架构是:数据存在中心机房 → 用户请求时从中心机房传出 → 经过公网到达用户。这个'传出公网'的过程,云厂商要向运营商付带宽费,所以收你出口流量费。"

"而 Cloudflare 有全球 300+ 城市的边缘节点,数据存在 R2 里,用户请求时由 Cloudflare 自己的 CDN 网络分发。整个链路都在 Cloudflare 自己的网络内,没有第三方带宽成本,所以能做到零出口流量费。"

"就像快递,"老张比喻道,"传统云存储要用第三方物流送货,物流费你出。而 Cloudflare 有自己的快递网络,送货不收额外费用。"

R2 vs AWS S3:全面对比

"既然 R2 兼容 S3 API,那它跟'正版' AWS S3 相比到底怎么样?"小王凑过来问。

老张整理了一张详细对比表:

对比项AWS S3Cloudflare R2
存储费$0.023/GB/月(Standard)$0.015/GB/月(Standard)
出口流量费$0.09/GB$0(免费)
Class A 操作(写、列表等)$4.50/百万次$4.50/百万次
Class B 操作(读、头部等)$0.40/百万次$0.36/百万次
免费额度5GB 存储 + 2万次 GET/月10GB 存储 + 100万次 Class A + 1000万次 Class B/月
低频访问存储$0.0125/GB/月$0.01/GB/月
S3 API 兼容原生兼容(核心功能)
版本控制支持不支持
存储桶策略支持不支持
对象标签支持不支持
多区域复制支持不支持(但支持 Location Hints)
与 Workers 集成需通过 S3 SDK原生 Binding,零延迟
数据迁移工具Super Slurper(免费迁移)

"核心差异就两点:R2 的出口流量费为零,S3 要收 $0.09/GBR2 免费额度更大。其他功能上 S3 更全,但对大多数 Web 应用来说,R2 的功能已经够用了。"

"再补充一点,"老张说,"R2 有个 Infrequent Access(低频访问)存储类别,适合不常访问的数据。存储费只要 $0.01/GB/月,但有数据检索费 $0.01/GB。适合备份文件、历史归档这类场景。"

R2 的免费额度:够用吗?

"对一个实习项目来说,免费额度是最关心的。"老张列出 R2 的免费额度:

项目每月免费额度说明
存储10 GB-month标准存储,低频访问不享受免费
Class A 操作100 万次写入、列表、分片上传等
Class B 操作1000 万次读取、头部查询等
出口流量无限永远免费
删除操作无限删除不收费

R2 免费额度:10GB 存储、100万 Class A、1000万 Class B、出口无限免费

"来算笔账。一张 AI 生成的图片大约 200KB,10GB 能存多少张?"

10 GB = 10,240 MB = 10,485,760 KB
10,485,760 / 0.2 ≈ 52,000 张图片

"5 万多张图片,免费存着。每次读取不花钱——出口流量费为零。Class B 操作(读取)每月 1000 万次免费,每天可以读 33 万次。对一个实习项目来说,完全够用了。"

"超出免费额度后呢?"

"存储 $0.015/GB/月,比 AWS S3 的 $0.023 便宜 35%。而且只收存储费和操作费,出口流量永远免费。"


实操指导

第一步:创建 R2 Bucket

"理论够了,开始动手。"老张打开 Cloudflare Dashboard。

  1. 登录 Cloudflare Dashboard,左侧菜单选择 Storage & Databases > R2 > Overview
  2. 如果是第一次使用,按提示完成 R2 订阅激活(需要绑定支付方式,但免费额度内不扣费)
  3. 点击 Create bucket(创建存储桶)
  4. 填写 Bucket 名称:spark-images(名称全网唯一,只能用小写字母、数字和连字符)
  5. 选择默认存储类别:Standard(标准存储)
  6. 可选:设置 Location Hint,提示数据存储的主要区域(如 APAC 表示亚太地区)
  7. 点击 Create 完成创建

"创建完成后,Bucket 默认是私有的——外部无法直接访问。后面我们会配置公开访问和预签名 URL。"

第二步:在 Workers 中绑定 R2

"跟 Workers AI 一样,R2 也是通过 Binding 的方式接入 Workers。"老张修改 wrangler.jsonc

{
"name": "spark-api",
"main": "src/index.js",
"compatibility_date": "2025-01-01",
"ai": {
"binding": "AI"
},
"r2_buckets": [
{
"binding": "IMAGES_BUCKET",
"bucket_name": "spark-images"
}
]
}

"加了最后那个 r2_buckets 配置。binding 是代码里访问它的变量名,bucket_name 是刚才创建的 Bucket 名称。配置好后,代码里就能通过 env.IMAGES_BUCKET 操作 R2 了。"

第三步:在 Workers 中读写 R2

"现在改造图片生成接口——生成图片后存到 R2,返回访问 URL。"老张修改 src/index.js

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};

export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;

if (method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}

// 生成图片并存储到 R2
if (path === "/api/image/generate" && method === "POST") {
try {
const { prompt } = await request.json();

// 1. 调用 Workers AI 生成图片
const imageResponse = await env.AI.run(
"@cf/black-forest-labs/flux-1-schnell",
{ prompt }
);

// 2. 生成唯一文件名
const imageId = crypto.randomUUID();
const key = `generated/${imageId}.png`;

// 3. 存储到 R2
await env.IMAGES_BUCKET.put(key, imageResponse, {
httpMetadata: {
contentType: "image/png",
},
customMetadata: {
prompt: prompt,
createdAt: new Date().toISOString(),
},
});

// 4. 返回文件路径和访问 URL
return new Response(
JSON.stringify({
success: true,
key: key,
url: `/api/image/${imageId}`,
prompt: prompt,
}),
{
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
}
);
} catch (error) {
return new Response(
JSON.stringify({ error: "图片生成失败", detail: error.message }),
{
status: 500,
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
}
}

// 从 R2 读取图片
if (path.startsWith("/api/image/") && method === "GET") {
const imageId = path.split("/").pop();
const key = `generated/${imageId}.png`;

// 从 R2 获取对象
const object = await env.IMAGES_BUCKET.get(key);

if (object === null) {
return new Response(
JSON.stringify({ error: "图片不存在" }),
{
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
}

// 返回图片数据
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("Cache-Control", "public, max-age=31536000");
headers.set("Access-Control-Allow-Origin", "*");

return new Response(object.body, { headers });
}

// 列出所有图片
if (path === "/api/images" && method === "GET") {
const listed = await env.IMAGES_BUCKET.list({
prefix: "generated/",
limit: 100,
include: ["customMetadata"],
});

const images = listed.objects.map((obj) => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded,
prompt: obj.customMetadata?.prompt || "",
url: `/api/image/${obj.key.replace("generated/", "").replace(".png", "")}`,
}));

return new Response(
JSON.stringify({ images, count: images.length }),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
}

// 删除图片
if (path.startsWith("/api/image/") && method === "DELETE") {
const imageId = path.split("/").pop();
const key = `generated/${imageId}.png`;

await env.IMAGES_BUCKET.delete(key);

return new Response(
JSON.stringify({ success: true, message: "图片已删除" }),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
}

return new Response(
JSON.stringify({ error: "Not Found" }),
{
status: 404,
headers: { "Content-Type": "application/json", ...corsHeaders },
}
);
},
};

老张逐段讲解:

"写入操作env.IMAGES_BUCKET.put(key, value, options)key 是文件在 R2 中的路径,value 可以是 ReadableStreamArrayBufferstringBlob。这里直接把 AI 返回的图片流传进去。httpMetadata 设置 Content-Type,customMetadata 可以存自定义键值对——这里存了用户输入的 prompt,方便后面查询。"

"读取操作env.IMAGES_BUCKET.get(key)。返回一个 R2ObjectBody 对象,它的 body 属性是 ReadableStream,可以直接作为 Response 的 body 返回。如果 key 不存在,返回 null。"

"列表操作env.IMAGES_BUCKET.list(options)prefix 按前缀过滤,limit 限制返回数量(最多 1000),include 指定是否包含元数据。返回的对象按 key 字典序排列。"

"删除操作env.IMAGES_BUCKET.delete(key)。也支持传数组批量删除,单次最多删 1000 个。"

"有个重要细节,"老张强调,"写入和删除是强一致性的——put() 的 Promise resolve 后,所有后续读取都能看到这个对象;delete() 的 Promise resolve 后,所有后续读取都不会再看到它。不需要担心最终一致性的延迟问题。"

第四步:公开访问 vs 预签名 URL

"上面的方案是通过 Worker 中转来读取图片——用户请求 /api/image/xxx,Worker 从 R2 取数据再返回。这种方式灵活,但每次请求都要经过 Worker 计算。"老张说。

"有没有办法让用户直接访问 R2 里的文件,不经过 Worker?"

"有,两种方式。"

方式一:公开 Bucket(Public Bucket)

"适用于所有用户都能看到的公开文件——比如网站 logo、公开图片。开启后,R2 里的文件可以通过 URL 直接访问。"

配置方法:

  1. r2.dev 开发地址(仅用于开发):

    • 进入 Bucket 的 Settings 页面
    • 找到 Public Development URL,点击 Enable
    • 确认后获得一个 https://pub-xxxxx.r2.dev 格式的 URL
    • 通过 https://pub-xxxxx.r2.dev/generated/imageId.png 直接访问文件
    • 注意:有速率限制,不适合生产环境
  2. 自定义域名(推荐生产使用):

    • 进入 Bucket 的 Settings 页面
    • 找到 Custom Domains,点击 Add
    • 输入域名(如 img.spark-ai.com,该域名需已托管在 Cloudflare)
    • Cloudflare 自动添加 CNAME 记录并完成配置
    • 通过 https://img.spark-ai.com/generated/imageId.png 访问
    • 自定义域名还支持 Cloudflare Cache、WAF、Bot Management 等功能

"公开 Bucket 适合'所有人都能看'的文件。但如果有些文件只允许特定用户访问呢?比如用户生成的图片,不应该被别人随便看到。"

方式二:预签名 URL(Presigned URL)

"预签名 URL 是 S3 的一个概念——生成一个带签名的临时 URL,持有这个 URL 的人可以在有效期内执行特定操作(读取或上传),过期后 URL 失效。"

"打个比方:公开 Bucket 就像公园——谁都能进。预签名 URL 就像酒店房卡——只有拿到卡的人能进,而且卡有有效期,到期自动失效。"

预签名 URL 的特点:

  • 客户端生成:不需要跟 R2 通信,本地用 API 密钥计算签名即可
  • 有效期可设:最短 1 秒,最长 7 天(604800 秒)
  • 操作限定:每个 URL 只能执行指定的操作(GET/PUT/HEAD/DELETE)
  • 支持自定义 Content-Type 限制:比如只允许上传 PNG 图片

"生成预签名 URL 需要用到 R2 的 S3 兼容 API 和 AWS SDK:"

import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

// 创建 R2 的 S3 客户端
const s3 = new S3Client({
region: "auto",
endpoint: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com",
credentials: {
accessKeyId: "<R2_ACCESS_KEY_ID>",
secretAccessKey: "<R2_SECRET_ACCESS_KEY>",
},
});

// 生成下载用的预签名 URL(有效期 1 小时)
const downloadUrl = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket: "spark-images",
Key: "generated/imageId.png",
}),
{ expiresIn: 3600 } // 3600 秒 = 1 小时
);
// → https://spark-images.<ACCOUNT_ID>.r2.cloudflarestorage.com/generated/imageId.png?X-Amz-Algorithm=...

// 生成上传用的预签名 URL(有效期 1 小时,限制只能传 PNG)
const uploadUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: "spark-images",
Key: "user-uploads/photo.png",
ContentType: "image/png",
}),
{ expiresIn: 3600 }
);

"拿到预签名 URL 后,不需要任何额外的认证头,直接用 HTTP 请求就能操作:"

# 下载
curl "https://spark-images.xxx.r2.cloudflarestorage.com/generated/imageId.png?X-Amz-Algorithm=..."

# 上传
curl -X PUT "https://spark-images.xxx.r2.cloudflarestorage.com/user-uploads/photo.png?X-Amz-Algorithm=..." \
--data-binary @photo.png

"两种方式怎么选?"

场景推荐方式理由
网站公开资源(logo、图标)公开 Bucket + 自定义域名CDN 缓存加速,访问最快
用户生成的公开内容公开 Bucket + 自定义域名通过 Key 的随机性保护隐私
用户私有文件预签名 URL时效性保证,过期自动失效
允许用户直接上传预签名 URL(PUT)不经过 Worker,省计算资源
需要 WAF/访问控制公开 Bucket + 自定义域名r2.dev 不支持 WAF

公开 Bucket vs 预签名 URL:公开资源用 Bucket,私密文件用预签名 URL

"对星火AI 来说,"老张建议,"图片生成后存到 R2,用自定义域名做公开访问。每张图的 Key 用 UUID,别人猜不到 URL,相当于'隐式私有'。如果将来要做用户系统,再切换成预签名 URL 做精细权限控制。"

第五步:部署与测试

# 本地开发测试
npx wrangler dev

# 测试图片生成(会自动存储到 R2)
curl -X POST http://localhost:8787/api/image/generate \
-H "Content-Type: application/json" \
-d '{"prompt": "a cat sitting on a cloud, watercolor"}'

# 返回示例:{"success":true,"key":"generated/abc-123.png","url":"/api/image/abc-123"}

# 测试读取图片
curl http://localhost:8787/api/image/abc-123 --output cat.png

# 测试列出图片
curl http://localhost:8787/api/images

# 测试删除图片
curl -X DELETE http://localhost:8787/api/image/abc-123

# 部署上线
npx wrangler deploy

"有个注意事项,"老张补充,"wrangler dev 本地开发时,R2 操作使用的是本地的模拟存储,数据不会写到线上 R2。只有 wrangler deploy 部署后,线上请求才会真正操作 R2。这跟 Workers AI 不一样——AI 本地开发也消耗线上额度,但 R2 本地开发是模拟的。"


小结预告

本篇知识点回顾

知识点核心内容
对象存储概念扁平 Key-Value 模型,HTTP API 访问,容量无限,适合 Web 场景
三种存储对比文件存储(目录层级)、块存储(裸设备)、对象存储(Key-Value + HTTP)
S3 兼容 APIAWS S3 是对象存储的事实标准,R2/OSS/COS 等均兼容 S3 API
R2 核心卖点零出口流量费——数据读取不收流量费,传统云存储流量费占总成本 90%+
R2 定价存储 $0.015/GB/月,Class A $4.50/百万次,Class B $0.36/百万次
免费额度10GB 存储 + 100万次 Class A + 1000万次 Class B/月,出口流量无限免费
R2 Bindingwrangler.jsonc 中配置 r2_buckets,代码中通过 env.BUCKET_NAME 访问
写入对象env.BUCKET.put(key, value, {httpMetadata, customMetadata})
读取对象env.BUCKET.get(key) 返回 R2ObjectBodybody 为 ReadableStream
列出对象env.BUCKET.list({prefix, limit, cursor, include}),最多 1000 条
删除对象env.BUCKET.delete(key),支持批量删除(最多 1000 个)
强一致性写入和删除操作 resolve 后,所有后续读取立即可见
公开 Bucket通过自定义域名或 r2.dev 开发地址直接访问,支持 CDN 缓存
预签名 URL临时授权 URL,有效期 1 秒~7 天,支持 GET/PUT/HEAD/DELETE 操作
R2 vs S3R2 存储更便宜、出口流量免费、免费额度更大;S3 功能更全(版本控制等)

动手挑战

  1. 基础挑战:创建一个 R2 Bucket,在 Workers 中绑定它,实现 PUT /api/file 上传文件和 GET /api/file/:key 下载文件的基本功能
  2. 进阶挑战:改造图片生成接口,AI 生成的图片存入 R2,实现 /api/images 列表接口返回所有图片,前端展示图片画廊
  3. 折腾挑战:配置 R2 自定义域名(或 r2.dev 开发地址),实现通过公开 URL 直接访问图片。再尝试用 AWS SDK 生成预签名 URL,实现 5 分钟有效期的临时下载链接

提示:R2 的 API Token 需要在 Dashboard 的 R2 > Manage R2 API Tokens 页面创建。生成预签名 URL 需要用 Access Key ID 和 Secret Access Key,跟 Workers Binding 是两套独立的认证方式——Binding 用于 Worker 内部访问,API Token 用于 S3 兼容 API。

下回预告

星火AI 的图片存储问题终于解决了。用户生成的图片存到 R2,通过自定义域名访问,零出口流量费,免费额度绰绰有余。

小王体验了一把完整的流程——生成图片 → 自动保存 → 刷新页面图片还在 → 点击下载,满意地点了点头。

"不过有个问题,"小王说,"用户每次打开聊天页面,都要重新加载所有历史对话。AI 回复的内容能不能也存下来?还有,用户的偏好设置、API 调用频率限制,这些数据怎么存?"

林一想了想:"这些数据量不大,但读取频繁。用 R2 存不太合适——R2 是存文件的,存键值对太浪费了。用数据库?又太重了。"

老张端着保温杯走过来:"你需要一个'备忘录'——Cloudflare KV,键值存储。读得快,存得简单,适合配置项、会话数据、缓存这些场景。"

"键值存储?像 Redis 那样?"

"类似,但用法更简单。而且——"老张笑了笑,"又有免费额度。"

下篇预告:《文件存好了,但每次请求都重新计算——KV 键值存储》

wp