首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP fsockopen 模擬 HTTP POST 請求:原理、實作與現代替代方案

     編輯:PHP 相關知識

前言

在 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 處理
}

健壯版 fsockopen 實作 (教學用)

<?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?

面向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 相容
維護成本極高 (協定細節、安全更新)社群維護、安全回報機制

現代替代方案快速上手

1. Guzzle (最成熟、功能最全)

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);

2. Symfony HTTP Client (零依賴、PSR-18、自動解碼)

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

3. PHP 8.2+ 內建 http-client (實驗性)

<?php
$client = new \Http\Client();
$resp = $client->post('https://api.example.com', json_encode(['key' => 'value']));
echo $resp->body;

何時仍需 fsockopen?

  1. 教學/面試:展示 HTTP 協定底層運作。
  2. 極度受限環境:嵌入式 PHP、禁用所有擴充套件的容器 (極罕見)。
  3. 特殊協定測試:測試非標準 HTTP 行為、Fuzzing、手工構造惡意封包。
  4. 舊系統維護:無法升級、無法安裝擴充套件,需最小修改修復 Bug。

總結

fsockopen() 是理解 TCP/HTTP 底層的絕佳教具,但絕不應用於生產業務邏輯。現代 PHP 專案請直接採用 Guzzle、Symfony HTTP Client 或原生 cURL,享受連線池、HTTP/2、TLS 1.3、中介軟體擴充等開箱即用能力。若必須手工實作,請嚴格遵循 RFC 7230/7231,處理所有邊界情況,並建立完整測試套件。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved