yeh110 / php-s3-lite
零第三方依赖的 S3 兼容对象存储客户端(PHP 8.1+),内置 AWS Signature V4
Requires
- php: >=8.1
- ext-curl: *
- ext-hash: *
- ext-mbstring: *
- ext-simplexml: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
零第三方依赖的 S3 兼容对象存储客户端,PHP 8.1+。
内置 AWS Signature V4 签名实现,不需要 aws/aws-sdk-php,甚至不需要 Composer。
已针对 https://s3.youdomain.com(RustFS)做过端到端实测:49 项检查通过,0 失败(1 条服务端不一致告警,见文末)。
特性
| 能力 | 方法 |
|---|---|
| 上传字符串 / 本地文件 / 流 / 大文件自动分片 | put() putFile() putStream() multipartUpload() upload() |
| 远程 URL 抓取转存 | putFromUrl() fetchToFile() |
| 下载到内存 / 落盘 / 流式 / 分段 | get() getToFile() download() |
| 预签名直传(前端直传,不经过业务服务器) | presignPut() |
| 预签名预览 / 预览链接 / 下载链接 | presignGet() previewUrl() downloadUrl() publicUrl() |
| 服务端代理预览 / 下载(私密桶不暴露地址) | preview() download() |
| 列举分页 / 目录模拟 / 自动翻页 | listObjects() iterateObjects() |
| 元信息 / 存在性 / 复制 / 删除 | head() exists() size() copy() delete() deleteMany() |
| 桶操作 | listBuckets() bucketExists() |
环境要求
- PHP >= 8.1,扩展:
curl、openssl、simplexml、mbstring、hash - 不依赖 Composer。命名空间为
yeh110\S3Lite
安装
git clone git@github.com:yeh110/php-s3-lite.git
<?php // 方式一:直接用自带自动加载(无需 Composer) require __DIR__ . '/php-s3-lite/autoload.php'; // 方式二:用 Composer(composer.json 已配好 PSR-4:yeh110\S3Lite\ → src/) // composer require yeh110/php-s3-lite require __DIR__ . '/vendor/autoload.php';
目录结构
php-s3-lite/
├── autoload.php 免 Composer 的自动加载
├── composer.json
├── src/
│ ├── S3Config.php 配置(只读属性 + fromArray + CA 自动探测)
│ ├── S3Client.php 客户端主体:签名请求、上传下载、预签名、预览、远程抓取
│ ├── S3Signer.php AWS Signature V4 签名器
│ ├── S3Object.php 对象描述值对象
│ ├── S3Response.php HTTP 响应值对象 + XML 辅助
│ └── S3Exception.php 异常(带 S3 错误码)
├── examples/ 六个场景的可运行示例
└── tests/smoke.php 端到端冒烟测试(打真实服务端)
快速开始
<?php require __DIR__ . '/php-s3-lite/autoload.php'; use yeh110\S3Lite\S3Client; use yeh110\S3Lite\S3Config; $s3 = new S3Client(new S3Config( endpoint: 'https://s3.youdomain.com', accessKey: '你的AK', secretKey: '你的SK', // region: 'us-east-1', // 默认值,服务端要求其他区域会自动纠偏 )); // 上传 $s3->put('ydrcm', 'docs/readme.txt', '你好', ['contentType' => 'text/plain; charset=utf-8']); // 生成 1 小时有效的预览链接,交给前端或第三方 echo $s3->previewUrl('ydrcm', 'docs/readme.txt', 3600); // 生成 10 分钟有效的直传地址,前端拿它直接 PUT echo $s3->presignPut('ydrcm', 'uploads/a.jpg', 600);
推荐用 S3Config::fromArray() 从环境变量读密钥:
$s3 = new S3Client(S3Config::fromArray([ 'endpoint' => getenv('S3_ENDPOINT'), 'accessKey' => getenv('S3_AK'), 'secretKey' => getenv('S3_SK'), ]));
API 速查
上传
// 字符串 $s3->put('bucket', 'a.txt', '内容', [ 'contentType' => 'text/plain; charset=utf-8', 'metadata' => ['biz-id' => '1001'], // 存为 x-amz-meta-biz-id 'cacheControl' => 'public, max-age=300', ]); // 本地文件(超过 multipartThreshold 自动分片) $s3->putFile('bucket', 'a.zip', '/data/a.zip'); // 流(必须给出长度:S3 不支持 chunked 上传) $fh = fopen('/data/a.bin', 'rb'); $s3->putStream('bucket', 'a.bin', $fh, filesize('/data/a.bin')); fclose($fh); // 显式分片,自定义分片大小(最小 5MiB) $s3->multipartUpload('bucket', 'big.bin', '/data/big.bin', ['partSize' => 8 * 1024 * 1024]); // 自动分派:字符串内容 / 本地路径 / URL / 资源 $s3->upload('bucket', 'key', $任意来源);
下载与预览
// 取到内存 $body = $s3->get('bucket', 'a.txt')->body; // 落到本地文件(流式,不占内存) $bytes = $s3->getToFile('bucket', 'big.zip', '/data/big.zip'); // 分段下载(断点续传、只取文件头尾) $part = $s3->get('bucket', 'big.zip', ['range' => 'bytes=0-1023']); $s3->getToFile('bucket', 'big.zip', '/data/part', ['range' => 'bytes=1048576-']); // 服务端代理输出(Web 环境:浏览器直接看到内容 / 触发下载) $s3->preview('private-bucket', 'a.pdf', ['filename' => '报告.pdf', 'passthroughRange' => true]); $s3->download('private-bucket', 'a.zip', ['filename' => '归档.zip']);
预签名
$s3->presignGet('bucket', 'a.jpg', 3600); // 预览/读取 $s3->presignPut('bucket', 'uploads/a.jpg', 600); // 直传 $s3->presignDelete('bucket', 'a.jpg', 600); // 让客户端删 $s3->presignUrl('GET', 'bucket', 'a.jpg', 3600, [ // 自定义查询参数 'query' => ['response-cache-control' => 'no-cache'], ]); $s3->previewUrl('bucket', 'a.txt', 3600, '季度 报告.txt'); // 内联打开,指定显示名 $s3->downloadUrl('bucket', 'a.txt', 3600, '季度 报告.txt'); // 点击即下载 $s3->publicUrl('bucket', 'a.jpg'); // 公开桶直链(无签名)
远程抓取
// 抓取并转存,自动记录来源到 x-amz-meta-source-url $s3->putFromUrl('bucket', 'avatar.jpg', 'https://example.com/a.jpg', [ 'maxBytes' => 20 * 1024 * 1024, // 超限直接中止 'headers' => ['Referer' => 'https://example.com/'], // 需要防盗链的源站 'metadata' => ['biz' => 'avatar'], ]); // 只抓取到本地 $info = $s3->fetchToFile('https://example.com/a.jpg', '/tmp/a.jpg'); // ['bytes' => 32797, 'contentType' => 'image/jpeg', 'effectiveUrl' => ..., 'status' => 200]
管理
$s3->listBuckets(); // ['ydrcm', 'ydrcm-private'] $s3->bucketExists('ydrcm'); $page = $s3->listObjects('ydrcm', [ 'prefix' => 'docs/', 'delimiter' => '/', 'maxKeys' => 100, ]); // ['objects' => S3Object[], 'prefixes' => string[], 'truncated' => bool, 'nextToken' => ?string, 'keyCount' => int] foreach ($s3->iterateObjects('ydrcm', ['prefix' => 'docs/']) as $object) { /* 自动翻页 */ } $obj = $s3->head('ydrcm', 'docs/a.txt'); // 不存在返回 null $s3->exists('ydrcm', 'docs/a.txt'); $s3->size('ydrcm', 'docs/a.txt'); $s3->copy('ydrcm', 'a.txt', 'ydrcm-private', 'backup/a.txt'); $s3->delete('ydrcm', 'a.txt'); $s3->deleteMany('ydrcm', ['a.txt', 'b.txt']); // 返回成功条数
异常处理
use yeh110\S3Lite\S3Exception; try { $s3->get('ydrcm', 'not-exist.txt'); } catch (S3Exception $e) { $e->getMessage(); // S3 请求失败 [GET https://...] HTTP 404 NoSuchKey: ... $e->statusCode; // 404 $e->s3Code; // NoSuchKey $e->requestId; // 便于找服务端日志 $e->isNotFound(); // true $e->isAccessDenied(); // false $e->isConflict(); }
示例
示例与测试都不内置密钥,先设置环境变量:
# Linux / macOS export S3_AK=你的AK S3_SK=你的SK # PowerShell $env:S3_AK="你的AK"; $env:S3_SK="你的SK" # 可选:覆盖服务地址(默认 https://s3.youdomain.com) $env:S3_ENDPOINT="https://oss.example.com"
php examples/01_upload.php # 上传:字符串/文件/流/分片/自动分派 php examples/02_presign_direct_upload.php # 预签名直传(含前端配合写法) php examples/03_preview.php # 预览:预签名链接 + 服务端代理 php examples/04_download.php # 下载:内存/落盘/分段/链接/代理 php examples/05_fetch_remote.php # 远程抓取转存 php examples/06_manage.php # 列举/元信息/复制/删除
跑冒烟测试(会打真实服务端并自动清理):
php tests/smoke.php php tests/smoke.php --ak=xxx --sk=yyy # 也可以命令行传密钥 php tests/smoke.php --endpoint=http://127.0.0.1:9002 # 绕过 nginx 直连后端 S3_DEBUG=1 php tests/smoke.php # 打印每个请求
配置项
| 配置 | 默认 | 说明 |
|---|---|---|
endpoint |
必填 | 如 https://s3.youdomain.com |
accessKey / secretKey |
必填 | 密钥 |
region |
us-east-1 |
区域不符时自动纠偏重试 |
pathStyle |
true |
路径寻址 /bucket/key;虚拟主机寻址需通配 DNS 与证书 |
verifyTls |
true |
生产务必保持 true |
caBundle |
null |
null=自动探测;''=交给 curl 默认;也可写具体路径 |
timeout / connectTimeout |
60 / 10 | 秒 |
multipartThreshold |
16 MiB | 超过则自动分片 |
multipartPartSize |
8 MiB | 不得小于 5 MiB |
presignForRange |
true |
Range 走预签名,规避服务端验签缺陷(见下) |
remoteMaxBytes |
512 MiB | 远程抓取单文件上限 |
httpVersion |
1.1 |
可改 2 |
debug |
false |
请求过程打到 STDERR |
注意事项(实测踩过的坑)
1. CA 证书:Windows 下最常见的报错
curl: (60) SSL certificate problem: unable to get local issuer certificate
PHP 在 Windows 上常常没有配置 curl.cainfo。本库会自动探测常见位置(curl.cainfo、openssl.cafile、
/etc/ssl/certs/ca-certificates.crt、Git 自带 ca-bundle.crt、PHP 目录下的 cacert.pem 等)。
探测不到时显式指定:
new S3Config(..., caBundle: 'D:/php/extras/ssl/cacert.pem'); // 或临时:caBundle: '' 表示交给 curl 默认行为
2. Range 分段下载
本服务端(RustFS)存在一个验签缺陷:带 Authorization 头且携带 Range 的请求会被判为
SignatureDoesNotMatch: missing signed header: range,即使 range 已正确纳入 SignedHeaders;
而预签名(查询串认证)不受影响,匿名 Range 也正常返回 206。
因此 presignForRange 默认为 true:库在检测到 range 参数时自动改用预签名 URL 发起请求。
服务端修复后可设为 false 走标准签名路径。
3. 不支持 chunked 上传
服务端要求 Content-Length,Transfer-Encoding: chunked 会返回 400。所以:
putStream()必须显式传长度putFromUrl()先落临时文件再上传(远端可能是 chunked/gzip,长度不可预知)
4. 分片上传规则
除最后一片外,每片不得小于 5MiB(S3Config::MIN_PART_SIZE 会强制校验);
CompleteMultipartUpload 失败时库会自动 Abort,避免留下垃圾分片。
5. HeadObject 可能返回陈旧的 200
实测发现:对象删除后,同一个 key 的 head() 偶尔仍返回 200 + 旧 size/etag,
而同一时刻的独立 GET 返回 404、列举返回 0(用全新连接、全新客户端实例、独立 Python 客户端交叉验证过)。
结论是服务端 HeadObject 的不一致,不是本库的问题(RustFS 上游有同族问题: #2933、#1763、 修复 PR #2936)。
业务上如需权威判断对象是否存在,建议用列举或 GET:
// 权威判断(推荐) $exists = $s3->listObjects('bucket', ['prefix' => $key, 'maxKeys' => 1])['keyCount'] > 0;
6. 私密桶的访问方式
私密桶不能匿名读取,二选一:
- 预签名:
previewUrl()/downloadUrl()/presignGet(),把链接交给前端,不占业务服务器带宽 - 服务端代理:
preview()/download(),URL 与密钥特征都不暴露,便于鉴权、审计、限流
实测结论(对 https://s3.youdomain.com)
49 项通过 0 失败 1 告警
覆盖并通过:签名认证、ListBuckets、HeadBucket、对象 PUT/GET/HEAD/DELETE、ETag 与 MD5 一致、 自定义元数据保留、中文与特殊字符 Key、列举分页与目录模拟、服务端复制、分片上传(含 5MiB 下限校验与 Abort)、预签名 GET/PUT、防篡改、防跨对象复用、预览与下载链接(中文文件名)、Range 分段、 远程抓取转存、错误码与异常。
唯一告警即上文第 5 条的 HeadObject 服务端不一致。
安全建议
- 不要用根密钥跑业务:给每个应用单独建最小权限账号
- 密钥放环境变量或
chmod 600的配置文件,不要进代码仓库 - 预签名有效期按需设置(本库限制在 1 秒 ~ 7 天)
- 直传场景建议在 key 里加随机串,并由后端二次校验(大小、类型、归属),不要直接信任前端
- 生产环境保持
verifyTls: true