yunadmin / yunstorage
无 SDK 依赖的多云对象存储类库,支持阿里云 OSS (V4)、腾讯云 COS (V5)、七牛云 Kodo、百度云 BOS (V2)
Requires
- php: ^8.0
- ext-curl: *
- ext-json: *
- yunadmin/http-client: ^1.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
一个轻量级、无第三方 SDK 依赖的多云对象存储扩展,原生 PHP 实现,支持阿里云 OSS、腾讯云 COS、七牛云 Kodo、百度云 BOS 四大厂商。
目录结构
yunstorage/ (yunadmin/yunstorage)
├── composer.json Composer 包定义
├── README.md 本文档
└── src/
├── Storage.php 工厂入口, 通过 Storage::make() 创建 Driver 实例
├── DriverInterface.php 驱动契约接口 (upload / fetch / delete / getObjectUrl / getError)
├── AbstractDriver.php 抽象基类, 封装配置访问 / HTTP 客户端 (yunadmin/http-client) / MIME 猜测 / 错误解析
├── Signer.php 签名工具集 (base64url / hmacSha1 / hmacSha256 / urlEncode / encodePath)
└── drivers/
├── AliyunDriver.php 阿里云 OSS (V4 签名 OSS4-HMAC-SHA256)
├── QcloudDriver.php 腾讯云 COS (V5 签名)
├── QiniuDriver.php 七牛云 Kodo (上传凭证 + QBox 管理凭证)
└── BaiduDriver.php 百度云 BOS (BCE V2 签名)
命名空间映射(PSR-4,根 Yunadmin\YunStorage\ → src/,与 yunadmin/http-client 风格一致):
| 文件 | 命名空间 |
|---|---|
src/Storage.php |
Yunadmin\YunStorage\Storage |
src/AbstractDriver.php |
Yunadmin\YunStorage\AbstractDriver |
src/DriverInterface.php |
Yunadmin\YunStorage\DriverInterface |
src/Signer.php |
Yunadmin\YunStorage\Signer |
src/drivers/AliyunDriver.php |
Yunadmin\YunStorage\drivers\AliyunDriver |
环境要求
- PHP >= 8.0
- 必装扩展:
ext-curl、ext-json - 依赖:
yunadmin/http-client
安装
composer require yunadmin/yunstorage
快速开始
use Yunadmin\YunStorage\Storage; // 1. 准备配置 (实际项目从数据库或 config 读取) $config = [ 'bucket' => 'my-bucket', 'access_key' => 'your-access-key', 'secret_key' => 'your-secret-key', 'domain' => 'oss-cn-hangzhou.aliyuncs.com', ]; // 2. 创建 Driver 实例 $storage = Storage::make('aliyun', $config); // 3. 上传本地文件 if ($storage->upload('/tmp/avatar.jpg', 'images/avatar.jpg')) { $url = $storage->getObjectUrl('images/avatar.jpg'); echo "上传成功, 访问地址: {$url}"; } else { echo "上传失败: " . $storage->getError(); } // 4. 抓取远程 URL 资源到对象存储 $storage->fetch('https://example.com/photo.png', 'images/photo.png'); // 5. 删除对象 $storage->delete('images/avatar.jpg');
API 文档
Yunadmin\YunStorage\Storage 工厂类
Storage::make(string $name, array $config): DriverInterface
创建驱动实例。
| 参数 | 类型 | 说明 |
|---|---|---|
$name |
string | 驱动名:aliyun / qcloud / qiniu / baidu |
$config |
array | 驱动配置(详见各厂商配置说明) |
异常:传入不支持的驱动名时抛 InvalidArgumentException。
Storage::extend(string $name, string $class): void
注册自定义驱动,用于扩展第三方厂商。
Storage::extend('huawei', \app\extend\ObsDriver::class); $storage = Storage::make('huawei', $config);
Storage::supportedDrivers(): array
返回所有已注册的驱动名列表。
DriverInterface 接口方法
所有 Driver 实例都实现以下 5 个方法:
upload(string $localPath, string $key): bool
上传本地文件到对象存储。
$localPath:本地文件绝对路径$key:对象存储中的 key(含目录,如images/2024/avatar.jpg)- 内部使用 cURL 流式上传(
CURLOPT_INFILE或CURLFile),大文件不占内存 - 成功返回
true,失败返回false并可通过getError()获取错误信息
fetch(string $url, ?string $key): bool
抓取远程 URL 资源或上传本地文件到对象存储。
$url:http(s)://远程地址 或 本地绝对路径$key:对象存储中的 key(为null时由云端自动命名,仅部分厂商支持)- 行为:
- 当
$url是http://或https://开头时,抓取远程资源 - 当
$url是本地路径时,等价于upload()
- 当
delete(string $key): bool
删除对象存储中的指定对象。
getObjectUrl(string $key): string
获取对象的访问 URL。
- 优先使用配置的
custom_domain拼接 - 否则按厂商默认域名拼接
- 返回完整的 URL 字符串
getError(): string
获取最近一次操作的错误信息(在 upload / fetch / delete 返回 false 后调用)。
各厂商配置说明
阿里云 OSS
$config = [ 'bucket' => 'your-bucket', // Bucket 名 'access_key' => 'your-access-key-id', // AccessKey ID 'secret_key' => 'your-access-key-secret', // AccessKey Secret 'domain' => 'oss-cn-hangzhou.aliyuncs.com', // Endpoint (不含 bucket 前缀, V4 会自动从中解析 region) 'region' => '', // 可选, 显式指定 (如 cn-hangzhou), 默认从 endpoint 解析 'custom_domain' => '', // 可选, 如 https://cdn.example.com ];
区域 Endpoint 参考:
| 区域 | Endpoint | Region (V4 签名用) |
|---|---|---|
| 华东1(杭州) | oss-cn-hangzhou.aliyuncs.com |
cn-hangzhou |
| 华东2(上海) | oss-cn-shanghai.aliyuncs.com |
cn-shanghai |
| 华北1(青岛) | oss-cn-qingdao.aliyuncs.com |
cn-qingdao |
| 华北2(北京) | oss-cn-beijing.aliyuncs.com |
cn-beijing |
| 华南1(深圳) | oss-cn-shenzhen.aliyuncs.com |
cn-shenzhen |
| 香港 | oss-cn-hongkong.aliyuncs.com |
cn-hongkong |
签名方式:V4 签名(OSS4-HMAC-SHA256),2025 年 9 月起对新 Bucket 强制要求,本扩展已默认采用 V4。
V4 关键点:
- 派生密钥链:
SigningKey = HMAC-SHA256("aliyun_v4_request", HMAC-SHA256("oss", HMAC-SHA256(region, HMAC-SHA256("aliyun_v4"+date, SK)))) - 必签 header:
host/x-oss-date/x-oss-content-sha256 - 流式上传用
UNSIGNED-PAYLOAD(避免双重读取文件),字符串 body 用实际sha256hash region从domain自动解析(如oss-cn-hangzhou.aliyuncs.com→cn-hangzhou),也可显式配置
腾讯云 COS
$config = [ 'bucket' => 'your-bucket-1250000000', // Bucket 名 (含 APPID 后缀) 'region' => 'ap-guangzhou', // 区域代号 'access_key' => 'your-secret-id', // SecretId 'secret_key' => 'your-secret-key', // SecretKey 'domain' => '', // 可选, 留空则自动拼接 'custom_domain' => '', // 可选, 如 https://cdn.example.com ];
区域 Region 参考:
| 区域 | Region |
|---|---|
| 广州 | ap-guangzhou |
| 上海 | ap-shanghai |
| 北京 | ap-beijing |
| 成都 | ap-chengdu |
| 香港 | ap-hongkong |
域名拼接规则(domain 留空时):{bucket}.cos.{region}.myqcloud.com
签名方式:V5 签名,q-sign-algorithm=sha1,SignKey 和 Signature 均用 hex 字符串
七牛云 Kodo
$config = [ 'bucket' => 'your-bucket', // Bucket 名 'access_key' => 'your-access-key', // AK 'secret_key' => 'your-secret-key', // SK 'region' => 'z0', // 区域: z0/z1/z2/na0/as0 'custom_domain' => 'https://cdn.example.com', // 访问域名 (必须配置) ];
区域 Region 参考:
| 区域 | Region |
|---|---|
| 华东 | z0 |
| 华北 | z1 |
| 华南 | z2 |
| 北美 | na0 |
| 东南亚 | as0 |
接口域名(自动按 region 选择):
| 接口 | 域名(z0 示例) |
|---|---|
| 上传 | upload.qiniup.com |
| 删除 | rs.qiniu.com |
| 抓取 | iovip.qbox.me |
签名方式:
- 上传凭证:
token = AK:base64url(hmac_sha1(SK, encodedPutPolicy)):encodedPutPolicy - 管理凭证:
QBox AK:base64url(hmac_sha1(SK, "path?query\nbody"))
百度云 BOS
$config = [ 'bucket' => 'your-bucket', // Bucket 名 'region' => 'gz', // 区域: gz/bj/su 'access_key' => 'your-access-key', // AK 'secret_key' => 'your-secret-key', // SK 'domain' => '', // 可选, 留空则自动拼接 'custom_domain' => '', // 可选, 如 https://cdn.example.com ];
区域 Region 参考:
| 区域 | Region |
|---|---|
| 广州 | gz |
| 北京 | bj |
| 苏州 | su |
域名拼接规则(domain 留空时):{bucket}.{region}.bcebos.com
签名方式:BCE V2 签名(2019-04 起百度推荐的最新协议,性能更高、密钥更安全)。
V2 与 V1 主要差异:
- 去掉
timestamp(完整时间)和expiration(有效期) - 增加
date(短日期yyyymmdd)、region、service,融入派生密钥链
V2 算法:
authStringPrefix = "bce-auth-v2/{AK}/{date}/{region}/{service}"
SigningKey = HMAC-SHA256-HEX(authStringPrefix, SK) // 返回 hex 字符串
CanonicalRequest = METHOD\n + CanonicalURI\n + CanonicalQuery\n + CanonicalHeaders
Signature = HMAC-SHA256-HEX(CanonicalRequest, SigningKey) // 返回 hex 字符串
Authorization = "{authStringPrefix}/{signedHeaders}/{signature}"
关键点:
- signingKey 用 hex 字符串(非二进制)作为下一步 HMAC 的 key(BCE 规范)
- 必签 header:
host+x-bce-date(避免Content-Length/Content-Type不匹配) region从配置读取,默认bj
接入到 ThinkPHP 项目
方式一:直接使用(推荐)
在业务逻辑中直接调用:
use Yunadmin\YunStorage\Storage; use think\facade\Db; // 从数据库读取存储配置 $channel = Db::name('storage')->where('status', 1)->find(); $config = json_decode($channel['params'], true) + [ 'bucket' => $channel['bucket'], 'access_key' => $channel['access_key'], 'secret_key' => $channel['secret_key'], 'domain' => $channel['domain'], 'region' => $channel['region'] ?? '', ]; $storage = Storage::make($channel['channel'], $config); $storage->upload($file->getPathname(), 'uploads/' . $file->hashName());
方式二:封装到引擎类
适配原有 ThinkPHP app/common/service/storage/engine/ 目录的引擎类风格:
namespace app\common\service\storage\engine; use Yunadmin\YunStorage\Storage; class Aliyun extends Server { private $storage; public function __construct($config) { parent::__construct($config); $this->storage = Storage::make('aliyun', $config); } public function upload($save_dir = '') { $key = $save_dir . '/' . $this->getFileName(); if ($this->storage->upload($this->getRealPath(), $key)) { $this->setFileUrl($this->storage->getObjectUrl($key)); return true; } $this->error = $this->storage->getError(); return false; } public function delete($fileName) { return $this->storage->delete($fileName); } public function fetch($url, $key = null) { return $this->storage->fetch($url, $key); } }
自定义 Driver 扩展
实现一个新的对象存储厂商只需 3 步:
步骤 1:实现 DriverInterface
namespace app\extend; use Yunadmin\YunStorage\AbstractDriver; use Yunadmin\YunStorage\Signer; class HuaweiObsDriver extends AbstractDriver { public function upload(string $localPath, string $key): bool { // 1. 校验参数 if ($key === '' || !is_file($localPath)) { $this->setError($key === '' ? 'key 不能为空' : "文件不存在: {$localPath}"); return false; } // 2. 构造请求 URL $host = $this->getConfig('bucket') . '.' . $this->getConfig('domain'); $url = 'https://' . $host . '/' . Signer::encodePath($key); // 3. 签名 $headers = [ 'Content-Type' => $this->guessMime($localPath), 'Content-Length' => filesize($localPath), ]; $this->signRequest('PUT', $key, $host, $headers); // 4. 流式上传 list($resp, $code) = $this->httpUpload($url, $localPath, [], '', $headers); return $code >= 200 && $code < 300 ?: ($this->setError($this->parseError($resp) ?: "HTTP {$code}") ? false : false); } public function fetch(string $url, ?string $key): bool { /* ... */ } public function delete(string $key): bool { /* ... */ } private function signRequest(string $method, string $key, string $host, array &$headers): void { // 实现厂商的签名算法 // ... $headers['Authorization'] = 'OBS ' . $this->getConfig('access_key') . ':' . $signature; } }
步骤 2:注册驱动
use Yunadmin\YunStorage\Storage; Storage::extend('huawei', \app\extend\HuaweiObsDriver::class);
步骤 3:使用
$storage = Storage::make('huawei', $config); $storage->upload('/tmp/file.jpg', 'images/file.jpg');
设计说明
为什么不使用官方 SDK?
| 对比项 | 官方 SDK | YunStorage |
|---|---|---|
| 包体积 | 阿里 SDK ~3MB + 腾讯 SDK ~2MB + 七牛 SDK ~1MB + 百度 SDK ~2MB = ~8MB | ~30KB |
| 依赖 | 各厂商依赖 Guzzle/PSR 等库,版本冲突风险 | 零依赖,仅 PHP 原生 |
| 功能 | 大而全(分片上传/生命周期/桶管理等) | 精简核心(上传/抓取/删除/URL) |
| 学习成本 | 各家 API 风格不一致 | 统一接口,切换厂商零成本 |
| 维护 | 升级 SDK 可能破坏兼容 | 签名算法稳定,几乎无需升级 |
大文件流式上传
所有 Driver 上传时通过 AbstractDriver::httpUpload() 委托 yunadmin/http-client,底层走 cURL 的 CURLOPT_INFILE(raw PUT)或 CURLFile(multipart),按 chunk 读取文件,内存占用恒为 O(1),上传 1GB 文件与 1KB 文件内存占用相同。
错误处理规范
所有方法失败时返回 false,并可通过 getError() 获取人类可读的错误信息:
if (!$storage->upload($file, $key)) { throw new \RuntimeException('上传失败: ' . $storage->getError()); }
错误信息来源优先级:
- 云厂商返回的错误描述(自动解析 XML 或 JSON)
- 本地校验错误(如 key 为空、文件不存在)
- HTTP 状态码兜底(如
OSS PUT 失败, HTTP 403)
签名算法正确性
各厂商签名均按官方文档实现,关键点:
| 厂商 | 算法 | 关键细节 |
|---|---|---|
| 阿里 OSS | V4: HMAC-SHA256 + Hex | 派生密钥链 4 步;必签 host/x-oss-date/x-oss-content-sha256;流式上传用 UNSIGNED-PAYLOAD;region 从 endpoint 自动解析 |
| 腾讯 COS | HMAC-SHA1 + Hex | SignKey 和 Signature 均用 hex 字符串(非 base64) |
| 百度 BOS | V2: HMAC-SHA256 + Hex | 派生密钥链含 date/region/service;signingKey 用 hex 字符串(非二进制)作为下一步 HMAC 的 key;只签 host + x-bce-date |
| 七牛 Kodo | HMAC-SHA1 + Base64URL | 上传凭证 token 三段式,管理凭证 QBox 前缀 |
常见问题
Q1:上传时报 403 SignatureDoesNotMatch
原因:系统时间与云服务器时间偏差过大(>15 分钟)。
解决:检查服务器时间是否已通过 NTP 同步:
# Linux ntpdate ntp.aliyun.com # Windows (PowerShell) w32tm /resync
Q2:腾讯 COS 报 "q-signature 不匹配"
检查:region 配置是否与 bucket 实际区域一致,bucket 名是否包含 APPID 后缀(如 mybucket-1250000000)。
Q3:七牛上传成功但 getObjectUrl 返回的 URL 无法访问
原因:七牛要求必须配置 custom_domain 或 domain(绑定到 bucket 的 CDN 或测试域名)。
解决:在七牛控制台绑定加速域名后填入 custom_domain。
Q4:百度 BOS 删除报 403
原因:BCE 签名只对 host 和 x-bce-date 两个 header 签名,若 HTTP 客户端自动添加了 Content-Length 不匹配的 header 会被拒绝。
解决:本扩展已规避此问题,如仍报错请检查 cURL 是否被全局拦截器修改了 header。
Q5:如何切换存储厂商而不改业务代码?
业务层只依赖 DriverInterface,切换厂商只需改 Storage::make() 第一个参数:
// 切换前 $storage = Storage::make('aliyun', $aliyunConfig); // 切换后 (业务代码完全不变) $storage = Storage::make('qcloud', $qcloudConfig); $storage->upload($file, $key); // 同样的调用
Q6:如何获取上传后的文件大小或 hash?
本扩展聚焦核心功能,未返回上传响应详情。如需获取,可在 Driver 中扩展 upload() 方法的返回值,或通过 getObjectUrl() 拼接的 URL 发 HEAD 请求查询。
版本兼容
- PHP 7.2 / 7.3 / 7.4 / 8.0 / 8.1 / 8.2 / 8.3 / 8.4 全部支持
- ThinkPHP 5.1 / 6.0 / 6.1 / 8.0 均可直接放入
extend/目录使用 - 不依赖任何 Composer 包,可与现有项目共存
License
MIT