Skip to content

16.11-对象存储接入

要点

  • 前面我们学了两种存数据的方式:KV 存键值对,D1 存结构化的表格数据
  • 对象存储的模型很简单:每个文件就是一个"对象",每个对象有一个唯一的 key(相当于文件路径),存储的内容就是文件本身的二进制数据
  • R2 是 Cloudflare 提供的对象存储服务,兼容 S3 的 API
  • 用 wrangler 命令行创建
  • R2Bucket 提供四个核心方法,覆盖了对象存储的增删查

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 命令行创建:

shellscript
// terminal
wrangler r2 bucket create my-bucket

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

jsonc
// wrangler.jsonc
{

  "r2_buckets": [

    {

      "binding": "BUCKET",

      "bucket_name": "my-bucket"

    }

  ]

}

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

然后给 Hono 加上类型声明:

typescript
// index.ts
import { Hono } from 'hono'

type Bindings = {

  BUCKET: R2Bucket

}

const app = new Hono<{ Bindings: Bindings }>()

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

5. 核心操作

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

typescript
// index.ts
import { Hono } from 'hono'

type Bindings = {

  BUCKET: R2Bucket

}

const app = new Hono<{ Bindings: Bindings }>()

// 上传文件

app.put('/objects/:key', async (c) => {

  const key = c.req.param('key')

  const body = await c.req.arrayBuffer()

  await c.env.BUCKET.put(key, body, {

    httpMetadata: {

      contentType: c.req.header('Content-Type') || 'application/octet-stream',

    },

  })

  return c.json({ key, message: 'Uploaded' })

})

// 获取文件

app.get('/objects/:key', async (c) => {

  const key = c.req.param('key')

  const object = await c.env.BUCKET.get(key)

  if (!object) {

    return c.json({ error: 'Not found' }, 404)

  }

  c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')

  return c.body(object.body)

})

// 删除文件

app.delete('/objects/:key', async (c) => {

  const key = c.req.param('key')

  await c.env.BUCKET.delete(key)

  return c.json({ message: 'Deleted' })

})

// 列出文件

app.get('/objects', async (c) => {

  const prefix = c.req.query('prefix') || ''

  const limit = Number(c.req.query('limit')) || 20

  const cursor = c.req.query('cursor')

  const listed = await c.env.BUCKET.list({

    prefix,

    limit,

    cursor: cursor || undefined,

  })

  return c.json({

    objects: listed.objects.map((obj) => ({

      key: obj.key,

      size: obj.size,

      uploaded: obj.uploaded,

    })),

    truncated: listed.truncated,

    cursor: listed.truncated ? listed.cursor : undefined,

  })

})

export default app

几个要点:

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

6. 文件上传 API

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

typescript
// index.ts
import { Hono } from 'hono'

type Bindings = {

  BUCKET: R2Bucket

}

const app = new Hono<{ Bindings: Bindings }>()

app.post('/upload', async (c) => {

  const formData = await c.req.formData()

  const file = formData.get('file') as File

  if (!file) {

    return c.json({ error: 'No file provided' }, 400)

  }

  // 用时间戳 + 原始文件名作为 key,避免重名覆盖

  const key = `uploads/${Date.now()}-${file.name}`

  await c.env.BUCKET.put(key, file.stream(), {

    httpMetadata: {

      contentType: file.type,

    },

  })

  return c.json({ key, size: file.size })

})

export default app

前端调用:

typescript
// client.ts
const formData = new FormData()

formData.append('file', fileInput.files[0])

const res = await fetch('https://your-worker.dev/upload', {

  method: 'POST',

  body: formData,

})

const { key } = await res.json()

console.log('文件已上传,key:', key)

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

7. 文件下载与图片服务

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

typescript
// index.ts
import { Hono } from 'hono'

type Bindings = {

  BUCKET: R2Bucket

}

const app = new Hono<{ Bindings: Bindings }>()

// 通过路径访问文件,如 /files/uploads/1234-avatar.png

app.get('/files/*', async (c) => {

  const key = c.req.path.replace('/files/', '')

  const object = await c.env.BUCKET.get(key)

  if (!object) {

    return c.notFound()

  }

  // 设置响应头

  c.header('Content-Type', object.httpMetadata?.contentType || 'application/octet-stream')

  c.header('Content-Length', String(object.size))

  c.header('ETag', object.httpEtag)

  // 缓存 1 小时

  c.header('Cache-Control', 'public, max-age=3600')

  return c.body(object.body)

})

export default app

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

8. 预签名 URL

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

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

typescript
// index.ts
import { Hono } from 'hono'

import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'

import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

type Bindings = {

  R2_ACCOUNT_ID: string

  R2_ACCESS_KEY_ID: string

  R2_SECRET_ACCESS_KEY: string

}

const app = new Hono<{ Bindings: Bindings }>()

const getS3Client = (env: Bindings) => {

  return new S3Client({

    region: 'auto',

    endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,

    credentials: {

      accessKeyId: env.R2_ACCESS_KEY_ID,

      secretAccessKey: env.R2_SECRET_ACCESS_KEY,

    },

  })

}

// 生成上传用的预签名 URL

app.post('/presign/upload', async (c) => {

  const { filename, contentType } = await c.req.json()

  const key = `uploads/${Date.now()}-${filename}`

  const client = getS3Client(c.env)

  const command = new PutObjectCommand({

    Bucket: 'my-bucket',

    Key: key,

    ContentType: contentType,

  })

  const url = await getSignedUrl(client, command, { expiresIn: 3600 })

  return c.json({ url, key })

})

// 生成下载用的预签名 URL

app.post('/presign/download', async (c) => {

  const { key } = await c.req.json()

  const client = getS3Client(c.env)

  const command = new GetObjectCommand({

    Bucket: 'my-bucket',

    Key: key,

  })

  const url = await getSignedUrl(client, command, { expiresIn: 3600 })

  return c.json({ url })

})

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. 实用场景

几个典型用途:

typescript
// index.ts
import { Hono } from 'hono'

type Bindings = {

  BUCKET: R2Bucket

}

const app = new Hono<{ Bindings: Bindings }>()

// 用户头像上传

app.post('/api/avatar', async (c) => {

  const formData = await c.req.formData()

  const file = formData.get('avatar') as File

  if (!file.type.startsWith('image/')) {

    return c.json({ error: 'Only images allowed' }, 400)

  }

  if (file.size > 2 * 1024 * 1024) {

    return c.json({ error: 'File too large, max 2MB' }, 400)

  }

  const key = `avatars/${crypto.randomUUID()}.${file.name.split('.').pop()}`

  await c.env.BUCKET.put(key, file.stream(), {

    httpMetadata: { contentType: file.type },

  })

  return c.json({ avatarUrl: `/files/${key}` })

})

// AI 生成图片存储

app.post('/api/ai-images', async (c) => {

  const { imageBuffer, prompt } = await c.req.json()

  const key = `ai-generated/${Date.now()}.png`

  await c.env.BUCKET.put(key, Uint8Array.from(atob(imageBuffer), (c) => c.charCodeAt(0)), {

    httpMetadata: { contentType: 'image/png' },

    customMetadata: { prompt },

  })

  return c.json({ key })

})

// 文档附件

app.post('/api/attachments', async (c) => {

  const formData = await c.req.formData()

  const file = formData.get('file') as File

  const allowedTypes = [

    'application/pdf',

    'application/msword',

    'text/plain',

  ]

  if (!allowedTypes.includes(file.type)) {

    return c.json({ error: 'File type not allowed' }, 400)

  }

  const key = `attachments/${Date.now()}-${file.name}`

  await c.env.BUCKET.put(key, file.stream(), {

    httpMetadata: { contentType: file.type },

  })

  return c.json({ key, filename: file.name })

})

export default app

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

总结

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

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

基于 MIT 协议开源