yunadmin / http-client
零依赖轻量级 HTTP 客户端类库,支持 cURL/Stream 双引擎、中间件、重定向、重试、JSON/FORM 表单、文件上传。
Requires
- php: ^8.0
- ext-curl: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
仓库:https://github.com/yunadmin-cn/http-client
安装:composer require yunadmin/http-client
零依赖、轻量级的 PHP HTTP 客户端。仅依赖 PHP ≥ 8.0 的原生扩展(curl / json),无任何 Composer 第三方包。
特性
| 能力 | 说明 |
|---|---|
| 双引擎 | cURL (CurlHandler) / Socket Stream (StreamHandler) 自动选择 |
| 异步并发 | CurlMultiHandler 基于 cURL Multi;Future、requestAsync()、pool()(并发度控制) |
| 流式上传 | 按 8KB chunk 读取 body,未知长度自动走 Transfer-Encoding: chunked,大文件友好 |
| 中间件系统 | HandlerStack 管道;内置重定向、HTTP 错误抛出、指数退避重试、历史记录 |
| Cookie 会话 | CookieJar + 标准 Set-Cookie 解析(支持引号内嵌分号) |
| 可测试 | MockHandler 响应队列 + HistoryMiddleware 历史记录,不发真实请求即可测业务 |
| 常用选项对齐 | decode_content、force_ip_resolve、timeout、connect_timeout、verify、cert/ssl_key、proxy、auth、on_stats、sink、json/form_params/multipart/body、query、base_uri、curl |
| PSR-7 风格对象 | Request / Response / Uri / Stream / StreamInterface |
要求:php ^8.0、ext-curl、ext-json(已在 composer.json 声明)。
安装
composer require yunadmin/http-client
或手动引入:只需注册 PSR-4 前缀 Yunadmin\HttpClient\ 指向 src/,并 require 'src/functions.php'。
1. 快速开始
use Yunadmin\HttpClient\Client; $client = new Client([ 'base_uri' => 'https://api.example.com/v1/', 'timeout' => 5.0, // 总超时(秒,支持小数) 'headers' => ['Accept' => 'application/json'], ]); // GET + query $resp = $client->get('users/123', ['query' => ['include' => 'roles,perms']]); echo $resp->getStatusCode(); // 200 echo $resp->getHeaderLine('Content-Type'); // application/json; charset=utf-8 // 两种取 body 方式 $raw = (string)$resp->getBody(); // 原始字符串 $data = $resp->getJsonBody(); // stdClass 或 array(JSON)
2. HTTP 方法
便捷方法:get / head / post / put / patch / delete / options,每个都接受 (uri, options)。
$client = new Client(); $client->head('https://example.com'); $client->post('https://api.example.com/items', ['json' => ['name'=>'x']]); $client->put('https://api.example.com/items/1', ['json' => ['name'=>'y']]); $client->patch('https://api.example.com/items/1',['json' => ['name'=>'z']]); $client->delete('https://api.example.com/items/1'); $client->options('https://api.example.com/');
对应异步版:getAsync / postAsync / putAsync / patchAsync / deleteAsync / requestAsync,返回 Future(见第 6 节)。
3. 请求体:JSON / 表单 / 文件上传
3.1 JSON(json)
$resp = $client->post('https://api.example.com/articles', [ 'json' => ['title' => '你好', 'tags' => ['php','http']], 'headers' => ['X-Trace' => 'abc'], ]); // 自动:Content-Type: application/json + json_encode
3.2 URL 编码表单(form_params)
$client->post('https://sso.example.com/login', [ 'form_params' => [ 'username' => 'admin', 'password' => '123456', 'remember' => 1, ], ]); // Content-Type: application/x-www-form-urlencoded
3.3 multipart 表单 & 文件上传(multipart)
$client->post('https://cdn.example.com/upload', [ 'multipart' => [ [ 'name' => 'avatar', 'contents' => fopen(__DIR__ . '/face.png', 'r'), 'filename' => 'face.png', 'headers' => ['Content-Type' => 'image/png'], ], [ 'name' => 'album', 'contents' => fopen(__DIR__ . '/album.zip', 'r'), 'filename' => 'album.zip', ], [ 'name' => 'desc', 'contents' => '我的头像 & 相册', ], ], ]);
说明:
contents支持 string / resource / StreamInterface 三种。filename省略时,若contents是文件资源会自动用stream_get_meta_data()['uri'],否则不生成filename=。- 会自动生成 boundary,
Content-Type: multipart/form-data; boundary=...。 - 大文件按 8KB chunk 读取,不会一次性进内存。
3.4 原始 body(body)
// 字符串 $client->post('https://api.example.com/raw', [ 'body' => '任何原始字节...', 'headers' => ['Content-Type' => 'text/plain'], ]); // 大文件 — 使用 LazyOpenStream,按需打开、流式发送 use function Yunadmin\HttpClient\lazy_open_stream; $body = lazy_open_stream('/data/huge.zip', 'r'); $client->post('https://cdn.example.com/upload', [ 'body' => $body, 'headers' => ['Content-Type' => 'application/zip'], ]);
getSize() != null→ 自动设置Content-LengthgetSize() == null→ 自动使用Transfer-Encoding: chunked
4. URI 处理
4.1 base_uri + 相对 URI
$client = new Client(['base_uri' => 'https://api.example.com/v1/']); // 注意 base_uri 以 / 结尾,否则把相对 uri 当作 path 拼接 $client->get('users'); // → /v1/users $client->get('/users'); // → /users (以 / 开头会覆盖 path) $client->get('https://other.com/x'); // → 完整 URI,忽略 base_uri
4.2 query 选项
$client->get('https://api.example.com/search', [ 'query' => ['q' => 'php 8', 'page' => 1, 'tags' => ['a','b']], ]); // 自动 http_build_query,也支持自己传字符串:'q=php+8&page=1'
4.3 Uri 类(PSR-7 风格)
use Yunadmin\HttpClient\Uri; $u = new Uri('https://user:pass@api.example.com:8443/v1?q=1#frag'); echo $u->getScheme(); // https echo $u->getHost(); // api.example.com echo $u->getPort(); // 8443 echo $u->getPath(); // /v1 echo $u->getQuery(); // q=1 echo $u->getUserInfo(); // user:pass echo (string)$u->withScheme('http')->withPort(null)->withQuery('x=2'); // → http://user:pass@api.example.com/v1?x=2#frag
5. 认证、代理、超时、TLS
$client = new Client([ // 1. Basic / Digest / Bearer / 自定义 'auth' => ['user', 'pass'], // Basic //'auth' => ['user', 'pass', 'digest'], // Digest(cURL 支持) //'auth' => ['xxxxxxxx-token', '', 'bearer'], // Bearer //'auth' => 'Basic dXNlcjpwYXNz', // 直接写字符串 // 2. 超时 'timeout' => 10.0, // 总超时秒(支持小数) 'connect_timeout' => 2.0, // 连接超时秒 // 3. 强制 IP 版本 //'force_ip_resolve' => 'v4', // 'v4' | 'v6' | null // 4. TLS 'verify' => true, // 默认 true,校验证书 //'verify' => '/etc/ssl/certs/ca-bundle.crt', // CA 路径 //'cert' => '/path/client.crt', // 客户端证书 //'ssl_key' => '/path/client.key', // 客户端私钥 // 5. 代理 //'proxy' => 'http://127.0.0.1:8080', // 单个 // 或按 scheme 分别设置 //'proxy' => [ // 'http' => 'http://10.0.0.1:8080', // 'https' => 'http://10.0.0.1:8443', // 'no' => ['.intranet.local', 'localhost'], //], // 6. 解压缩 'decode_content' => true, // 默认 true:加 Accept-Encoding,并自动 gzip/deflate 解响应体 ]);
6. 异步 & 并发(cURL Multi)
6.1 getAsync / postAsync / requestAsync → 返回 Future
$client = new Client(); $f1 = $client->getAsync('https://example.com/a'); $f2 = $client->getAsync('https://example.com/b'); $f3 = $client->postAsync('https://example.com/c', ['json' => ['k' => 'v']]); // 阻塞等到各自完成(内部 tick Multi,三条是并发的) $r1 = $f1->wait(); $r2 = $f2->wait(); $r3 = $f3->wait();
6.2 Future 链式回调
$client ->getAsync('https://api.example.com/items/1') ->then(static fn($resp) => $resp->getJsonBody()) // 成功:转换 ->then(static fn($data) => process($data)) // 继续转 ->otherwise(static fn(\Throwable $e) => log($e)) // 失败兜底 ->wait();
链式回调会在
wait()时按顺序执行(轻量实现,不依赖 Promise/A+ 库)。
6.3 pool() — 大列表 + 并发度控制
$urls = [ 'https://example.com/p1', 'https://example.com/p2', 'https://example.com/p3', // ... 可几百上千条 ]; $requests = []; foreach ($urls as $u) { $requests[] = ['GET', $u]; } $client->pool($requests, [ 'concurrency' => 10, // 同时最多 10 条 'fulfilled' => static function ($resp, $index) use ($urls) { echo "[OK] #{$index} {$urls[$index]} => {$resp->getStatusCode()}\n"; }, 'rejected' => static function (\Throwable $e, $index) use ($urls) { echo "[ERR] #{$index} {$urls[$index]} => {$e->getMessage()}\n"; }, ]);
$requests 每一项支持:
['GET', $uri]['POST', $uri, ['json' => [...]]]- 直接传
RequestInterface实例
7. 重定向、HTTP 错误、重试
7.1 重定向(allow_redirects)
$client = new Client([ 'allow_redirects' => [ 'max' => 5, // 最大重定向次数 'strict' => true, // 严格模式:302/303 POST → 改 GET;false 保持原方法 'keep_body' => false, // 重定向后是否保留 body ], ]); // 或简单关闭: // $client = new Client(['allow_redirects' => false]);
内置 RedirectMiddleware;超过 max 抛 TooManyRedirectsException。
7.2 HTTP 错误(http_errors)
默认 4xx / 5xx 会抛异常:
use Yunadmin\HttpClient\Exception\ClientException; use Yunadmin\HttpClient\Exception\ServerException; use Yunadmin\HttpClient\Exception\RequestException; try { $client->get('https://api.example.com/404-thing'); } catch (ClientException $e) { // 4xx echo "4xx: " . $e->getResponse()?->getStatusCode(); } catch (ServerException $e) { // 5xx echo "5xx: " . $e->getResponse()?->getStatusCode(); } catch (RequestException $e) { // 基类(含连接失败等) echo "req failed: " . $e->getMessage(); }
关闭:new Client(['http_errors' => false]),自己判断 getStatusCode()。
7.3 重试(指数退避,retry)
$client = new Client([ 'retry' => [ 'retries' => 3, // 最多重试 3 次(共 1 + 3 = 4 次请求) 'delay' => 300, // 初始延迟 ms 'multiplier' => 2.0, // 2 次幂退避:300 → 600 → 1200 ms // 'when' 可选:callable(次数, Request, Response?, e?) : bool // 未传时,默认:连接错误重试;5xx 重试;幂等方法的 429/503 重试 ], ]);
关闭:'retry' => false。
8. Cookie 会话(CookieJar)
use Yunadmin\HttpClient\Cookie\CookieJar; $jar = new CookieJar($strictMode = true); $client = new Client(['cookies' => $jar]); // 1. 登录,Set-Cookie 自动写入 $jar $client->post('https://sso.example.com/login', [ 'form_params' => ['u' => 'admin', 'p' => '123456'], ]); // 2. 后续请求自动带上匹配的 Cookie $me = $client->get('https://sso.example.com/me')->getJsonBody();
CookieJar 常用方法:
$jar->set($name, $value, ['domain'=>'example.com','path'=>'/','secure'=>true,'expires'=>time()+3600]); $jar->get('session_id', 'example.com', '/'); // Cookie 对象或 null $jar->toArray(); // 导出数组 $jar->toHeaderString('https://api.example.com/x'); // 给某个 URL 拼 Cookie 请求头
Set-Cookie解析器支持:引号内含分号的 value(例如tk="abc;foo=bar"; Path=/api),不会被错误切断。
单次请求也可以直接传非 Jar 的 cookies:
$client->get('https://example.com/x', [ 'cookies' => ['sid' => 'abc'], // array → 转 k=v //'cookies' => 'sid=abc; other=xyz', // string → 直接 Cookie 头 ]);
9. sink 下载、on_stats 统计、调试
9.1 文件下载(sink)
// 直接写文件路径 $client->get('https://example.com/big.zip', [ 'sink' => __DIR__ . '/tmp/big.zip', ]); // 或写已打开的资源流 $fp = fopen('php://temp', 'w+b'); $client->get('https://example.com/x', ['sink' => $fp]); rewind($fp); echo stream_get_contents($fp); fclose($fp);
使用 sink 后,$resp->getBody() 会被清空(返回空字符串),避免内存保留一份。
9.2 on_stats 统计回调
$client->get('https://example.com', [ 'on_stats' => static function (object $s) { echo "url: " . $s->url . "\n"; echo "status: " . ($s->response?->getStatusCode() ?? '-') . "\n"; echo "time(s): " . $s->time . "\n"; if (isset($s->handler_data)) { // cURL: curl_getinfo 结果数组 // Stream: 包含 dns/connect/starttransfer/ttfb_ms 等分段耗时(毫秒) } }, ]);
9.3 debug:输出 HTTP 原始报文
$client->get('https://example.com', [ 'debug' => true, // 输出到 stdout //'debug' => fopen('trace.log', 'ab'), // 写文件 ]);
9.4 全局 http_* 快捷函数
use function Yunadmin\HttpClient\http_get; use function Yunadmin\HttpClient\http_post; use function Yunadmin\HttpClient\http_json; $html = http_get('https://example.com'); // 直接 string body $html2 = http_post('https://example.com/form', ['a' => 1]); // application/x-www-form-urlencoded $data = http_json('https://api.example.com/items'); // 自动 json_decode → array/stdClass $resp = http_json('https://api.example.com/items', true); // true = 返回 Response 对象
这些函数会复用同一个静态 Client 实例。
10. 可测试:Mock + 历史记录
use Yunadmin\HttpClient\Client; use Yunadmin\HttpClient\Handler\HandlerStack; use Yunadmin\HttpClient\Handler\MockHandler; use Yunadmin\HttpClient\Middleware\HistoryMiddleware; use Yunadmin\HttpClient\Response; // 1. 准备响应队列 $mock = new MockHandler(); $mock->append(new Response(200, ['Content-Type' => 'application/json'], '{"id":1,"name":"a"}')); $mock->append(new Response(201, ['X-Id' => '2'], '')); $mock->append(new \RuntimeException('Network down')); // 也可以排队 Exception // 2. 建 HandlerStack,推送 HistoryMiddleware $history = []; $stack = HandlerStack::createWithMock($mock); $stack->push(new HistoryMiddleware($history), 'history'); // 3. 业务代码里注入 client,不发真实请求 $client = new Client(['handler' => $stack, 'base_uri' => 'https://api.example.com/']); $a = $client->get('items/1')->getJsonBody(); $b = $client->post('items', ['json' => ['name' => 'x']]); try { $client->get('items/3'); } catch (\RuntimeException $e) { // Net down } // 4. 断言 echo $history[0]['request']->getMethod(); // GET echo $history[0]['request']->getUri(); // https://api.example.com/items/1 echo $history[0]['response']->getStatusCode();// 200 echo $history[1]['request']->getMethod(); // POST echo $history[1]['options']['json']['name']; // x echo count($history); // 3
11. 自定义中间件 & HandlerStack
每个中间件签名:callable(RequestInterface, array): (ResponseInterface|FutureInterface)。
use Yunadmin\HttpClient\Handler\HandlerStack; use Yunadmin\HttpClient\Contract\RequestInterface; use Yunadmin\HttpClient\Response; $stack = HandlerStack::create(); // 自动挑 CurlHandler / StreamHandler $stack->push(static function (callable $next) { return static function (RequestInterface $req, array $opts) use ($next) { // 进入:加 trace header $req = $req->withHeader('X-Trace-Id', bin2hex(random_bytes(8))); $t1 = microtime(true); // 交给下一层 $result = $next($req, $opts); // 出来:打点(支持同步 Response 或异步 Future) $log = static function ($r) use ($t1, $req) { $ms = round((microtime(true) - $t1) * 1000, 1); $code = $r instanceof Response ? $r->getStatusCode() : 'ERR'; file_put_contents('request.log', "{$req->getMethod()} {$req->getUri()} => {$code} ({$ms}ms)\n", FILE_APPEND); return $r; }; if ($result instanceof \Yunadmin\HttpClient\Future) { return $result->then($log, $log); } return $log($result); }; }, 'trace'); $client = new Client(['handler' => $stack]); $client->get('https://example.com');
12. 选项速查表
| 选项 | 类型 | 说明 |
|---|---|---|
base_uri |
string / UriInterface | 相对 URI 基准 |
handler |
HandlerStack / HandlerInterface | 自定义底层或中间件栈 |
timeout / connect_timeout |
float | 总 / 连接超时秒(<1 可用) |
verify |
bool / string | false 关校验,或 CA 文件/目录 |
cert / ssl_key |
string / [path, pass] |
客户端证书/私钥 |
proxy |
string / [http,https,no] |
代理;no 为不走代理的域名数组 |
force_ip_resolve |
'v4' / 'v6' / null |
强制 IPv4/6 |
decode_content |
bool | 默认 true:自动压缩&解压 |
auth |
[u,p] / [t,'','bearer'] / string |
Authorization 头 |
headers |
array | 合并进请求头 |
query |
array / string | 追加到 URI query |
json |
mixed | JSON 请求体 |
form_params |
array | URL 编码表单 |
multipart |
array | [{name, contents, filename?, headers?}, ...] |
body |
string / resource / StreamInterface | 原始请求体 |
cookies |
CookieJar / array / string | Jar=会话保持;其他=单次 |
allow_redirects |
bool / [max,strict,keep_body] |
重定向控制 |
http_errors |
bool | 默认 true,4xx/5xx 抛异常 |
retry |
false / [retries,delay,multiplier,when?] |
指数退避重试 |
on_stats |
callable(object) | 请求完成统计回调 |
sink |
string / resource | 响应体直接写文件/流 |
debug |
bool / resource | 原始 HTTP 报文输出 |
curl |
[CURLOPT_* => v] |
CurlHandler 直接追加 CURLOPT |
13. 目录结构
src/
├── Client.php 主入口:便捷方法、选项归一化、sync/async/pool
├── Future.php 异步结果:pending/fulfilled/rejected + then/otherwise/wait
├── Message.php Request/Response 公共头操作
├── Request.php 请求对象
├── Response.php 响应对象(带 getJsonBody())
├── Uri.php URI 解析与拼接
├── Stream.php 数据流(资源包装 + 静态工厂 fromString/fopen)
├── Contract/ HandlerInterface、MessageInterface、RequestInterface、ResponseInterface、StreamInterface、UriInterface
├── Exception/ RequestException(基类)、ConnectException、ClientException、ServerException、TooManyRedirectsException
├── Handler/
│ ├── CurlHandler.php 同步 cURL:流式上传、on_stats、debug、curl 扩展
│ ├── CurlMultiHandler.php 并发 cURL Multi:enqueue/tick/execute + selectTimeout
│ ├── StreamHandler.php Socket Stream:流式上传、chunked、分段耗时、force_ip_resolve
│ ├── MockHandler.php 单元测试:响应/异常队列
│ └── HandlerStack.php 中间件管道:create / createWithMock / push / resolve / __invoke / getMultiHandler / enqueue
├── Middleware/
│ ├── RedirectMiddleware.php 3xx 处理(match 表达式分派方法/body/状态)
│ ├── HttpErrorsMiddleware.php 4xx/5xx 抛 ClientException/ServerException
│ ├── RetryMiddleware.php 指数退避重试(默认策略 + 自定义 when)
│ └── HistoryMiddleware.php 记录 {request,response,options,error} 历史
├── Cookie/
│ ├── CookieJar.php 会话存取 + 匹配 domain/path + 请求前/响应后挂钩
│ └── SetCookie.php Set-Cookie 解析:引号内嵌分号状态机 splitParts
├── Stream/
│ └── LazyOpenStream.php 懒加载文件流(首次读写才 fopen)
└── functions.php 全局辅助:stream_for() / uri_for() / build_query() / lazy_open_stream()
+ 便捷:http_get() / http_post() / http_json()
+ 调试:dump_request() / dump_response()
版本要求
- PHP ^8.0(用到构造函数属性提升、
match、str_contains/str_starts_with/str_ends_with、nullsafe?->、??=、mixed类型等) ext-curl、ext-json(composer 自动校验)