1. 为什么需要对象存储
前面我们学了两种存数据的方式:KV 存键值对,D1 存结构化的表格数据。但有一类东西它们都不擅长——文件。
用户上传的头像、文章里的配图、生成的 PDF 报告、录制的音频……这些都是文件。文件和数据库里的数据有本质区别:
- 体积大:一张图片几百 KB 到几 MB,一个视频可能几百 MB。数据库存这些东西效率很低
- 不需要查询内部内容:你不会"查出所有宽度大于 1920px 的图片",你只会"按文件名取出这张图"
- 需要直接下载:浏览器要直接拿到图片的二进制数据来渲染,不是拿一段 JSON
所以需要一种专门存文件的方案,这就是对象存储(Object Storage)。
2. 什么是对象存储
对象存储的模型很简单:每个文件就是一个"对象",每个对象有一个唯一的 key(相当于文件路径),存储的内容就是文件本身的二进制数据。
你可以把它想象成一个巨大的网盘,但没有真正的文件夹层级。虽然 key 可以写成 uploads/2024/avatar.png 这种带斜杠的路径,看起来像文件夹结构,但底层其实是扁平的——uploads/2024/avatar.png 就是一整个 key 字符串,不存在叫 uploads 的文件夹。
和我们前面学过的存储做个对比:
| KV | D1 | 对象存储 | |
|---|---|---|---|
| 存什么 | 小段文本/JSON | 结构化数据(表格) | 文件(图片、视频、文档等) |
| 怎么取 | 按 key 取 value | SQL 查询,支持筛选、关联 | 按 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 命令行创建:
1wrangler r2 bucket create my-bucket
创建成功后,在 wrangler.jsonc 里绑定到 Worker:
1{2"r2_buckets": [3{4"binding": "BUCKET",5"bucket_name": "my-bucket"6}7]8}
binding 是你在代码里访问这个 bucket 的变量名,bucket_name 是实际的 bucket 名称。
然后给 Hono 加上类型声明:
1import { Hono } from 'hono'23type Bindings = {4BUCKET: R2Bucket5}67const app = new Hono<{ Bindings: Bindings }>()
这样 c.env.BUCKET 就有完整的类型提示了。
5. 核心操作
R2Bucket 提供四个核心方法,覆盖了对象存储的增删查:
01import { Hono } from 'hono'0203type Bindings = {04BUCKET: R2Bucket05}0607const app = new Hono<{ Bindings: Bindings }>()0809// 上传文件10app.put('/objects/:key', async (c) => {11const key = c.req.param('key')12const body = await c.req.arrayBuffer()1314await c.env.BUCKET.put(key, body, {15httpMetadata: {16contentType: c.req.header('Content-Type') || 'application/octet-stream',17},18})1920return c.json({ key, message: 'Uploaded' })21})2223// 获取文件24app.get('/objects/:key', async (c) => {25const key = c.req.param('key')26const object = await c.env.BUCKET.get(key)2728if (!object) {29return c.json({ error: 'Not found' }, 404)30}3132c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')33return c.body(object.body)34})3536// 删除文件37app.delete('/objects/:key', async (c) => {38const key = c.req.param('key')39await c.env.BUCKET.delete(key)40return c.json({ message: 'Deleted' })41})4243// 列出文件44app.get('/objects', async (c) => {45const prefix = c.req.query('prefix') || ''46const limit = Number(c.req.query('limit')) || 2047const cursor = c.req.query('cursor')4849const listed = await c.env.BUCKET.list({50prefix,51limit,52cursor: cursor || undefined,53})5455return c.json({56objects: listed.objects.map((obj) => ({57key: obj.key,58size: obj.size,59uploaded: obj.uploaded,60})),61truncated: listed.truncated,62cursor: listed.truncated ? listed.cursor : undefined,63})64})6566export default app
几个要点:
put(key, body, options)的 body 可以是ArrayBuffer、ReadableStream、string等get(key)返回R2ObjectBody | null,注意判空list()支持分页,truncated为true时用cursor获取下一页
6. 文件上传 API
实际项目中,前端一般用 multipart/form-data 上传文件。来写一个完整的上传接口:
01import { Hono } from 'hono'0203type Bindings = {04BUCKET: R2Bucket05}0607const app = new Hono<{ Bindings: Bindings }>()0809app.post('/upload', async (c) => {10const formData = await c.req.formData()11const file = formData.get('file') as File1213if (!file) {14return c.json({ error: 'No file provided' }, 400)15}1617// 用时间戳 + 原始文件名作为 key,避免重名覆盖18const key = `uploads/${Date.now()}-${file.name}`1920await c.env.BUCKET.put(key, file.stream(), {21httpMetadata: {22contentType: file.type,23},24})2526return c.json({ key, size: file.size })27})2829export default app
前端调用:
01const formData = new FormData()02formData.append('file', fileInput.files[0])0304const res = await fetch('https://your-worker.dev/upload', {05method: 'POST',06body: formData,07})0809const { key } = await res.json()10console.log('文件已上传,key:', key)
注意 file.stream() 是流式传输,不会把整个文件加载到内存,适合大文件。
7. 文件下载与图片服务
上传了文件,还需要一个下载/访问接口。可以做一个简单的图片服务:
01import { Hono } from 'hono'0203type Bindings = {04BUCKET: R2Bucket05}0607const app = new Hono<{ Bindings: Bindings }>()0809// 通过路径访问文件,如 /files/uploads/1234-avatar.png10app.get('/files/*', async (c) => {11const key = c.req.path.replace('/files/', '')12const object = await c.env.BUCKET.get(key)1314if (!object) {15return c.notFound()16}1718// 设置响应头19c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')20c.header('Content-Length', String(object.size))21c.header('ETag', object.httpEtag)2223// 缓存 1 小时24c.header('Cache-Control', 'public, max-age=3600')2526return c.body(object.body)27})2829export default app
加上 Cache-Control 后,图片会被 Cloudflare CDN 缓存,后续请求不用再读 R2,响应更快。
8. 预签名 URL
有时候你不想让文件流量经过 Worker,想让客户端直接从 R2 读写。这时候可以用预签名 URL。
R2 兼容 S3 的预签名 URL 机制,需要用 @aws-sdk/s3-request-presigner:
01import { Hono } from 'hono'02import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'03import { getSignedUrl } from '@aws-sdk/s3-request-presigner'0405type Bindings = {06R2_ACCOUNT_ID: string07R2_ACCESS_KEY_ID: string08R2_SECRET_ACCESS_KEY: string09}1011const app = new Hono<{ Bindings: Bindings }>()1213const getS3Client = (env: Bindings) => {14return new S3Client({15region: 'auto',16endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,17credentials: {18accessKeyId: env.R2_ACCESS_KEY_ID,19secretAccessKey: env.R2_SECRET_ACCESS_KEY,20},21})22}2324// 生成上传用的预签名 URL25app.post('/presign/upload', async (c) => {26const { filename, contentType } = await c.req.json()27const key = `uploads/${Date.now()}-${filename}`2829const client = getS3Client(c.env)30const command = new PutObjectCommand({31Bucket: 'my-bucket',32Key: key,33ContentType: contentType,34})3536const url = await getSignedUrl(client, command, { expiresIn: 3600 })37return c.json({ url, key })38})3940// 生成下载用的预签名 URL41app.post('/presign/download', async (c) => {42const { key } = await c.req.json()4344const client = getS3Client(c.env)45const command = new GetObjectCommand({46Bucket: 'my-bucket',47Key: key,48})4950const url = await getSignedUrl(client, command, { expiresIn: 3600 })51return c.json({ url })52})5354export 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. 实用场景
几个典型用途:
01import { Hono } from 'hono'0203type Bindings = {04BUCKET: R2Bucket05}0607const app = new Hono<{ Bindings: Bindings }>()0809// 用户头像上传10app.post('/api/avatar', async (c) => {11const formData = await c.req.formData()12const file = formData.get('avatar') as File1314if (!file.type.startsWith('image/')) {15return c.json({ error: 'Only images allowed' }, 400)16}1718if (file.size > 2 * 1024 * 1024) {19return c.json({ error: 'File too large, max 2MB' }, 400)20}2122const key = `avatars/${crypto.randomUUID()}.${file.name.split('.').pop()}`23await c.env.BUCKET.put(key, file.stream(), {24httpMetadata: { contentType: file.type },25})2627return c.json({ avatarUrl: `/files/${key}` })28})2930// AI 生成图片存储31app.post('/api/ai-images', async (c) => {32const { imageBuffer, prompt } = await c.req.json()33const key = `ai-generated/${Date.now()}.png`3435await c.env.BUCKET.put(key, Uint8Array.from(atob(imageBuffer), (c) => c.charCodeAt(0)), {36httpMetadata: { contentType: 'image/png' },37customMetadata: { prompt },38})3940return c.json({ key })41})4243// 文档附件44app.post('/api/attachments', async (c) => {45const formData = await c.req.formData()46const file = formData.get('file') as File4748const allowedTypes = [49'application/pdf',50'application/msword',51'text/plain',52]5354if (!allowedTypes.includes(file.type)) {55return c.json({ error: 'File type not allowed' }, 400)56}5758const key = `attachments/${Date.now()}-${file.name}`59await c.env.BUCKET.put(key, file.stream(), {60httpMetadata: { contentType: file.type },61})6263return c.json({ key, filename: file.name })64})6566export default app
头像上传加了类型和大小校验,AI 图片用了 customMetadata 存储生成参数,文档附件限制了允许的文件类型。根据业务需求灵活调整就好。
11. 总结
R2 是 Cloudflare Workers 生态里做文件存储的首选方案。S3 兼容的 API 意味着迁移成本低,零出口流量费是最大卖点。通过 c.env.BUCKET 就能直接操作,put、get、delete、list 四个方法覆盖了绝大多数场景。
下一篇看 Hono RPC 客户端——怎么让前端调用后端 API 像调用本地函数一样有类型提示。