在 cURL 擴充套件未安裝、或需極度精確控制 TCP 層行為的罕見情境下,fsockopen() 可手工組裝 HTTP 請求。但這需要開發者完整理解 HTTP/1.1 協定、處理 Chunked Transfer、Keep-Alive、TLS/SSL 等複雜細節。本文解析經典實作缺陷,提供健壯版本,並強烈建議遷移至現代 HTTP 客戶端。
<?php
// 原始碼問題:
function wfopen($url, $post='', $cookie='', $timeout=15) {
$matches = parse_url($url);
$out = "POST {$matches['path']} HTTP/1.0\r\n"; // 1. HTTP/1.0 無 Host 強制、無 Keep-Alive
$out .= "Host: {$matches['host']}\r\n"; // 2. 缺少 Port 處理
$out .= "Content-Length: " . strlen($post) . "\r\n"; // 3. 忽略 multipart/form-data
$out .= "Cookie: $cookie\r\n\r\n"; // 4. Cookie 未編碼、無 SameSite
$out .= $post;
$socket = @fsockopen($matches['host'], 80, $errno, $errstr, $timeout);
// ... 讀取回應,未處理:
// - HTTPS (需 ssl:// 前綴、驗證憑證)
// - Chunked Encoding
// - 壓縮回應 (gzip/deflate)
// - 重導向 (3xx)
// - 連線/讀取超時分離
// - 錯誤碼非 2xx 處理
}
<?php
declare(strict_types=1);
/**
* 純 Socket HTTP 客戶端 (支援 HTTP/HTTPS、基本重導向、Chunked、Gzip)
* 僅供學習協定細節,生產請用 Guzzle/cURL
*/
class SocketHttpClient
{
private const MAX_REDIRECTS = 5;
private const BUFFER_SIZE = 8192;
public function __construct(
private int $connectTimeout = 10,
private int $readTimeout = 30,
private bool $verifySsl = true
) {}
public function post(string $url, array|string $data = [], array $headers = []): array
{
return $this->request('POST', $url, $data, $headers, 0);
}
public function get(string $url, array $headers = []): array
{
return $this->request('GET', $url, null, $headers, 0);
}
private function request(
string $method,
string $url,
array|string|null $body,
array $headers,
int $redirectCount
): array {
if ($redirectCount > self::MAX_REDIRECTS) {
throw new \RuntimeException('Too many redirects');
}
$parsed = parse_url($url);
if (!$parsed || !isset($parsed['scheme'], $parsed['host'])) {
throw new \InvalidArgumentException('Invalid URL');
}
$scheme = $parsed['scheme'];
$host = $parsed['host'];
$port = $parsed['port'] ?? ($scheme === 'https' ? 443 : 80);
$path = ($parsed['path'] ?? '/') . ($parsed['query'] ? '?' . $parsed['query'] : '');
// 準備 Body
$bodyStr = '';
$contentType = 'application/x-www-form-urlencoded';
if (is_array($body)) {
$bodyStr = http_build_query($body);
} elseif (is_string($body)) {
$bodyStr = $body;
}
$contentLength = strlen($bodyStr);
// 預設 Headers
$defaultHeaders = [
'Host' => $host . ($port !== 80 && $port !== 443 ? ":$port" : ''),
'User-Agent' => 'PHP-SocketHttpClient/1.0',
'Accept' => '*/*',
'Connection' => 'close', // 簡化:不支援 Keep-Alive
'Content-Type' => $contentType,
'Content-Length' => (string)$contentLength,
];
$headers = array_merge($defaultHeaders, $headers);
// 建立 Socket
$transport = $scheme === 'https' ? "ssl://$host" : "tcp://$host";
$flags = STREAM_CLIENT_CONNECT;
if ($this->verifySsl && $scheme === 'https') {
$flags |= STREAM_CLIENT_PERSISTENT; // 實際需 stream_context_set_option 驗證
}
$context = stream_context_create([
'ssl' => [
'verify_peer' => $this->verifySsl,
'verify_peer_name' => $this->verifySsl,
]
]);
$socket = @stream_socket_client(
"$transport:$port",
$errno,
$errstr,
$this->connectTimeout,
$flags,
$context
);
if (!$socket) {
throw new \RuntimeException("Connect failed: $errstr ($errno)");
}
stream_set_timeout($socket, $this->readTimeout);
// 發送請求
$request = "$method $path HTTP/1.1\r\n";
foreach ($headers as $k => $v) {
$request .= "$k: $v\r\n";
}
$request .= "\r\n$bodyStr";
fwrite($socket, $request);
// 讀取回應頭
$headerLines = [];
while (!feof($socket)) {
$line = fgets($socket, self::BUFFER_SIZE);
if ($line === "\r\n" || $line === "\n") break;
$headerLines[] = rtrim($line);
}
$statusLine = array_shift($headerLines);
preg_match('/HTTP\/\d\.\d\s+(\d{3})/', $statusLine, $m);
$statusCode = (int)($m[1] ?? 0);
// 解析 Headers
$responseHeaders = [];
$contentLength = null;
$chunked = false;
$contentEncoding = 'identity';
foreach ($headerLines as $h) {
if (stripos($h, ':') === false) continue;
[$name, $value] = explode(':', $h, 2);
$name = strtolower(trim($name));
$value = trim($value);
$responseHeaders[$name] = $value;
if ($name === 'content-length') $contentLength = (int)$value;
if ($name === 'transfer-encoding' && stripos($value, 'chunked') !== false) $chunked = true;
if ($name === 'content-encoding') $contentEncoding = $value;
if ($name === 'location') $redirectUrl = $value;
}
// 讀取 Body
$body = '';
if ($chunked) {
$body = $this->readChunked($socket);
} elseif ($contentLength !== null) {
$body = $this->readExact($socket, $contentLength);
} else {
// Connection: close 讀到 EOF
while (!feof($socket)) $body .= fread($socket, self::BUFFER_SIZE);
}
fclose($socket);
// 解壓縮
if ($contentEncoding === 'gzip' || $contentEncoding === 'deflate') {
$body = gzdecode($body) ?? $body;
}
// 處理重導向
if (in_array($statusCode, [301, 302, 303, 307, 308]) && isset($redirectUrl)) {
$newUrl = $this->resolveUrl($url, $redirectUrl);
$nextMethod = in_array($statusCode, [303, 301, 302]) && $method !== 'GET' ? 'GET' : $method;
return $this->request($nextMethod, $newUrl, $nextMethod === 'GET' ? null : $body, [], $redirectCount + 1);
}
return [
'status' => $statusCode,
'headers' => $responseHeaders,
'body' => $body,
'url' => $url,
];
}
private function readChunked($socket): string
{
$data = '';
while (!feof($socket)) {
$line = fgets($socket, self::BUFFER_SIZE);
$len = hexdec(trim($line));
if ($len === 0) break;
$data .= $this->readExact($socket, $len);
fgets($socket); // 讀掉 \r\n
}
return $data;
}
private function readExact($socket, int $len): string
{
$data = '';
while (strlen($data) < $len) {
$chunk = fread($socket, min(self::BUFFER_SIZE, $len - strlen($data)));
if ($chunk === '' || $chunk === false) break;
$data .= $chunk;
}
return $data;
}
private function resolveUrl(string $base, string $relative): string
{
if (str_starts_with($relative, 'http')) return $relative;
$parsed = parse_url($base);
$basePath = rtrim($parsed['path'] ?? '/', '/');
if ($relative[0] === '/') return "{$parsed['scheme']}://{$parsed['host']}$relative";
return "{$parsed['scheme']}://{$parsed['host']}$basePath/$relative";
}
}
<?php
require_once 'SocketHttpClient.php';
$client = new SocketHttpClient(connectTimeout: 5, readTimeout: 15);
try {
// 表單 POST
$resp = $client->post('https://httpbin.org/post', [
'username' => 'demo',
'action' => 'login',
]);
echo "Status: {$resp['status']}\n";
echo "Body: {$resp['body']}\n";
// JSON POST
$jsonResp = $client->post('https://api.example.com/endpoint',
json_encode(['key' => 'value']),
['Content-Type' => 'application/json', 'Accept' => 'application/json']
);
} catch (\Throwable $e) {
echo "Request failed: {$e->getMessage()}\n";
}
| 面向 | fsockopen 手工實作 | Guzzle / cURL / PHP 8.2+ http-client |
|---|---|---|
| TLS/SSL | 需手動建立 context、驗證憑證鏈、SNI、ALPN | 內建、預設安全、支援 HTTP/2、HTTP/3 |
| 效能 | 無連線池、無 Keep-Alive、每請求新建 TCP | 連線複用、多路複用、非同步並發 |
| 功能完整度 | 需自行實作:重導向、壓縮、Chunked、Cookie Jar、Proxy、Digest Auth、重試、熔斷 | 開箱即用、Middleware 架構擴充 |
| 錯誤處理 | 手動檢查 errno、stream_get_meta_data | 異常階層、PSR-18/17 相容 |
| 維護成本 | 極高 (協定細節、安全更新) | 社群維護、安全回報機制 |
composer require guzzlehttp/guzzle
<?php
$client = new \GuzzleHttp\Client(['timeout' => 10]);
$resp = $client->post('https://api.example.com', [
'json' => ['key' => 'value'],
'headers' => ['Authorization' => 'Bearer token'],
]);
$data = json_decode($resp->getBody()->getContents(), true);
composer require symfony/http-client
<?php
$client = \Symfony\Component\HttpClient\HttpClient::create();
$resp = $client->request('POST', 'https://api.example.com', [
'json' => ['key' => 'value'],
]);
$data = $resp->toArray(); // 自動 json_decode
http-client (實驗性)<?php
$client = new \Http\Client();
$resp = $client->post('https://api.example.com', json_encode(['key' => 'value']));
echo $resp->body;
fsockopen() 是理解 TCP/HTTP 底層的絕佳教具,但絕不應用於生產業務邏輯。現代 PHP 專案請直接採用 Guzzle、Symfony HTTP Client 或原生 cURL,享受連線池、HTTP/2、TLS 1.3、中介軟體擴充等開箱即用能力。若必須手工實作,請嚴格遵循 RFC 7230/7231,處理所有邊界情況,並建立完整測試套件。