最近帮朋友的外贸企业官网(基于 PbootCMS)做了一件事:把所有静态文件迁移到 Cloudflare R2 对象存储。整个过程踩了不少坑,记录一下完整方案。

为什么要用 R2

朋友的站点跑在一台境外虚拟主机上,磁盘空间有限,1000 多张产品图片占了 400 多 MB。而且图片全部通过服务器带宽分发,加载速度一般。

选 Cloudflare R2 的理由很简单:

  • 没有出口流量费——这是跟 AWS S3 最大的区别
  • S3 兼容 API,现成的协议不用学新的
  • 配合 Cloudflare CDN,全球加速
  • 每月 10GB 免费存储,小站够用了

之前的代码为什么不能用

项目里其实已经有两套 S3 相关代码了,一套在 apps/oss_plugin/,一套在 plugins/s3storage/。看完代码发现都跑不了:

第一套(OssUpload.php):纯 PHP 实现,但签名算法写错了。AWS Signature V2 的 StringToSign 格式不对,Date 头缺失,canonical header 的拼接方式也有问题。而且这个类根本没被任何地方调用。

第二套(S3Storage.php):依赖 aws/aws-sdk-php,但项目里没装 Composer,也没有 vendor/ 目录。namespace 也没被 PbootCMS 的自动加载机制识别。

两套代码都没有 hook 进 PbootCMS 的上传流程,等于写了个寂寞。全部删掉,重新来。

方案设计

目标很明确:

  1. 纯 PHP 实现,不依赖任何 SDK
  2. 正确的 AWS Signature V4 签名(R2 要求的)
  3. 无缝接入 PbootCMS 的上传流程
  4. 后台可视化配置
  5. 提供一键迁移工具处理存量文件

核心代码:R2Storage 类

放在 core/extend/R2Storage.php,大概 300 行。核心是 AWS V4 签名的实现:

// Canonical Request
$canonicalHeaders = "content-type:{$contentType}\nhost:{$host}\n";
$signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date';

// String to Sign
$scope = "{$date}/{$this->region}/s3/aws4_request";
$stringToSign = "AWS4-HMAC-SHA256\n{$now}\n{$scope}\n" . hash('sha256', $canonicalRequest);

// Signing Key(四层 HMAC)
$kDate    = hash_hmac('sha256', $date, "AWS4{$this->secretAccessKey}", true);
$kRegion  = hash_hmac('sha256', $this->region, $kDate, true);
$kService = hash_hmac('sha256', 's3', $kRegion, true);
$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
$signature = hash_hmac('sha256', $stringToSign, $kSigning);

R2 的 region 固定填 auto,endpoint 是 https://{accountId}.r2.cloudflarestorage.com

类提供了几个方法:upload()delete()headObject()(用于对比文件)、testConnection()getUrl()。配置从 PbootCMS 的数据库读取,跟后台配置页联动。

Hook 进上传流程

PbootCMS 的文件上传统一走 core/function/file.php 里的 handle_upload() 函数。在函数返回前插入 R2 同步逻辑:

关键设计:上传成功后直接返回 R2 的 URL,存到数据库里的就是 CDN 地址,不用事后再替换。如果 R2 挂了,回退到本地路径,不影响正常使用。

上传前还会 HEAD 请求检查文件是否已存在且大小一致,避免重复上传。

后台配置页

PbootCMS 的系统配置是 tab 式的,在配置页面加了第 10 个 tab「R2存储」,包含:

  • 启用/禁用开关
  • Account ID
  • Access Key ID / Secret Access Key
  • Bucket 名称
  • 自定义域名(自动补 https://
  • 上传路径前缀

配置保存在 PbootCMS 的 ay_config 表里,跟其他系统配置一样,会自动缓存。

一键迁移工具

存量文件怎么办?写了个独立的 PHP 页面,需要登录后台后才能访问,提供五步操作:

  1. 测试连接——HEAD 请求验证 Bucket 是否可达
  2. 批量上传——扫描 static/upload/ 下所有文件,每批 10 个,AJAX 驱动,有进度条,可暂停继续。上传前检查是否已存在,跳过重复文件
  3. 替换数据库链接——用 SQL 的 REPLACE() 函数批量替换数据库中的文件路径
  4. 对比文件——逐个 HEAD 请求对比本地文件和 R2 上的文件大小是否一致
  5. 删除本地文件——只删除确认已同步的文件,大小不一致或 R2 上不存在的会跳过保留,删完自动清理空目录

踩过的坑

1. 自定义域名少了 https://

后台填域名时如果只填了 r2.example.com 没带协议头,替换后数据库里的链接就变成了 r2.example.com/uploads/xxx.jpg,浏览器当成相对路径,图片全挂。

解决:在构造函数里统一处理,不管填了什么格式都强制补上 https://

2. Git 仓库 400MB

上传目录里的图片被提交进了 Git 历史,导致 .git 目录膨胀到 400MB。用 git filter-repo 清理后降到了 14MB。

git filter-repo --invert-paths --path static/upload/ --path static/images/ --force

清完要 force push,其他克隆的地方需要重新 clone。

3. 签名算法的细节

之前那套代码用的是 V2 签名,R2 只支持 V4。V4 签名的几个关键点:

  • Canonical Headers 必须按字母序排列
  • 每个 header 值前后的空格要 trim
  • payload 的 SHA256 hash 要放在 x-amz-content-sha256 头里
  • 签名用的是四层嵌套 HMAC,顺序是 date - region - service - request

最终效果

  • 新上传的文件自动同步到 R2,数据库直接存 CDN 链接
  • 存量 1156 个文件(419MB)通过迁移工具一键搬迁
  • 图片通过 Cloudflare CDN 全球加速
  • 服务器磁盘释放了 400 多 MB
  • Git 仓库从 400MB 瘦身到 14MB
  • R2 失败时自动回退本地,不影响正常使用

总结

整个方案不依赖任何第三方 SDK,纯 PHP + curl 实现,对 PbootCMS 的侵入性很小(只改了一个函数),配置全在后台可视化完成。如果你也在用 PbootCMS 且想上对象存储,这套方案可以直接参考。