<?php
declare(strict_types=1);
class HttpDownloader
{
private string $url = '';
private string $scheme = 'http';
private string $host = '';
private int $port = 80;
private string $path = '/';
private string $query = '';
private string $user = '';
private string $pass = '';
private $fp = null; // Socket 資源
private array $responseHeaders = [];
private string $responseBody = '';
private string $error = '';
private int $statusCode = 0;
// 下載進度回調
public ?callable $progressCallback = null;
public function __construct(private int $connectTimeout = 10, private int $readTimeout = 30) {}
/** 設定目標 URL 並解析 */
public function setUrl(string $url): self
{
$this->reset();
$this->url = $url;
$parts = parse_url($url);
if (!$parts) throw new InvalidArgumentException("無效 URL: $url");
$this->scheme = $parts['scheme'] ?? 'http';
$this->host = $parts['host'] ?? throw new InvalidArgumentException("缺少 Host");
$this->port = $parts['port'] ?? ($this->scheme === 'https' ? 443 : 80);
$this->path = $parts['path'] ?? '/';
$this->query = $parts['query'] ?? '';
$this->user = $parts['user'] ?? '';
$this->pass = $parts['pass'] ?? '';
return $this;
}
/** 執行 GET 請求並下載到文件(流式) */
public function downloadToFile(string $savePath, int $offset = 0): bool
{
$this->openConnection();
$this->sendRequest('GET', $offset);
$this->readResponseHeaders();
if ($this->statusCode >= 400) {
$this->error = "HTTP {$this->statusCode}: " . $this->getHeader('status');
return false;
}
$fpOut = fopen($savePath, $offset ? 'ab' : 'wb');
if (!$fpOut) {
$this->error = "無法創建本地文件: $savePath";
return false;
}
$totalSize = (int)$this->getHeader('content-length');
$downloaded = $offset;
$bufferSize = 8192;
while (!feof($this->fp)) {
$chunk = fread($this->fp, $bufferSize);
if ($chunk === '') break;
fwrite($fpOut, $chunk);
$downloaded += strlen($chunk);
if ($this->progressCallback) {
($this->progressCallback)($downloaded, $totalSize, $this->statusCode);
}
}
fclose($fpOut);
$this->close();
return true;
}
/** 獲取完整回應 (頭+體),適用於小文件/API */
public function get(string $url): array
{
$this->setUrl($url);
$this->openConnection();
$this->sendRequest('GET');
$this->readResponseHeaders();
$this->readResponseBody();
$this->close();
return [
'status' => $this->statusCode,
'headers' => $this->responseHeaders,
'body' => $this->responseBody,
];
}
/** POST 請求 */
public function post(string $url, array $data = [], array $headers = []): array
{
$this->setUrl($url);
$this->openConnection();
$body = http_build_query($data);
$this->sendRequest('POST', 0, $headers, $body);
$this->readResponseHeaders();
$this->readResponseBody();
$this->close();
return [
'status' => $this->statusCode,
'headers' => $this->responseHeaders,
'body' => $this->responseBody,
];
}
// ---------- 內部方法 ----------
private function reset(): void
{
$this->close();
$this->responseHeaders = [];
$this->responseBody = '';
$this->error = '';
$this->statusCode = 0;
}
private function openConnection(): void
{
$target = ($this->scheme === 'https' ? 'ssl://' : 'tcp://') . $this->host . ':' . $this->port;
$this->fp = @stream_socket_client(
$target,
$errno,
$errstr,
$this->connectTimeout,
STREAM_CLIENT_CONNECT,
stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]])
);
if (!$this->fp) {
throw new RuntimeException("連接失敗: {$errstr} ({$errno})");
}
stream_set_timeout($this->fp, $this->readTimeout);
// HTTP/1.1 需要 Host header
if ($this->scheme === 'https') {
stream_socket_enable_crypto($this->fp, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
}
}
private function sendRequest(string $method, int $rangeStart = 0, array $extraHeaders = [], string $body = ''): void
{
$requestPath = $this->path . ($this->query ? '?' . $this->query : '');
$headers = [
"{$method} {$requestPath} HTTP/1.1",
"Host: {$this->host}",
"User-Agent: PHP HttpDownloader/1.0",
"Connection: Close",
"Accept: */*",
];
if ($this->user || $this->pass) {
$auth = base64_encode($this->user . ':' . $this->pass);
$headers[] = "Authorization: Basic {$auth}";
}
if ($rangeStart > 0) {
$headers[] = "Range: bytes={$rangeStart}-";
}
if ($body) {
$headers[] = "Content-Type: application/x-www-form-urlencoded";
$headers[] = "Content-Length: " . strlen($body);
}
$headers = array_merge($headers, $extraHeaders);
$request = implode("\r\n", $headers) . "\r\n\r\n" . $body;
fwrite($this->fp, $request);
}
private function readResponseHeaders(): void
{
$this->responseHeaders = [];
while (($line = fgets($this->fp, 4096)) !== false) {
$line = rtrim($line, "\r\n");
if ($line === '') break; // Headers 結束
$this->responseHeaders[] = $line;
// 解析狀態行
if (str_starts_with($line, 'HTTP/')) {
if (preg_match('/HTTP\/\d\.\d\s+(\d{3})/', $line, $m)) {
$this->statusCode = (int)$m[1];
}
}
}
}
private function readResponseBody(): void
{
$this->responseBody = '';
while (!feof($this->fp)) {
$this->responseBody .= fread($this->fp, 8192);
}
}
private function close(): void
{
if (is_resource($this->fp)) {
fclose($this->fp);
$this->fp = null;
}
}
public function getHeader(string $name): ?string
{
$name = strtolower($name);
foreach ($this->responseHeaders as $header) {
if (str_starts_with(strtolower($header), $name . ':')) {
return trim(substr($header, strlen($name) + 1));
}
}
return null;
}
public function getAllHeaders(): array
{
return $this->responseHeaders;
}
public function getError(): string
{
return $this->error;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
public function __destruct()
{
$this->close();
}
}
$downloader = new HttpDownloader(connectTimeout: 10, readTimeout: 60);
// 1. 簡單 GET 獲取 API 數據
$api = $downloader->get('https://api.example.com/v1/user/123');
if ($api['status'] === 200) {
$data = json_decode($api['body'], true);
var_dump($data);
}
// 2. 大文件下載 + 進度條
$url = 'https://releases.ubuntu.com/22.04.3/ubuntu-22.04.3-desktop-amd64.iso';
$save = '/tmp/ubuntu.iso';
$downloader->progressCallback = function(int $downloaded, int $total, int $status) {
static $lastPct = -1;
if ($total > 0) {
$pct = (int)($downloaded / $total * 100);
if ($pct !== $lastPct) {
echo "\r進度: {$pct}% ({$downloaded}/{$total} bytes)";
$lastPct = $pct;
}
}
};
if ($downloader->downloadToFile($save)) {
echo "\n下載完成: {$save}\n";
} else {
echo "\n下載失敗: " . $downloader->getError() . "\n";
}
// 3. 斷點續傳
$offset = filesize($save) ?? 0;
if ($offset > 0) {
echo "續傳從: {$offset} bytes\n";
$downloader->downloadToFile($save, $offset);
}
| 特性 | HttpDownloader (Socket) | cURL | Guzzle |
|---|---|---|---|
| 依賴 | 無 (核心流) | php_curl.dll/so | Composer 包 |
| HTTP/2 | 不支援 | 支援 | 支援 |
| 並發/異步 | 需自行實現 stream_select | curl_multi | Promise/異步 |
| SSL 驗證 | 簡易 (可關閉) | 完整 CA 鏈 | 完整 |
| Cookie 罐 | 手動管理 | 內建 | 內建 |
| 重定向跟隨 | 手動處理 3xx | CURLOPT_FOLLOWLOCATION | 自動 |
| 大文件記憶體 | 流式,常數記憶體 | 流式寫入文件 | 流式/PSR-7 |
本類展示了 PHP 底層 Socket 實現 HTTP 客戶端的核心邏輯:解析 URL → 建立 TCP/SSL 連接 → 發送請求行/頭 → 讀取回應頭/體 → 流式寫入文件。理解這一流程對排查網絡異常、實現自定義協議、優化大文件傳輸極有幫助。實際項目中,請優先選擇 Guzzle 或 cURL,享受成熟生態與安全保障。