AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 为什么需要对象存储

前面我们学了两种存数据的方式:KV 存键值对,D1 存结构化的表格数据。但有一类东西它们都不擅长——文件

用户上传的头像、文章里的配图、生成的 PDF 报告、录制的音频……这些都是文件。文件和数据库里的数据有本质区别:

  • 体积大:一张图片几百 KB 到几 MB,一个视频可能几百 MB。数据库存这些东西效率很低
  • 不需要查询内部内容:你不会"查出所有宽度大于 1920px 的图片",你只会"按文件名取出这张图"
  • 需要直接下载:浏览器要直接拿到图片的二进制数据来渲染,不是拿一段 JSON

所以需要一种专门存文件的方案,这就是对象存储(Object Storage)

2. 什么是对象存储

对象存储的模型很简单:每个文件就是一个"对象",每个对象有一个唯一的 key(相当于文件路径),存储的内容就是文件本身的二进制数据。

你可以把它想象成一个巨大的网盘,但没有真正的文件夹层级。虽然 key 可以写成 uploads/2024/avatar.png 这种带斜杠的路径,看起来像文件夹结构,但底层其实是扁平的——uploads/2024/avatar.png 就是一整个 key 字符串,不存在叫 uploads 的文件夹。

和我们前面学过的存储做个对比:

KVD1对象存储
存什么小段文本/JSON结构化数据(表格)文件(图片、视频、文档等)
怎么取按 key 取 valueSQL 查询,支持筛选、关联按 key 取文件
单条大小最大 25MB行数据通常很小单文件最大几 GB
典型用途配置、缓存、Session用户信息、订单、文章头像、附件、静态资源

对象存储领域最出名的是 AWS 的 S3(Simple Storage Service),它基本定义了整个行业的 API 标准。后来的云厂商做对象存储,大多兼容 S3 的接口,这样迁移成本低。

3. Cloudflare R2

R2 是 Cloudflare 提供的对象存储服务,兼容 S3 的 API。和 S3 最大的区别:R2 没有出口流量费

S3 每 GB 出口流量收 0.09 美元,如果你的应用有大量图片访问或文件下载,流量费很容易比存储费还贵。R2 直接免了这笔钱,只收存储费(0.015 美元/GB/月)。

免费额度对个人项目也很友好:每月 10GB 存储、100 万次写入操作、1000 万次读取操作。

4. 创建 R2 Bucket

用 wrangler 命令行创建:

terminal
1
wrangler r2 bucket create my-bucket

创建成功后,在 wrangler.jsonc 里绑定到 Worker:

wrangler.jsonc
1
{
2
"r2_buckets": [
3
{
4
"binding": "BUCKET",
5
"bucket_name": "my-bucket"
6
}
7
]
8
}

binding 是你在代码里访问这个 bucket 的变量名,bucket_name 是实际的 bucket 名称。

然后给 Hono 加上类型声明:

index.ts
1
import { Hono } from 'hono'
2
3
type Bindings = {
4
BUCKET: R2Bucket
5
}
6
7
const app = new Hono<{ Bindings: Bindings }>()

这样 c.env.BUCKET 就有完整的类型提示了。

5. 核心操作

R2Bucket 提供四个核心方法,覆盖了对象存储的增删查:

index.ts
01
import { Hono } from 'hono'
02
03
type Bindings = {
04
BUCKET: R2Bucket
05
}
06
07
const app = new Hono<{ Bindings: Bindings }>()
08
09
// 上传文件
10
app.put('/objects/:key', async (c) => {
11
const key = c.req.param('key')
12
const body = await c.req.arrayBuffer()
13
14
await c.env.BUCKET.put(key, body, {
15
httpMetadata: {
16
contentType: c.req.header('Content-Type') || 'application/octet-stream',
17
},
18
})
19
20
return c.json({ key, message: 'Uploaded' })
21
})
22
23
// 获取文件
24
app.get('/objects/:key', async (c) => {
25
const key = c.req.param('key')
26
const object = await c.env.BUCKET.get(key)
27
28
if (!object) {
29
return c.json({ error: 'Not found' }, 404)
30
}
31
32
c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')
33
return c.body(object.body)
34
})
35
36
// 删除文件
37
app.delete('/objects/:key', async (c) => {
38
const key = c.req.param('key')
39
await c.env.BUCKET.delete(key)
40
return c.json({ message: 'Deleted' })
41
})
42
43
// 列出文件
44
app.get('/objects', async (c) => {
45
const prefix = c.req.query('prefix') || ''
46
const limit = Number(c.req.query('limit')) || 20
47
const cursor = c.req.query('cursor')
48
49
const listed = await c.env.BUCKET.list({
50
prefix,
51
limit,
52
cursor: cursor || undefined,
53
})
54
55
return c.json({
56
objects: listed.objects.map((obj) => ({
57
key: obj.key,
58
size: obj.size,
59
uploaded: obj.uploaded,
60
})),
61
truncated: listed.truncated,
62
cursor: listed.truncated ? listed.cursor : undefined,
63
})
64
})
65
66
export default app

几个要点:

  • put(key, body, options) 的 body 可以是 ArrayBufferReadableStreamstring
  • get(key) 返回 R2ObjectBody | null,注意判空
  • list() 支持分页,truncatedtrue 时用 cursor 获取下一页

6. 文件上传 API

实际项目中,前端一般用 multipart/form-data 上传文件。来写一个完整的上传接口:

index.ts
01
import { Hono } from 'hono'
02
03
type Bindings = {
04
BUCKET: R2Bucket
05
}
06
07
const app = new Hono<{ Bindings: Bindings }>()
08
09
app.post('/upload', async (c) => {
10
const formData = await c.req.formData()
11
const file = formData.get('file') as File
12
13
if (!file) {
14
return c.json({ error: 'No file provided' }, 400)
15
}
16
17
// 用时间戳 + 原始文件名作为 key,避免重名覆盖
18
const key = `uploads/${Date.now()}-${file.name}`
19
20
await c.env.BUCKET.put(key, file.stream(), {
21
httpMetadata: {
22
contentType: file.type,
23
},
24
})
25
26
return c.json({ key, size: file.size })
27
})
28
29
export default app

前端调用:

client.ts
01
const formData = new FormData()
02
formData.append('file', fileInput.files[0])
03
04
const res = await fetch('https://your-worker.dev/upload', {
05
method: 'POST',
06
body: formData,
07
})
08
09
const { key } = await res.json()
10
console.log('文件已上传,key:', key)

注意 file.stream() 是流式传输,不会把整个文件加载到内存,适合大文件。

7. 文件下载与图片服务

上传了文件,还需要一个下载/访问接口。可以做一个简单的图片服务:

index.ts
01
import { Hono } from 'hono'
02
03
type Bindings = {
04
BUCKET: R2Bucket
05
}
06
07
const app = new Hono<{ Bindings: Bindings }>()
08
09
// 通过路径访问文件,如 /files/uploads/1234-avatar.png
10
app.get('/files/*', async (c) => {
11
const key = c.req.path.replace('/files/', '')
12
const object = await c.env.BUCKET.get(key)
13
14
if (!object) {
15
return c.notFound()
16
}
17
18
// 设置响应头
19
c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')
20
c.header('Content-Length', String(object.size))
21
c.header('ETag', object.httpEtag)
22
23
// 缓存 1 小时
24
c.header('Cache-Control', 'public, max-age=3600')
25
26
return c.body(object.body)
27
})
28
29
export default app

加上 Cache-Control 后,图片会被 Cloudflare CDN 缓存,后续请求不用再读 R2,响应更快。

8. 预签名 URL

有时候你不想让文件流量经过 Worker,想让客户端直接从 R2 读写。这时候可以用预签名 URL。

R2 兼容 S3 的预签名 URL 机制,需要用 @aws-sdk/s3-request-presigner

index.ts
01
import { Hono } from 'hono'
02
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'
03
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
04
05
type Bindings = {
06
R2_ACCOUNT_ID: string
07
R2_ACCESS_KEY_ID: string
08
R2_SECRET_ACCESS_KEY: string
09
}
10
11
const app = new Hono<{ Bindings: Bindings }>()
12
13
const getS3Client = (env: Bindings) => {
14
return new S3Client({
15
region: 'auto',
16
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
17
credentials: {
18
accessKeyId: env.R2_ACCESS_KEY_ID,
19
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
20
},
21
})
22
}
23
24
// 生成上传用的预签名 URL
25
app.post('/presign/upload', async (c) => {
26
const { filename, contentType } = await c.req.json()
27
const key = `uploads/${Date.now()}-${filename}`
28
29
const client = getS3Client(c.env)
30
const command = new PutObjectCommand({
31
Bucket: 'my-bucket',
32
Key: key,
33
ContentType: contentType,
34
})
35
36
const url = await getSignedUrl(client, command, { expiresIn: 3600 })
37
return c.json({ url, key })
38
})
39
40
// 生成下载用的预签名 URL
41
app.post('/presign/download', async (c) => {
42
const { key } = await c.req.json()
43
44
const client = getS3Client(c.env)
45
const command = new GetObjectCommand({
46
Bucket: 'my-bucket',
47
Key: key,
48
})
49
50
const url = await getSignedUrl(client, command, { expiresIn: 3600 })
51
return c.json({ url })
52
})
53
54
export default app

前端拿到预签名 URL 后,直接用 fetch PUT/GET 就行,不经过 Worker,减少延迟和带宽消耗。

9. R2 的限制

用之前知道几个限制:

  • 单次 PUT 最大 5GB,超过需要用 multipart upload(分片上传),最大支持 5TB
  • 免费额度:10GB 存储、100 万次写入、1000 万次读取/月
  • key 长度:最大 1024 字节
  • metadata 大小:自定义 metadata 最大 2KB
  • 每个账号:最多 1000 个 bucket

对于大多数中小项目来说,免费额度完全够用。

10. 实用场景

几个典型用途:

index.ts
01
import { Hono } from 'hono'
02
03
type Bindings = {
04
BUCKET: R2Bucket
05
}
06
07
const app = new Hono<{ Bindings: Bindings }>()
08
09
// 用户头像上传
10
app.post('/api/avatar', async (c) => {
11
const formData = await c.req.formData()
12
const file = formData.get('avatar') as File
13
14
if (!file.type.startsWith('image/')) {
15
return c.json({ error: 'Only images allowed' }, 400)
16
}
17
18
if (file.size > 2 * 1024 * 1024) {
19
return c.json({ error: 'File too large, max 2MB' }, 400)
20
}
21
22
const key = `avatars/${crypto.randomUUID()}.${file.name.split('.').pop()}`
23
await c.env.BUCKET.put(key, file.stream(), {
24
httpMetadata: { contentType: file.type },
25
})
26
27
return c.json({ avatarUrl: `/files/${key}` })
28
})
29
30
// AI 生成图片存储
31
app.post('/api/ai-images', async (c) => {
32
const { imageBuffer, prompt } = await c.req.json()
33
const key = `ai-generated/${Date.now()}.png`
34
35
await c.env.BUCKET.put(key, Uint8Array.from(atob(imageBuffer), (c) => c.charCodeAt(0)), {
36
httpMetadata: { contentType: 'image/png' },
37
customMetadata: { prompt },
38
})
39
40
return c.json({ key })
41
})
42
43
// 文档附件
44
app.post('/api/attachments', async (c) => {
45
const formData = await c.req.formData()
46
const file = formData.get('file') as File
47
48
const allowedTypes = [
49
'application/pdf',
50
'application/msword',
51
'text/plain',
52
]
53
54
if (!allowedTypes.includes(file.type)) {
55
return c.json({ error: 'File type not allowed' }, 400)
56
}
57
58
const key = `attachments/${Date.now()}-${file.name}`
59
await c.env.BUCKET.put(key, file.stream(), {
60
httpMetadata: { contentType: file.type },
61
})
62
63
return c.json({ key, filename: file.name })
64
})
65
66
export default app

头像上传加了类型和大小校验,AI 图片用了 customMetadata 存储生成参数,文档附件限制了允许的文件类型。根据业务需求灵活调整就好。

11. 总结

R2 是 Cloudflare Workers 生态里做文件存储的首选方案。S3 兼容的 API 意味着迁移成本低,零出口流量费是最大卖点。通过 c.env.BUCKET 就能直接操作,putgetdeletelist 四个方法覆盖了绝大多数场景。

下一篇看 Hono RPC 客户端——怎么让前端调用后端 API 像调用本地函数一样有类型提示。