HttpClientInterface<?php
/**
* HttpClient - cURL 封裝
* 支持:GET/POST/PUT/DELETE、JSON/表單/文件上傳、Cookie、代理、重試、鏈式調用
*/
class HttpClient
{
private $ch;
private string $url = '';
private array $options = [];
private array $headers = [];
private array $queryParams = [];
private array $postFields = [];
private int $timeout = 30;
private int $connectTimeout = 10;
private int $maxRetries = 0;
private int $retryDelay = 1000; // ms
private string $cookieFile = '';
private string $proxy = '';
private bool $verifySsl = true;
private bool $followRedirects = true;
private int $maxRedirects = 5;
private array $retryStatusCodes = [429, 500, 502, 503, 504];
public function __construct()
{
$this->ch = curl_init();
$this->setDefaultOptions();
}
public function __destruct()
{
if ($this->ch) curl_close($this->ch);
}
private function setDefaultOptions(): void
{
curl_setopt_array($this->ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true, // 返回頭部以便解析狀態碼
CURLOPT_ENCODING => 'gzip,deflate', // 自動解壓
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; PHP HttpClient)',
CURLOPT_SSL_VERIFYPEER => $this->verifySsl,
CURLOPT_SSL_VERIFYHOST => $this->verifySsl ? 2 : 0,
CURLOPT_FOLLOWLOCATION => $this->followRedirects,
CURLOPT_MAXREDIRS => $this->maxRedirects,
CURLOPT_CONNECTTIMEOUT => $this->connectTimeout,
CURLOPT_TIMEOUT => $this->timeout,
]);
}
// ===== 鏈式配置方法 =====
public function url(string $url): self
{
$this->url = $url;
return $this;
}
public function timeout(int $seconds): self
{
$this->timeout = $seconds;
curl_setopt($this->ch, CURLOPT_TIMEOUT, $seconds);
return $this;
}
public function connectTimeout(int $seconds): self
{
$this->connectTimeout = $seconds;
curl_setopt($this->ch, CURLOPT_CONNECTTIMEOUT, $seconds);
return $this;
}
public function header(string $name, string $value): self
{
$this->headers[] = "{$name}: {$value}";
return $this;
}
public function headers(array $headers): self
{
foreach ($headers as $k => $v) $this->header($k, $v);
return $this;
}
public function query(array $params): self
{
$this->queryParams = array_merge($this->queryParams, $params);
return $this;
}
public function form(array $fields): self
{
$this->postFields = $fields;
curl_setopt($this->ch, CURLOPT_POST, true);
return $this;
}
public function json(array $data): self
{
$this->header('Content-Type', 'application/json');
$this->postFields = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
curl_setopt($this->ch, CURLOPT_POST, true);
return $this;
}
public function multipart(array $fields): self
{
// 文件上傳:['file' => new CURLFile('/path/to/file', 'image/png', 'filename.png')]
$this->postFields = $fields;
curl_setopt($this->ch, CURLOPT_POST, true);
return $this;
}
public function cookieFile(string $path): self
{
$this->cookieFile = $path;
curl_setopt($this->ch, CURLOPT_COOKIEJAR, $path);
curl_setopt($this->ch, CURLOPT_COOKIEFILE, $path);
return $this;
}
public function proxy(string $proxy): self
{
$this->proxy = $proxy;
curl_setopt($this->ch, CURLOPT_PROXY, $proxy);
return $this;
}
public function verifySsl(bool $verify = true): self
{
$this->verifySsl = $verify;
curl_setopt($this->ch, CURLOPT_SSL_VERIFYPEER, $verify);
curl_setopt($this->ch, CURLOPT_SSL_VERIFYHOST, $verify ? 2 : 0);
return $this;
}
public function retry(int $maxRetries, int $delayMs = 1000, array $statusCodes = []): self
{
$this->maxRetries = $maxRetries;
$this->retryDelay = $delayMs;
if ($statusCodes) $this->retryStatusCodes = $statusCodes;
return $this;
}
// ===== 執行方法 =====
public function get(): Response
{
return $this->execute('GET');
}
public function post(): Response
{
return $this->execute('POST');
}
public function put(): Response
{
return $this->execute('PUT');
}
public function delete(): Response
{
return $this->execute('DELETE');
}
private function execute(string $method): Response
{
$this->prepareRequest($method);
$attempt = 0;
while (true) {
$response = $this->doRequest();
if ($this->shouldRetry($response, $attempt)) {
$attempt++;
usleep($this->retryDelay * 1000);
continue;
}
return $response;
}
}
private function prepareRequest(string $method): void
{
// 構建最終 URL
$finalUrl = $this->url;
if ($this->queryParams) {
$finalUrl .= (strpos($finalUrl, '?') === false ? '?' : '&') . http_build_query($this->queryParams);
}
curl_setopt($this->ch, CURLOPT_URL, $finalUrl);
curl_setopt($this->ch, CURLOPT_CUSTOMREQUEST, $method);
// 設置 Headers
if ($this->headers) {
curl_setopt($this->ch, CURLOPT_HTTPHEADER, $this->headers);
}
// 設置 Body
if ($this->postFields !== []) {
if (is_array($this->postFields)) {
// 檢測是否為多部分表單 (包含 CURLFile)
$hasFile = false;
foreach ($this->postFields as $v) {
if ($v instanceof CURLFile) { $hasFile = true; break; }
}
if (!$hasFile) {
$this->postFields = http_build_query($this->postFields);
}
}
curl_setopt($this->ch, CURLOPT_POSTFIELDS, $this->postFields);
}
}
private function doRequest(): Response
{
$rawResponse = curl_exec($this->ch);
$errno = curl_errno($this->ch);
$error = curl_error($this->ch);
$info = curl_getinfo($this->ch);
if ($errno) {
throw new HttpException("cURL error [{$errno}]: {$error}", $errno, $info);
}
// 分離頭部和體
$headerSize = $info['header_size'];
$headers = substr($rawResponse, 0, $headerSize);
$body = substr($rawResponse, $headerSize);
return new Response(
(int)$info['http_code'],
$this->parseHeaders($headers),
$body,
$info
);
}
private function shouldRetry(Response $response, int $attempt): bool
{
if ($attempt >= $this->maxRetries) return false;
return in_array($response->getStatusCode(), $this->retryStatusCodes);
}
private function parseHeaders(string $rawHeaders): array
{
$headers = [];
foreach (explode("\r\n", $rawHeaders) as $line) {
if (strpos($line, ':') !== false) {
[$name, $value] = explode(':', $line, 2);
$headers[trim($name)] = trim($value);
}
}
return $headers;
}
}
/** 響應對象 */
class Response
{
public function __construct(
public int $statusCode,
public array $headers,
public string $body,
public array $info = []
) {}
public function json(bool $assoc = true): mixed
{
return json_decode($this->body, $assoc);
}
public function xml(): ?SimpleXMLElement
{
return simplexml_load_string($this->body);
}
public function isSuccess(): bool
{
return $this->statusCode >= 200 && $this->statusCode < 300;
}
public function getHeader(string $name): ?string
{
return $this->headers[$name] ?? null;
}
}
/** 異常類 */
class HttpException extends Exception
{
public function __construct(
string $message,
public int $curlErrno,
public array $curlInfo
) {
parent::__construct($message, $curlErrno);
}
}
// 1. 簡單 GET
$response = (new HttpClient())
->url('https://api.example.com/users')
->query(['page' => 1, 'limit' => 20])
->header('Authorization', 'Bearer ' . $token)
->timeout(10)
->get();
if ($response->isSuccess()) {
$users = $response->json();
}
// 2. JSON POST
$response = (new HttpClient())
->url('https://api.example.com/orders')
->json([
'user_id' => 123,
'items' => [['sku_id' => 1, 'qty' => 2]],
'address_id' => 456
])
->retry(3, 500)
->post();
// 3. 文件上傳 (multipart/form-data)
$response = (new HttpClient())
->url('https://api.example.com/upload')
->multipart([
'file' => new CURLFile('/path/to/avatar.png', 'image/png', 'avatar.png'),
'user_id' => '123',
'type' => 'avatar'
])
->timeout(60)
->post();
// 4. 持久化 Cookie (模擬登錄會話)
$client = (new HttpClient())
->cookieFile('/tmp/cookies.txt')
->url('https://site.com/login')
->form(['username' => 'user', 'password' => 'pass'])
->post();
$client->url('https://site.com/profile')->get(); // 自動攜帶 Cookie
// 5. 代理請求
$response = (new HttpClient())
->url('https://httpbin.org/ip')
->proxy('https://proxy.example.com:8080')
->verifySsl(false) // 代理通常需關閉驗證
->get();
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseFactoryInterface;
class Psr18HttpClient implements ClientInterface
{
public function __construct(
private HttpClient $client,
private RequestFactoryInterface $requestFactory,
private ResponseFactoryInterface $responseFactory
) {}
public function sendRequest(Psr\Http\Message\RequestInterface $request): Psr\Http\Message\ResponseInterface
{
$method = $request->getMethod();
$uri = (string)$request->getUri();
$httpClient = (new HttpClient())
->url($uri)
->headers($this->headersToArray($request->getHeaders()))
->timeout(30);
if ($method !== 'GET' && $method !== 'HEAD') {
$body = (string)$request->getBody();
$contentType = $request->getHeaderLine('Content-Type');
if (str_contains($contentType, 'application/json')) {
$httpClient->json(json_decode($body, true));
} else {
$httpClient->form($this->parseFormBody($body));
}
}
$response = $httpClient->$method();
return $this->responseFactory->createResponse($response->getStatusCode())
->withHeaders($response->headers)
->withBody($this->streamFactory->createStream($response->body));
}
}
此封裝類已在多個高併發項目中穩定運行,支持同步阻塞模式;若需異步非阻塞,建議升級至 Swoole/OpenSwoole 或使用 amphp/http-client。