hlkaiyan / min-s3
轻量 S3 客户端,面向自建 S3 服务器(MinIO / Ceph / 其他 S3 兼容存储),零第三方依赖
Requires
- php: >=8.1
- ext-curl: *
- ext-json: *
- ext-simplexml: *
- ext-xmlwriter: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
面向自建 S3 服务器(MinIO / Ceph RGW / SeaweedFS / 其他 S3 兼容存储)的轻量 PHP 客户端。
零第三方依赖,只用 PHP 内置扩展。用法与 aws/aws-sdk-php 的 S3Client 保持一致,从原 SDK 迁移基本不用改调用代码。
$s3 = new MinS3\S3Client([ 'endpoint' => 'http://127.0.0.1:9000', 'region' => 'us-east-1', 'credentials' => ['key' => 'minioadmin', 'secret' => 'minioadmin'], ]); $s3->putObject(['Bucket' => 'my-bucket', 'Key' => 'a.txt', 'Body' => 'hello']); echo $s3->getObject(['Bucket' => 'my-bucket', 'Key' => 'a.txt'])['Body'];
为什么不直接用 aws-sdk-php
以下是 src/ 目录的实测数据:
| aws/aws-sdk-php | 只抽 S3 的版本 | min-s3 | |
|---|---|---|---|
| 自身文件数 | 3478 | 412 | 51 |
| 自身体积 | 47.2 MB | 3.4 MB | 529 KB |
| 第三方依赖包 | 6 | 6 | 0 |
| 第三方依赖体积 | 2.0 MB | 2.0 MB | 0 |
| 装完的总体积 | 49.2 MB | 5.4 MB | 529 KB |
| 支持的 S3 操作 | 116 | 116 | 116 |
操作数一个没少:min-s3 复用了 aws-sdk-php 的 S3 接口模型文件(src/data/api-2.json.php,267 KB),
由它驱动请求的序列化与响应解析。因此 116 个 S3 操作的参数名、类型、位置全部与官方 SDK 一致,
不是手写的一小撮常用接口。
去掉的是 AWS 专有设施:区域端点表、凭证链(IMDS / ECS / SSO / AssumeRole)、 接入点 ARN 寻址、S3 Express、传输加速、双栈与 FIPS 端点、客户端加密。 这些在自建 S3 场景下用不到。
环境要求
- PHP >= 8.1
- 扩展:
curl、simplexml、json、xmlwriter(一般都是默认启用的)
安装
composer require hlkaiyan/min-s3
本地目录安装(调试时用,改动立即生效):
{
"repositories": [
{ "type": "path", "url": "./min-s3" }
],
"require": {
"hlkaiyan/min-s3": "*"
}
}
不使用 Composer
把整个 min-s3 目录放进项目,引入自带的加载器即可。因为没有第三方依赖,
不需要额外准备任何东西:
require __DIR__ . '/min-s3/autoload.php'; $s3 = new MinS3\S3Client([...]);
对接自建 S3 服务器
use MinS3\S3Client; $s3 = new S3Client([ 'endpoint' => 'http://127.0.0.1:9000', // 必填 'region' => 'us-east-1', // 必填,见下方说明 'credentials' => [ 'key' => 'minioadmin', 'secret' => 'minioadmin', ], ]);
关于 region:自建服务通常不校验区域,但它是 SigV4 签名范围的一部分,
客户端和服务端算出来的必须一致,所以不能省略。服务端没特殊配置就填 us-east-1。
关于寻址方式:min-s3 默认用路径式(http://host/bucket/key),
这与 aws-sdk-php 的默认值相反 —— 自建服务多数不支持虚拟主机式寻址。
如果你的服务配了泛域名解析,可以切换:
'use_path_style_endpoint' => false, // 变成 http://bucket.host/key
端点是 IP 地址时会自动退回路径式(bucket.127.0.0.1 无法解析),无需手动处理。
关于自签名证书:内网 HTTPS 常用自签证书,可以指定 CA 或关闭校验:
'http' => [ 'verify' => '/path/to/ca-bundle.crt', // 指定 CA // 'verify' => false, // 关闭校验,仅限可信内网 ],
常用操作
上传
// 字符串 $s3->putObject(['Bucket' => 'b', 'Key' => 'a.txt', 'Body' => 'hello']); // 本地文件(不读进内存) $s3->putObject(['Bucket' => 'b', 'Key' => 'a.txt', 'SourceFile' => '/path/a.txt']); // 流 $s3->putObject([ 'Bucket' => 'b', 'Key' => 'a.bin', 'Body' => MinS3\Http\Stream::open('/path/a.bin', 'r'), ]); // 附带元数据与其他参数 $s3->putObject([ 'Bucket' => 'b', 'Key' => 'report.pdf', 'SourceFile' => '/path/report.pdf', 'ContentType' => 'application/pdf', 'ACL' => 'public-read', 'Metadata' => ['author' => 'zhang', 'version' => '2'], 'CacheControl'=> 'max-age=3600', ]);
upload() 会按大小自动选择整体上传或分片上传(默认阈值 16 MB):
$s3->upload('my-bucket', 'big.zip', fopen('/path/big.zip', 'r'));
大文件分片上传
use MinS3\Multipart\MultipartUploader; $uploader = new MultipartUploader($s3, '/path/big.bin', [ 'bucket' => 'my-bucket', 'key' => 'big.bin', 'part_size' => 8 * 1024 * 1024, // 分片大小,最小 5 MB 'concurrency' => 4, // 并发分片数 ]); $result = $uploader->upload();
分片是真并发(curl_multi),且源文件按需分段读取,内存占用与文件大小无关。
断点续传:失败时从异常里取出状态,之后接着传,已完成的分片不会重传。
use MinS3\Exception\MultipartUploadException; try { $uploader->upload(); } catch (MultipartUploadException $e) { $state = $e->getState(); file_put_contents('/tmp/upload.state', serialize($state)); // 稍后续传 $state = unserialize(file_get_contents('/tmp/upload.state')); (new MultipartUploader($s3, '/path/big.bin', [ 'bucket' => 'my-bucket', 'key' => 'big.bin', 'state' => $state, ]))->upload(); }
放弃上传时记得清理,否则残留分片会一直占用空间:
$uploader->abort();
下载
// 读进内存 $body = (string) $s3->getObject(['Bucket' => 'b', 'Key' => 'a.txt'])['Body']; // 直接落盘,不经过内存 $s3->getObject(['Bucket' => 'b', 'Key' => 'big.bin', 'SaveAs' => '/path/big.bin']); // 流式分段读 $stream = $s3->getObject(['Bucket' => 'b', 'Key' => 'big.bin'])['Body']; while (!$stream->eof()) { echo $stream->read(8192); } // 断点续传 / 部分下载 $part = $s3->getObject(['Bucket' => 'b', 'Key' => 'big.bin', 'Range' => 'bytes=0-1023']);
列举与翻页
单次列举最多返回 1000 条,用分页器自动翻页:
// 逐页 foreach ($s3->getPaginator('ListObjectsV2', ['Bucket' => 'b', 'Prefix' => 'logs/']) as $page) { foreach ($page['Contents'] ?? [] as $object) { echo $object['Key'], ' ', $object['Size'], "\n"; } } // 逐个对象,省掉一层循环 foreach ($s3->getIterator('ListObjectsV2', ['Bucket' => 'b']) as $object) { echo $object['Key'], "\n"; } // 只取前 100 个 foreach ($s3->getIterator('ListObjectsV2', ['Bucket' => 'b', '@limit' => 100]) as $object) { echo $object['Key'], "\n"; }
按"目录"列举:
$result = $s3->listObjectsV2(['Bucket' => 'b', 'Prefix' => 'docs/', 'Delimiter' => '/']); foreach ($result['CommonPrefixes'] ?? [] as $prefix) { echo "子目录: ", $prefix['Prefix'], "\n"; } foreach ($result['Contents'] ?? [] as $object) { echo "文件: ", $object['Key'], "\n"; }
删除
$s3->deleteObject(['Bucket' => 'b', 'Key' => 'a.txt']); // 批量删除,自动按 1000 个一批 use MinS3\BatchDelete; BatchDelete::fromKeys($s3, 'b', ['a.txt', 'b.txt', 'c.txt'])->delete(); BatchDelete::fromListObjects($s3, 'b', ['Prefix' => 'tmp/'])->delete(); // 按正则删除 $s3->deleteMatchingObjects('b', 'logs/', '/\.log$/');
复制
$s3->copy('src-bucket', 'src-key', 'dst-bucket', 'dst-key');
超过 50 MB 自动改用分片复制(S3 单次复制上限 5 GB)。数据在服务端流转,不经过本机。
预签名 URL
生成临时链接,不暴露密钥。生成过程是纯本地计算,不发请求:
// 下载链接 $url = $s3->createPresignedUrl('b', 'a.txt', '+20 minutes'); // 强制浏览器下载并指定文件名 $url = $s3->createPresignedUrl('b', 'a.pdf', '+1 hour', [ 'ResponseContentDisposition' => 'attachment; filename="报告.pdf"', ]); // 上传链接:前端可以直接 PUT 到这个地址 $command = $s3->getCommand('PutObject', ['Bucket' => 'b', 'Key' => 'up.bin']); $url = (string) $s3->createPresignedRequest($command, '+30 minutes')->getUri();
有效期上限是 7 天(SigV4 的规定)。
浏览器表单直传
文件从浏览器直接传到 S3,不经过你的服务器:
use MinS3\PostObjectV4; $post = new PostObjectV4($s3, 'my-bucket', ['key' => 'uploads/${filename}', 'acl' => 'private'], [ ['bucket' => 'my-bucket'], ['starts-with', '$key', 'uploads/'], ['content-length-range', 0, 10 * 1024 * 1024], // 限制 10 MB ], '+1 hours' ); ?> <form action="<?= htmlspecialchars($post->getFormAttributes()['action']) ?>" method="POST" enctype="multipart/form-data"> <?php foreach ($post->getFormInputs() as $name => $value): ?> <input type="hidden" name="<?= htmlspecialchars($name) ?>" value="<?= htmlspecialchars($value) ?>"> <?php endforeach; ?> <input type="file" name="file"> <button type="submit">上传</button> </form>
s3:// 流包装器
注册后可以用 PHP 原生文件函数操作对象:
$s3->registerStreamWrapper(); file_put_contents('s3://my-bucket/a.txt', 'hello'); echo file_get_contents('s3://my-bucket/a.txt'); $handle = fopen('s3://my-bucket/big.bin', 'r'); while (!feof($handle)) { echo fread($handle, 8192); } fclose($handle); file_exists('s3://my-bucket/a.txt'); filesize('s3://my-bucket/a.txt'); unlink('s3://my-bucket/a.txt'); foreach (scandir('s3://my-bucket/docs') as $name) { echo $name, "\n"; }
写入时通过 context 传额外参数:
$context = stream_context_create(['s3' => ['ContentType' => 'text/markdown', 'ACL' => 'public-read']]); file_put_contents('s3://my-bucket/doc.md', '# 标题', 0, $context);
注意 S3 不支持追加写,'a' 模式不可用;写入在 fclose 时才真正提交。
目录同步
// 上传整个目录 $s3->uploadDirectory('/var/www/assets', 'my-bucket', 'assets'); // 下载到本地目录 $s3->downloadBucket('/var/www/assets', 'my-bucket', 'assets'); // 带进度输出与并发控制 $s3->uploadDirectory('/var/www/assets', 'my-bucket', 'assets', [ 'concurrency' => 10, 'debug' => true, ]);
异步与并发
每个操作都有 Async 变体,返回 Promise。多个未 wait() 的请求是真并发:
$promises = []; foreach ($files as $i => $file) { $promises[] = $s3->putObjectAsync([ 'Bucket' => 'b', 'Key' => "f{$i}", 'SourceFile' => $file, ]); } foreach ($promises as $promise) { $promise->wait(); }
支持链式处理:
$size = $s3->getObjectAsync(['Bucket' => 'b', 'Key' => 'a.txt']) ->then(fn($result) => strlen((string) $result['Body'])) ->otherwise(fn($e) => -1) ->wait();
存在性判断与等待
$s3->doesBucketExist('my-bucket'); $s3->doesObjectExist('my-bucket', 'a.txt'); // 第二个参数为 true 时,403(有桶但无权限)也算存在 $s3->doesBucketExist('my-bucket', true); // 轮询等待 $s3->waitUntil('ObjectExists', ['Bucket' => 'b', 'Key' => 'a.txt']); $s3->waitUntil('BucketExists', ['Bucket' => 'b']);
其他操作
116 个 S3 操作都可以直接调用,方法名是操作名首字母小写:
$s3->createBucket(['Bucket' => 'new-bucket']); $s3->putBucketVersioning(['Bucket' => 'b', 'VersioningConfiguration' => ['Status' => 'Enabled']]); $s3->putBucketPolicy(['Bucket' => 'b', 'Policy' => json_encode($policy)]); $s3->putBucketCors(['Bucket' => 'b', 'CORSConfiguration' => ['CORSRules' => [...]]]); $s3->putObjectTagging(['Bucket' => 'b', 'Key' => 'a.txt', 'Tagging' => ['TagSet' => [...]]]); $s3->listObjectVersions(['Bucket' => 'b']);
参数名与 AWS S3 API 文档 及 aws-sdk-php 完全一致。具体某个自建服务支持哪些操作,取决于它自己的实现程度。
错误处理
use MinS3\Exception\S3Exception; use MinS3\Exception\ConnectException; try { $s3->getObject(['Bucket' => 'b', 'Key' => 'a.txt']); } catch (S3Exception $e) { $e->getAwsErrorCode(); // NoSuchKey、AccessDenied ... $e->getStatusCode(); // 404 $e->getAwsErrorMessage(); // 服务端返回的描述 $e->getAwsRequestId(); // 排查时提供给运维 $e->getCommandName(); // GetObject } catch (ConnectException $e) { // 网络层失败:DNS、连接超时、TLS 握手 $e->getRequest(); }
getAwsErrorCode() 等方法名沿用 aws-sdk-php,便于迁移;也提供了
getErrorCode() / getErrorMessage() 短别名。
自动重试:连接错误、5xx、429 以及 SlowDown 等限流错误会自动重试
(默认 3 次,指数退避加抖动)。4xx 不重试。
'retries' => 5, // 全局 $s3->getObject([..., '@retries' => 0]); // 单次请求
配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
endpoint |
必填 | 服务地址,如 http://127.0.0.1:9000 |
region |
us-east-1 |
参与签名,不能为空 |
credentials |
读环境变量 | 数组、Credentials 实例或返回它的可调用对象 |
use_path_style_endpoint |
true |
路径式寻址。与 aws-sdk-php 默认值相反 |
retries |
3 |
重试次数 |
http |
[] |
传输层选项,见下表 |
checksum_calculation |
when_required |
when_required / when_supported |
checksum_algorithm |
md5 |
md5 / crc32 / sha1 / sha256 |
handler |
curl | 自定义传输实现,测试时可注入桩件 |
http 支持的选项:
| 选项 | 说明 |
|---|---|
timeout |
整体超时秒数,0 为不限 |
connect_timeout |
连接超时秒数,默认 10 |
verify |
true / false / CA 文件或目录路径 |
proxy |
代理地址 |
cert / ssl_key |
客户端证书与私钥,可写成 [路径, 密码] |
force_ip_resolve |
'v4' / 'v6' |
curl |
直接透传的 curl 选项,优先级最高 |
单次请求可以用 @http 覆盖:
$s3->getObject([ 'Bucket' => 'b', 'Key' => 'big.bin', '@http' => ['timeout' => 300, 'sink' => '/path/big.bin'], ]);
凭证也可以动态提供(临时凭证轮换场景),过期后会自动重新获取:
'credentials' => function () { $token = fetchTokenFromSomewhere(); return new MinS3\Credentials($token['key'], $token['secret'], $token['token'], time() + 3600); },
未显式配置时会读 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
(也认 S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY)。
与 aws-sdk-php 的差异
调用方式一致,以下几处行为有意做了调整:
1. use_path_style_endpoint 默认 true
自建服务多数不支持虚拟主机式寻址。原 SDK 默认 false。
2. 校验和默认发 Content-MD5
新版 aws-sdk-php 默认对所有支持的操作发 x-amz-checksum-crc32,
但相当一部分自建 S3(旧版 MinIO、Ceph RGW)不认这个头,DeleteObjects
之类会直接失败。Content-MD5 是所有 S3 兼容实现的最大公约数。
需要与 AWS 行为一致时:
'checksum_calculation' => 'when_supported', 'checksum_algorithm' => 'crc32',
3. 不支持的功能
去掉了 AWS 专有的部分:AWS 区域端点自动解析、IMDS / ECS / SSO / AssumeRole 凭证链、接入点与 Outposts ARN、S3 Express 会话鉴权、传输加速、双栈与 FIPS 端点、 客户端加密(SSE-C 这类服务端加密仍然可用)、SigV4a 非对称签名。
4. 方法签名的小调整
doesBucketExist($bucket, $accept403 = false) 与
doesObjectExist($bucket, $key, $includeDeleteMarkers = false, $options = [])
合并了原 SDK 的 V1 / V2 两个版本,行为等同 V2。
验证情况
与 aws-sdk-php 的对拍,以及端到端测试的结果:
| 验证项 | 结果 |
|---|---|
| 签名算法对拍(相同输入 → 相同 Authorization) | 19/19 一致 |
| 预签名 URL 对拍(固定时间,逐字节比对) | 16/16 一致 |
| 请求构造对拍(43 个操作 × 2 种寻址) | 86/86 一致 |
| 域名端点的虚拟主机式寻址对拍 | 8/8 一致 |
| 端到端功能测试(mock 服务端) | 73/73 通过 |
| 真实 HTTP 传输测试(curl 连本地服务器) | 18/18 通过 |
| 全部 116 个操作的模型解析与序列化 | 116/116 通过 |
对拍覆盖了中文 key、含空格与 + & = ? 的 key、query 参数排序、
临时凭证、虚拟主机式与路径式寻址、含点桶名退回路径式等边界情况。
流式上传实测:2 MB 文件上传过程内存增长 0.0 MB。
预签名 URL 实测:用裸 curl(不经过本包)请求返回 200 并取回正确内容。
自己跑测试
git clone https://github.com/hlkaiyan/min-s3.git cd min-s3 php tests/run.php # 或 composer test
不需要 composer install——测试本身也不依赖 PHPUnit 之类的第三方包。
这不是偷懒:测试只通过 autoload.php 加载本包,任何一处漏用了第三方类,
测试都会因为找不到类而直接失败,零依赖这件事因此是被持续验证的,
而不是靠人工承诺。
各组测试的分工:
| 文件 | 内容 | 项数 |
|---|---|---|
tests/dependencies.php |
反射遍历全部类型引用,确认没有指向包外的 | 1342 处 |
tests/functional.php |
端到端功能,跑在内存版 S3 服务端上 | 73 |
tests/edgecases.php |
边界与失效场景,每条对应一个曾真实存在的 bug | 20 |
tests/readme.php |
把本文档里的每段示例执行一遍 | 36 |
tests/compat.php |
与 aws-sdk-php 逐字节对拍 | 129 |
tests/transport.php |
真实 curl 传输,自动起停本机测试服务器 | 18 |
tests/run.php 会依次跑完并汇总。真实传输那组的服务器只监听
127.0.0.1,跑完必定回收,不访问外网。
对拍需要额外准备
tests/compat.php 要拿 aws-sdk-php 作参照,没装时自动跳过(不算失败)。
想跑它:
composer require --dev aws/aws-sdk-php
刻意没写进 composer.json 的 require-dev——本包主打零依赖,不该让
每个开发者为一个对拍脚本装 50 MB 的 SDK。装了就会被自动发现。
也可以不装,直接用 v1.0.0 Release 附件里的固定快照(composer.zip,
解压到 vendor/ 即可),基准不会随上游更新漂移,步骤见
docs/release.md。
它验证的是包内其他测试做不到的事:其余测试只能确认 min-s3 自洽 (请求发得出去、响应解析得回来),确认不了「与官方 SDK 行为一致」。 改动签名、序列化、解析、寻址或 URL 编码之后,应该跑一次。
CI 在 PHP 8.1 / 8.2 / 8.3 / 8.4 上跑,另外覆盖 Windows 与 macOS。
敏感信息扫描
参与开发的话建议启用 pre-commit 钩子,提交前自动扫描暂存内容:
composer hooks:install # 等价于 git config core.hooksPath .githooks
这只改本仓库的配置,不动你的全局 git 设置。之后每次 git commit
都会扫一遍将要提交的内容,发现密钥、token、私钥、内嵌凭据的 URL
就中止提交。确认是误报时把值加进 tests/audit.php 的 $allowlist
并注明理由;个别情况要跳过用 git commit --no-verify。
手动全量扫描:
composer scan # 或 php tests/audit.php
(命令名不叫 audit——那是 Composer 2.4+ 的内置命令,用于检查依赖的
安全公告,会把自定义脚本盖掉。)
CI 里还会跑一遍 gitleaks,规则库比自带脚本大得多。两者定位不同:
tests/audit.php 零依赖、任何人 clone 下来就能跑,做本地即时拦截;
gitleaks 在 CI 上做更全面的复查。
这套配置(三层防护、CI 矩阵、以及实际踩过的坑)整理在 docs/ci-setup.md,与语言无关的部分可以直接 搬到别的项目。
发布新版本
composer 只认 tag:往 main 推多少提交都不影响 composer require 装到的
内容,不打 tag 等于没发布。另外 .gitattributes 的 export-ignore 会把
tests/、docs/ 等挡在分发包外,所以 vendor/hlkaiyan/min-s3/ 本来就比
仓库少一批目录,不是装错了。
版本号判定、打标签、Packagist 同步、Release 附件管理、发布前检查清单, 整理在 docs/release.md。
许可
Apache-2.0。API 模型文件与签名、序列化逻辑来自 aws/aws-sdk-php(同为 Apache-2.0)。