mcpField
C
cloudflare-r2

cloudflare-r2

github.com/jackspace云与 DevOps1651 次安装12 周前更新
代码仓库

关于 cloudflare-r2

cloudflare-r2 是在 Workers 中使用 Cloudflare R2 Object Storage 的技能与实现指南,涵盖对象操作及分段传输、预签名 URL、元数据和浏览器 CORS 配置。

主要功能

  1. 01配置 Worker 存储桶绑定
  2. 02上传、下载、列出和删除对象
  3. 03分段及可续传上传方案
  4. 04生成预签名上传和下载 URL
  5. 05配置 HTTP 元数据与 CORS

适用场景

  1. 01构建 Worker 对象存取接口
  2. 02支持大型或可续传文件上传
  3. 03通过预签名 URL 让客户端直接传输文件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/jackspace/claudeskillz --skill cloudflare-r2

安装后在 Claude Code 中输入/cloudflare-r2确认已加载。

附带文件README.mdSKILL.json

详细介绍

Cloudflare R2 对象存储

状态:已可用于生产环境 ✅ 最后更新:2025-10-21 依赖项:cloudflare-worker-base(用于 Worker 设置) 最新版本:[email protected]、@cloudflare/[email protected]、[email protected]


快速入门(5 分钟)

1. 创建 R2 存储桶
# Via Wrangler CLI (recommended)
npx wrangler r2 bucket create my-bucket

# Or via Cloudflare Dashboard
# https://dash.cloudflare.com → R2 Object Storage → Create bucket

存储桶命名规则:

  • 3-63 个字符
  • 只能包含小写字母、数字和连字符
  • 必须以字母或数字开头和结尾
  • 在你的账户内全局唯一
2. 配置 R2 绑定

添加到你的 wrangler.jsonc:

{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-10-11",
  "r2_buckets": [
    {
      "binding": "MY_BUCKET",          // Available as env.MY_BUCKET in your Worker
      "bucket_name": "my-bucket",      // Name from wrangler r2 bucket create
      "preview_bucket_name": "my-bucket-preview"  // Optional: separate bucket for dev
    }
  ]
}

重要:

  • binding 是你在代码中访问存储桶的方式(env.MY_BUCKET)
  • bucket_name 是实际的 R2 存储桶名称
  • preview_bucket_name 是可选项,但建议用于分隔开发和生产数据
3. 基本上传/下载
// src/index.ts
import { Hono } from 'hono';

type Bindings = {
  MY_BUCKET: R2Bucket;
};

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

// Upload file
app.put('/upload/:filename', async (c) => {
  const filename = c.req.param('filename');
  const body = await c.req.arrayBuffer();

  try {
    const object = await c.env.MY_BUCKET.put(filename, body, {
      httpMetadata: {
        contentType: c.req.header('content-type') || 'application/octet-stream',
      },
    });

    return c.json({
      success: true,
      key: object.key,
      size: object.size,
      etag: object.etag,
    });
  } catch (error: any) {
    console.error('R2 Upload Error:', error.message);
    return c.json({ error: 'Upload failed' }, 500);
  }
});

// Download file
app.get('/download/:filename', async (c) => {
  const filename = c.req.param('filename');

  try {
    const object = await c.env.MY_BUCKET.get(filename);

    if (!object) {
      return c.json({ error: 'File not found' }, 404);
    }

    return new Response(object.body, {
      headers: {
        'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
        'ETag': object.httpEtag,
        'Cache-Control': object.httpMetadata?.cacheControl || 'public, max-age=3600',
      },
    });
  } catch (error: any) {
    console.error('R2 Download Error:', error.message);
    return c.json({ error: 'Download failed' }, 500);
  }
});

export default app;
4. 部署并测试
# Deploy
npx wrangler deploy

# Test upload
curl -X PUT https://my-worker.workers.dev/upload/test.txt \
  -H "Content-Type: text/plain" \
  -d "Hello, R2!"

# Test download
curl https://my-worker.workers.dev/download/test.txt

R2 Workers API

类型定义
// Add to env.d.ts or worker-configuration.d.ts
interface Env {
  MY_BUCKET: R2Bucket;
  // ... other bindings
}

// For Hono
type Bindings = {
  MY_BUCKET: R2Bucket;
};

const app = new Hono<{ Bindings: Bindings }>();
put() - 上传对象

签名:

put(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, options?: R2PutOptions): Promise<R2Object | null>

基本用法:

// Upload from request body
await env.MY_BUCKET.put('path/to/file.txt', request.body);

// Upload string
await env.MY_BUCKET.put('config.json', JSON.stringify({ foo: 'bar' }));

// Upload ArrayBuffer
await env.MY_BUCKET.put('image.png', await file.arrayBuffer());

使用元数据:

const object = await env.MY_BUCKET.put('document.pdf', fileData, {
  httpMetadata: {
    contentType: 'application/pdf',
    contentLanguage: 'en-US',
    contentDisposition: 'attachment; filename="report.pdf"',
    contentEncoding: 'gzip',
    cacheControl: 'public, max-age=86400',
  },
  customMetadata: {
    userId: '12345',
    uploadDate: new Date().toISOString(),
    version: '1.0',
  },
});

条件上传(防止覆盖):

// Only upload if file doesn't exist
const object = await env.MY_BUCKET.put('file.txt', data, {
  onlyIf: {
    uploadedBefore: new Date('2020-01-01'), // Any date before R2 existed
  },
});

if (!object) {
  // File already exists, upload prevented
  return c.json({ error: 'File already exists' }, 409);
}

// Only upload if etag matches (update specific version)
const object = await env.MY_BUCKET.put('file.txt', data, {
  onlyIf: {
    etagMatches: existingEtag,
  },
});

使用校验和:

// R2 will verify the checksum
const md5Hash = await crypto.subtle.digest('MD5', fileData);

await env.MY_BUCKET.put('file.txt', fileData, {
  md5: md5Hash,
});
get() - 下载对象

签名:

get(key: string, options?: R2GetOptions): Promise<R2ObjectBody | null>

基本用法:

// Get full object
const object = await env.MY_BUCKET.get('file.txt');

if (!object) {
  return c.json({ error: 'Not found' }, 404);
}

// Return as response
return new Response(object.body, {
  headers: {
    'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
    'ETag': object.httpEtag,
  },
});

读取为不同格式:

const object = await env.MY_BUCKET.get('data.json');

if (object) {
  const text = await object.text();           // As string
  const json = await object.json();           // As JSON object
  const buffer = await object.arrayBuffer();  // As ArrayBuffer
  const blob = await object.blob();           // As Blob
}

范围请求(部分下载):

// Get first 1MB of file
const object = await env.MY_BUCKET.get('large-file.mp4', {
  range: { offset: 0, length: 1024 * 1024 },
});

// Get bytes 100-200
const object = await env.MY_BUCKET.get('file.bin', {
  range: { offset: 100, length: 100 },
});

// Get from offset to end
const object = await env.MY_BUCKET.get('file.bin', {
  range: { offset: 1000 },
});

条件下载:

// Only download if etag matches
const object = await env.MY_BUCKET.get('file.txt', {
  onlyIf: {
    etagMatches: cachedEtag,
  },
});

if (!object) {
  // Etag didn't match, file was modified
  return c.json({ error: 'File changed' }, 412);
}
head() - 仅获取元数据

签名:

head(key: string): Promise<R2Object | null>

用法:

// Get object metadata without downloading body
const object = await env.MY_BUCKET.head('file.txt');

if (object) {
  console.log({
    key: object.key,
    size: object.size,
    etag: object.etag,
    uploaded: object.uploaded,
    contentType: object.httpMetadata?.contentType,
    customMetadata: object.customMetadata,
  });
}

使用场景:

  • 检查文件是否存在
  • 下载前获取文件大小
  • 检查最后修改日期
  • 验证用于缓存的 etag
delete() - 删除对象

签名:

delete(key: string | string[]): Promise<void>

删除单个对象:

// Delete single object
await env.MY_BUCKET.delete('file.txt');

// No error if file doesn't exist (idempotent)

批量删除(最多 1000 个键):

// Delete multiple objects at once
const keysToDelete = [
  'old-file-1.txt',
  'old-file-2.txt',
  'temp/cache-data.json',
];

await env.MY_BUCKET.delete(keysToDelete);

// Much faster than individual deletes

确认后删除:

app.delete('/files/:filename', async (c) => {
  const filename = c.req.param('filename');

  // Check if exists first
  const exists = await c.env.MY_BUCKET.head(filename);

  if (!exists) {
    return c.json({ error: 'File not found' }, 404);
  }

  await c.env.MY_BUCKET.delete(filename);

  return c.json({ success: true, deleted: filename });
});
list() - 列出对象

签名:

list(options?: R2ListOptions): Promise<R2Objects>

基本列表:

// List all objects (up to 1000)
const listed = await env.MY_BUCKET.list();

console.log({
  objects: listed.objects,      // Array of R2Object
  truncated: listed.truncated,  // true if more results exist
  cursor: listed.cursor,        // For pagination
});

// Process objects
for (const object of listed.objects) {
  console.log(`${object.key}: ${object.size} bytes`);
}

分页:

app.get('/api/files', async (c) => {
  const cursor = c.req.query('cursor');

  const listed = await c.env.MY_BUCKET.list({
    limit: 100,
    cursor: cursor || undefined,
  });

  return c.json({
    files: listed.objects.map(obj => ({
      name: obj.key,
      size: obj.size,
      uploaded: obj.uploaded,
      etag: obj.etag,
    })),
    hasMore: listed.truncated,
    nextCursor: listed.cursor,
  });
});

按前缀筛选(按目录列出):

// List all files in 'images/' folder
const images = await env.MY_BUCKET.list({
  prefix: 'images/',
});

// List all user files
const userFiles = await env.MY_BUCKET.list({
  prefix: `users/${userId}/`,
});

分隔符(类似文件夹的列表):

// List only top-level items in 'uploads/'
const listed = await env.MY_BUCKET.list({
  prefix: 'uploads/',
  delimiter: '/',
});

console.log('Files:', listed.objects);          // Files directly in uploads/
console.log('Folders:', listed.delimitedPrefixes); // Sub-folders like uploads/2024/

分段上传

对于大于 100MB 的文件或需要可恢复上传时,请使用分段上传 API。

何时使用分段上传

✅ 适合使用分段上传的情况:

  • 文件大小 > 100MB
  • 需要可恢复上传
  • 希望并行上传
  • 从浏览器/客户端上传

❌ 不适合使用分段上传的情况:

  • 文件大小 < 5MB(开销不值得)
  • 在 Worker 中上传(请使用直接 put())
基本分段上传流程
app.post('/api/upload/start', async (c) => {
  const { filename } = await c.req.json();

  // Create multipart upload
  const multipart = await c.env.MY_BUCKET.createMultipartUpload(filename, {
    httpMetadata: {
      contentType: 'application/octet-stream',
    },
  });

  return c.json({
    key: multipart.key,
    uploadId: multipart.uploadId,
  });
});

app.put('/api/upload/part', async (c) => {
  const { key, uploadId, partNumber } = await c.req.json();
  const body = await c.req.arrayBuffer();

  // Resume the multipart upload
  const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);

  // Upload a part
  const uploadedPart = await multipart.uploadPart(partNumber, body);

  return c.json({
    partNumber: uploadedPart.partNumber,
    etag: uploadedPart.etag,
  });
});

app.post('/api/upload/complete', async (c) => {
  const { key, uploadId, parts } = await c.req.json();

  // Resume the multipart upload
  const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);

  // Complete the upload
  const object = await multipart.complete(parts);

  return c.json({
    success: true,
    key: object.key,
    size: object.size,
    etag: object.etag,
  });
});

app.post('/api/upload/abort', async (c) => {
  const { key, uploadId } = await c.req.json();

  const multipart = c.env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
  await multipart.abort();

  return c.json({ success: true });
});
完整的分段上传示例
// Full Worker implementing multipart upload API
interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1);
    const action = url.searchParams.get('action');

    switch (request.method) {
      case 'POST': {
        switch (action) {
          case 'mpu-create': {
            // Create multipart upload
            const multipart = await env.MY_BUCKET.createMultipartUpload(key);
            return Response.json({
              key: multipart.key,
              uploadId: multipart.uploadId,
            });
          }
          case 'mpu-complete': {
            // Complete multipart upload
            const uploadId = url.searchParams.get('uploadId')!;
            const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
            const parts = await request.json();
            const object = await multipart.complete(parts);
            return Response.json({
              key: object.key,
              etag: object.etag,
              size: object.size,
            });
          }
          default:
            return new Response(`Unknown action: ${action}`, { status: 400 });
        }
      }
      case 'PUT': {
        switch (action) {
          case 'mpu-uploadpart': {
            // Upload a part
            const uploadId = url.searchParams.get('uploadId')!;
            const partNumber = parseInt(url.searchParams.get('partNumber')!);
            const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
            const uploadedPart = await multipart.uploadPart(partNumber, request.body!);
            return Response.json({
              partNumber: uploadedPart.partNumber,
              etag: uploadedPart.etag,
            });
          }
          default:
            return new Response(`Unknown action: ${action}`, { status: 400 });
        }
      }
      case 'DELETE': {
        switch (action) {
          case 'mpu-abort': {
            // Abort multipart upload
            const uploadId = url.searchParams.get('uploadId')!;
            const multipart = env.MY_BUCKET.resumeMultipartUpload(key, uploadId);
            await multipart.abort();
            return new Response(null, { status: 204 });
          }
          default:
            return new Response(`Unknown action: ${action}`, { status: 400 });
        }
      }
      default:
        return new Response('Method Not Allowed', { status: 405 });
    }
  },
} satisfies ExportedHandler<Env>;

预签名 URL

预签名 URL 可让客户端直接向 R2 上传对象或从 R2 下载对象,无需经过你的 Worker。

何时使用预签名 URL

✅ 适合使用预签名 URL 的情况:

  • 客户端直接向 R2 上传(节省 Worker 带宽)
  • 临时访问私有对象
  • 分享带有有效期的下载链接
  • 避免 Worker 请求大小限制

❌ 不适合使用预签名 URL 的情况:

  • 需要处理或验证上传内容
  • 文件已经公开
  • 使用自定义域名(预签名 URL 仅适用于 R2 端点)
使用 aws4fetch 生成预签名 URL
npm install aws4fetch
import { AwsClient } from 'aws4fetch';

interface Env {
  R2_ACCESS_KEY_ID: string;
  R2_SECRET_ACCESS_KEY: string;
  ACCOUNT_ID: string;
  MY_BUCKET: R2Bucket;
}

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

app.post('/api/presigned-upload', async (c) => {
  const { filename } = await c.req.json();

  // Create AWS client for R2
  const r2Client = new AwsClient({
    accessKeyId: c.env.R2_ACCESS_KEY_ID,
    secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
  });

  const bucketName = 'my-bucket';
  const accountId = c.env.ACCOUNT_ID;

  const url = new URL(
    `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
  );

  // Set expiry (1 hour)
  url.searchParams.set('X-Amz-Expires', '3600');

  // Sign the URL for PUT
  const signed = await r2Client.sign(
    new Request(url, { method: 'PUT' }),
    { aws: { signQuery: true } }
  );

  return c.json({
    uploadUrl: signed.url,
    expiresIn: 3600,
  });
});

app.post('/api/presigned-download', async (c) => {
  const { filename } = await c.req.json();

  const r2Client = new AwsClient({
    accessKeyId: c.env.R2_ACCESS_KEY_ID,
    secretAccessKey: c.env.R2_SECRET_ACCESS_KEY,
  });

  const bucketName = 'my-bucket';
  const accountId = c.env.ACCOUNT_ID;

  const url = new URL(
    `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${filename}`
  );

  url.searchParams.set('X-Amz-Expires', '3600');

  const signed = await r2Client.sign(
    new Request(url, { method: 'GET' }),
    { aws: { signQuery: true } }
  );

  return c.json({
    downloadUrl: signed.url,
    expiresIn: 3600,
  });
});
使用预签名 URL 在客户端上传
// 1. Get presigned URL from your Worker
const response = await fetch('https://my-worker.workers.dev/api/presigned-upload', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ filename: 'photo.jpg' }),
});

const { uploadUrl } = await response.json();

// 2. Upload file directly to R2
const file = document.querySelector('input[type="file"]').files[0];

await fetch(uploadUrl, {
  method: 'PUT',
  body: file,
  headers: {
    'Content-Type': file.type,
  },
});
预签名 URL 安全

重要:

  • ❌ 绝不要在客户端代码中暴露 R2 访问密钥
  • ✅ 始终在服务端生成预签名 URL
  • ✅ 始终设置适当的有效期(通常为 1-24 小时)
  • ✅ 考虑在生成 URL 前添加身份验证
  • ✅ 考虑对预签名 URL 的生成进行速率限制
// Example with auth check
app.post('/api/presigned-upload', async (c) => {
  // Verify user is authenticated
  const authHeader = c.req.header('Authorization');
  if (!authHeader) {
    return c.json({ error: 'Unauthorized' }, 401);
  }

  // Validate user has permission
  const userId = await verifyToken(authHeader);
  if (!userId) {
    return c.json({ error: 'Invalid token' }, 401);
  }

  // Only allow uploads to user's own folder
  const { filename } = await c.req.json();
  const key = `users/${userId}/${filename}`;

  // Generate presigned URL...
});

CORS 配置

配置 CORS,以允许浏览器请求你的 R2 存储桶。

公共存储桶 CORS
{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://example.com"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["*"],
      "MaxAgeSeconds": 3600
    }
  ]
}
允许所有来源(公共资源)
{
  "CORSRules": [
    {
      "AllowedOrigins": ["*"],
      "AllowedMethods": ["GET", "HEAD"],
      "AllowedHeaders": ["Range"],
      "MaxAgeSeconds": 3600
    }
  ]
}
使用 CORS 上传
{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://app.example.com"],
      "AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
      "AllowedHeaders": [
        "Content-Type",
        "Content-MD5",
        "x-amz-meta-*"
      ],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
通过控制面板应用 CORS
  1. 前往 Cloudflare 控制面板 → R2
  2. 选择你的存储桶
  3. 前往 Settings 选项卡
  4. 在 CORS Policy 下选择 Add CORS policy
  5. 粘贴 JSON 配置
  6. 保存
预签名 URL 的 CORS

使用预签名 URL 时,CORS 由 R2 直接处理。请在存储桶上配置 CORS,而不是在 Worker 中配置。


HTTP 元数据

Content-Type
// Set content type on upload
await env.MY_BUCKET.put('image.jpg', imageData, {
  httpMetadata: {
    contentType: 'image/jpeg',
  },
});

// Will be returned in Content-Type header when downloaded
Cache-Control
// Set caching headers
await env.MY_BUCKET.put('static/logo.png', logoData, {
  httpMetadata: {
    contentType: 'image/png',
    cacheControl: 'public, max-age=31536000, immutable',
  },
});

// For frequently updated content
await env.MY_BUCKET.put('api/data.json', jsonData, {
  httpMetadata: {
    contentType: 'application/json',
    cacheControl: 'public, max-age=60, must-revalidate',
  },
});
Content-Disposition
// Force download with specific filename
await env.MY_BUCKET.put('report.pdf', pdfData, {
  httpMetadata: {
    contentType: 'application/pdf',
    contentDisposition: 'attachment; filename="monthly-report.pdf"',
  },
});

// Display inline
await env.MY_BUCKET.put('image.jpg', imageData, {
  httpMetadata: {
    contentType: 'image/jpeg',
    contentDisposition: 'inline',
  },
});
Content-Encoding
// Indicate gzip compression
await env.MY_BUCKET.put('data.json.gz', gzippedData, {
  httpMetadata: {
    contentType: 'application/json',
    contentEncoding: 'gzip',
  },
});

自定义元数据

为对象存储任意键值元数据。

设置自定义元数据
await env.MY_BUCKET.put('document.pdf', pdfData, {
  customMetadata: {
    userId: '12345',
    department: 'engineering',
    uploadDate: new Date().toISOString(),
    version: '1.0',
    approved: 'true',
  },
});
读取自定义元数据
const object = await env.MY_BUCKET.head('document.pdf');

if (object) {
  console.log(object.customMetadata);
  // {
  //   userId: '12345',
  //   department: 'engineering',
  //   uploadDate: '2025-10-21T10:00:00.000Z',
  //   version: '1.0',
  //   approved: 'true'
  // }
}
限制
  • 所有自定义元数据的总大小最多为 2KB
  • 键和值必须是字符串
  • 键不区分大小写
  • 键的数量不限(但总大小不得超过 2KB)

错误处理

常见 R2 错误
try {
  await env.MY_BUCKET.put(key, data);
} catch (error: any) {
  const message = error.message;

  if (message.includes('R2_ERROR')) {
    // Generic R2 error
  } else if (message.includes('exceeded')) {
    // Quota exceeded
  } else if (message.includes('precondition')) {
    // Conditional operation failed
  } else if (message.includes('multipart')) {
    // Multipart upload error
  }

  console.error('R2 Error:', message);
  return c.json({ error: 'Storage operation failed' }, 500);
}
重试逻辑
async function r2WithRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error: any) {
      const message = error.message;

      // Retry on transient errors
      const isRetryable =
        message.includes('network') ||
        message.includes('timeout') ||
        message.includes('temporarily unavailable');

      if (!isRetryable || attempt === maxRetries - 1) {
        throw error;
      }

      // Exponential backoff
      const delay = Math.min(1000 * Math.pow(2, attempt), 5000);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }

  throw new Error('Retry logic failed');
}

// Usage
const object = await r2WithRetry(() =>
  env.MY_BUCKET.get('important-file.txt')
);

性能优化

批量操作
// ❌ DON'T: Delete files one by one
for (const file of filesToDelete) {
  await env.MY_BUCKET.delete(file);
}

// ✅ DO: Batch delete (up to 1000 keys)
await env.MY_BUCKET.delete(filesToDelete);
大文件的范围请求
// Download only the first 10MB of a large video
const object = await env.MY_BUCKET.get('video.mp4', {
  range: { offset: 0, length: 10 * 1024 * 1024 },
});

// Return range to client
return new Response(object.body, {
  status: 206, // Partial Content
  headers: {
    'Content-Type': 'video/mp4',
    'Content-Range': `bytes 0-${10 * 1024 * 1024 - 1}/${object.size}`,
  },
});
缓存标头
// Immutable assets (hashed filenames)
await env.MY_BUCKET.put('static/app.abc123.js', jsData, {
  httpMetadata: {
    contentType: 'application/javascript',
    cacheControl: 'public, max-age=31536000, immutable',
  },
});

// Dynamic content
await env.MY_BUCKET.put('api/latest.json', jsonData, {
  httpMetadata: {
    contentType: 'application/json',
    cacheControl: 'public, max-age=60, stale-while-revalidate=300',
  },
});
数据完整性的校验和
// Compute MD5 checksum
const md5Hash = await crypto.subtle.digest('MD5', fileData);

// R2 will verify checksum on upload
await env.MY_BUCKET.put('important.dat', fileData, {
  md5: md5Hash,
});

// If checksum doesn't match, upload will fail

最佳实践摘要

✅ 始终执行:
  1. 为所有上传设置适当的 contentType
  2. 对多个对象使用批量删除(最多 1000 个)
  3. 为静态资源设置缓存标头(cacheControl)
  4. 对大型客户端上传使用预签名 URL
  5. 对大于 100MB 的文件使用分段上传
  6. 允许浏览器上传前设置 CORS 策略
  7. 为预签名 URL 设置过期时间(1-24 小时)
  8. 使用 try/catch 妥善处理错误
  9. 只需要元数据时,使用 head(),而不是 get()
  10. 使用条件操作防止覆盖
❌ 绝不执行:
  1. 绝不在客户端代码中暴露 R2 访问密钥
  2. 绝不省略 contentType(文件将以二进制形式下载)
  3. 绝不在循环中删除(使用批量删除)
  4. 绝不在没有错误处理的情况下上传
  5. 浏览器上传时绝不跳过 CORS 配置
  6. 绝不对小文件(< 5MB)使用分段上传
  7. 绝不在单次调用中删除超过 1000 个键(将会失败)
  8. 绝不假定上传成功(始终检查响应)
  9. 绝不省略预签名 URL 的过期时间(存在安全风险)
  10. 绝不硬编码存储桶名称(使用绑定)

已避免的已知问题

问题描述避免方法
浏览器中的 CORS 错误缺少 CORS 策略,导致浏览器无法上传或下载在允许浏览器访问前,在存储桶设置中配置 CORS
文件以二进制形式下载缺少 content-type,导致浏览器下载文件而不是显示文件上传时始终设置 httpMetadata.contentType
预签名 URL 过期URL 永不过期,带来安全风险始终设置 X-Amz-Expires(通常为 1-24 小时)
分段上传限制分块超过 100MB 或分块数超过 10,000分块大小保持在 5MB-100MB,每次上传最多 10,000 个分块
批量删除限制尝试删除超过 1000 个键时会失败将删除操作拆分为每批 1000 个
自定义元数据溢出元数据超过 2KB 限制将自定义元数据总大小控制在 2KB 以内

Wrangler 命令参考

# Bucket management
wrangler r2 bucket create <BUCKET_NAME>
wrangler r2 bucket list
wrangler r2 bucket delete <BUCKET_NAME>

# Object management
wrangler r2 object put <BUCKET_NAME>/<KEY> --file=<FILE_PATH>
wrangler r2 object get <BUCKET_NAME>/<KEY> --file=<OUTPUT_PATH>
wrangler r2 object delete <BUCKET_NAME>/<KEY>

# List objects
wrangler r2 object list <BUCKET_NAME>
wrangler r2 object list <BUCKET_NAME> --prefix="folder/"

官方文档


准备好使用 R2 存储了! 🚀

评论

支持 Markdown