Search by

yunadmin / yunstorage

yunadmin

无 SDK 依赖的多云对象存储类库,支持阿里云 OSS (V4)、腾讯云 COS (V5)、七牛云 Kodo、百度云 BOS (V2)

Package info

github.com/haoziliao/yunstorage

pkg:composer/yunadmin/yunstorage

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-25 02:22 UTC

This package is auto-updated.

Last update: 2026-09-25 02:46:18 UTC


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 用实际 sha256 hash
  • 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());
}

错误信息来源优先级:

  1. 云厂商返回的错误描述(自动解析 XML 或 JSON)
  2. 本地校验错误(如 key 为空、文件不存在)
  3. 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