Search by

yeh110 / php-s3-lite

yeh110

零第三方依赖的 S3 兼容对象存储客户端(PHP 8.1+),内置 AWS Signature V4

Package info

github.com/yeh110/php-s3-lite

pkg:composer/yeh110/php-s3-lite

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v0.1.1 2026-09-30 13:12 UTC

This package is auto-updated.

Last update: 2026-09-30 13:19:41 UTC


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 服务端不一致。

安全建议

  1. 不要用根密钥跑业务:给每个应用单独建最小权限账号
  2. 密钥放环境变量或 chmod 600 的配置文件,不要进代码仓库
  3. 预签名有效期按需设置(本库限制在 1 秒 ~ 7 天)
  4. 直传场景建议在 key 里加随机串,并由后端二次校验(大小、类型、归属),不要直接信任前端
  5. 生产环境保持 verifyTls: true