最近帮朋友的外贸企业官网(基于 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 的上传流程,等于写了个寂寞。全部删掉,重新来。
方案设计
目标很明确:
- 纯 PHP 实现,不依赖任何 SDK
- 正确的 AWS Signature V4 签名(R2 要求的)
- 无缝接入 PbootCMS 的上传流程
- 后台可视化配置
- 提供一键迁移工具处理存量文件
核心代码: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 页面,需要登录后台后才能访问,提供五步操作:
- 测试连接——HEAD 请求验证 Bucket 是否可达
- 批量上传——扫描 static/upload/ 下所有文件,每批 10 个,AJAX 驱动,有进度条,可暂停继续。上传前检查是否已存在,跳过重复文件
- 替换数据库链接——用 SQL 的 REPLACE() 函数批量替换数据库中的文件路径
- 对比文件——逐个 HEAD 请求对比本地文件和 R2 上的文件大小是否一致
- 删除本地文件——只删除确认已同步的文件,大小不一致或 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 且想上对象存储,这套方案可以直接参考。
评论
暂无评论