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

PHP 遠端檔案下載:cURL 斷點續傳、進度回調、大檔案最佳實踐

     編輯:PHP 相關知識

單純用 copy() 或 file_get_contents() 下載遠端檔案,大檔案易逾時、無進度、無法斷點續傳。本文提供生產級 Downloader 類別,基於 cURL 實現:**斷點續傳**、**即時進度回調**、**多執行緒分段下載**、**速度限制**、**完整性驗證(SHA256)**、**自動重試**。

核心 Downloader 類別

<?php
class Downloader
{
    private string $url;
    private string $savePath;
    private array $options = [];

    // 進度回調:function(float $progress, int $downloaded, int $total, float $speedKbps): void
    public ?callable $onProgress = null;
    // 完成回調:function(bool $success, string $path, array $info): void
    public ?callable $onComplete = null;

    public function __construct(string $url, string $savePath, array $options = [])
    {
        $this->url = $url;
        $this->savePath = $savePath;
        $this->options = array_merge([
            'timeout' => 300,           // 總逾時秒數
            'connect_timeout' => 30,    // 連線逾時
            'max_retries' => 3,         // 失敗重試次數
            'retry_delay' => 2,         // 重試延遲秒數
            'speed_limit' => 0,         // 速度限制 KB/s (0=不限制)
            'expected_hash' => null,    // 'sha256:...' 完整性驗證
            'temp_suffix' => '.part',   // 臨時檔案後綴
            'overwrite' => false,       // 目標檔存在時是否覆蓋
        ], $options);
    }

    public function download(): bool
    {
        // 1. 檢查目標檔案
        if (file_exists($this->savePath) && !$this->options['overwrite']) {
            $this->triggerComplete(false, $this->savePath, ['error' => '檔案已存在']);
            return false;
        }

        // 2. 取得遠端檔案資訊(HEAD 請求)
        $remoteInfo = $this->getRemoteInfo();
        if (!$remoteInfo) {
            $this->triggerComplete(false, $this->savePath, ['error' => '無法取得遠端資訊']);
            return false;
        }

        $totalSize = $remoteInfo['size'];
        $supportsRange = $remoteInfo['accept_ranges'];

        // 3. 檢查斷點續傳
        $tempPath = $this->savePath . $this->options['temp_suffix'];
        $downloaded = 0;
        if ($supportsRange && file_exists($tempPath)) {
            $downloaded = filesize($tempPath);
            if ($downloaded >= $totalSize) {
                // 已下載完成,驗證並重命名
                return $this->finalize($tempPath, $totalSize);
            }
        }

        // 4. 迴圈重試下載
        for ($attempt = 1; $attempt <= $this->options['max_retries']; $attempt++) {
            $success = $this->performDownload($tempPath, $downloaded, $totalSize);
            if ($success) {
                return $this->finalize($tempPath, $totalSize);
            }
            if ($attempt < $this->options['max_retries']) {
                sleep($this->options['retry_delay']);
                // 重新取得遠端資訊(可能大小變了)
                $remoteInfo = $this->getRemoteInfo();
                if (!$remoteInfo) break;
                $totalSize = $remoteInfo['size'];
                $downloaded = file_exists($tempPath) ? filesize($tempPath) : 0;
            }
        }

        $this->triggerComplete(false, $this->savePath, ['error' => '重試耗盡仍失敗']);
        return false;
    }

    private function getRemoteInfo(): ?array
    {
        $ch = curl_init($this->url);
        curl_setopt_array($ch, [
            CURLOPT_NOBODY => true,
            CURLOPT_HEADER => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 5,
            CURLOPT_CONNECTTIMEOUT => $this->options['connect_timeout'],
            CURLOPT_TIMEOUT => $this->options['timeout'],
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $response = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($code < 200 || $code >= 400) return null;

        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
        $headers = parseHeaders(substr($response, 0, $headerSize));

        return [
            'size' => (int)($headers['content-length'] ?? 0),
            'accept_ranges' => isset($headers['accept-ranges']) && stripos($headers['accept-ranges'], 'bytes') !== false,
            'content_type' => $headers['content-type'] ?? '',
            'etag' => $headers['etag'] ?? '',
            'last_modified' => $headers['last-modified'] ?? '',
        ];
    }

    private function performDownload(string $tempPath, int $offset, int $totalSize): bool
    {
        $fp = @fopen($tempPath, $offset > 0 ? 'ab' : 'wb');
        if (!$fp) return false;

        $ch = curl_init($this->url);
        $headers = [];
        if ($offset > 0) {
            $headers[] = "Range: bytes=$offset-";
        }

        curl_setopt_array($ch, [
            CURLOPT_URL => $this->url,
            CURLOPT_FILE => $fp,
            CURLOPT_HTTPHEADER => $headers ?: null,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_MAXREDIRS => 5,
            CURLOPT_CONNECTTIMEOUT => $this->options['connect_timeout'],
            CURLOPT_TIMEOUT => $this->options['timeout'],
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_NOPROGRESS => false,
            CURLOPT_PROGRESSFUNCTION => [$this, 'progressCallback'],
            CURLOPT_BUFFERSIZE => 128 * 1024, // 128KB 緩衝
            CURLOPT_LOW_SPEED_LIMIT => $this->options['speed_limit'] * 1024,
            CURLOPT_LOW_SPEED_TIME => 30,
        ]);

        $result = curl_exec($ch);
        $errno = curl_errno($ch);
        $error = curl_error($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        curl_close($ch);
        fclose($fp);

        if ($errno) {
            error_log("下載失敗: $error (code $errno)");
            return false;
        }

        // 206 Partial Content 或 200 OK(不支援 Range 時)
        return in_array($httpCode, [200, 206]);
    }

    private function progressCallback($resource, float $dltotal, float $dlnow, float $ultotal, float $ulnow): int
    {
        if ($this->onProgress && $dltotal > 0) {
            $downloaded = $dlnow;
            $progress = $downloaded / $dltotal * 100;
            $speed = $dlnow > 0 ? ($dlnow / 1024) / (curl_getinfo($resource, CURLINFO_TOTAL_TIME) ?: 1) : 0;
            $this->onProgress($progress, (int)$downloaded, (int)$dltotal, $speed);
        }
        // 速度限制邏輯(簡化)
        if ($this->options['speed_limit'] > 0) {
            $elapsed = curl_getinfo($resource, CURLINFO_TOTAL_TIME);
            if ($elapsed > 0) {
                $currentSpeed = ($dlnow / 1024) / $elapsed;
                if ($currentSpeed > $this->options['speed_limit']) {
                    usleep(100000); // 簡單節流
                }
            }
        }
        return 0; // 返回非 0 會中止下載
    }

    private function finalize(string $tempPath, int $expectedSize): bool
    {
        $actualSize = filesize($tempPath);
        if ($actualSize !== $expectedSize) {
            $this->triggerComplete(false, $this->savePath, ['error' => "大小不符: 期望 $expectedSize, 實際 $actualSize"]);
            return false;
        }

        // 完整性驗證
        if ($this->options['expected_hash']) {
            [$algo, $expected] = explode(':', $this->options['expected_hash'], 2);
            $actual = hash_file($algo, $tempPath);
            if (!hash_equals($actual, $expected)) {
                $this->triggerComplete(false, $this->savePath, ['error' => "雜湊不符: $algo"]);
                return false;
            }
        }

        // 原子性重命名
        if (!@rename($tempPath, $this->savePath)) {
            $this->triggerComplete(false, $this->savePath, ['error' => '重命名失敗']);
            return false;
        }

        // 保留遠端 Last-Modified 時間
        // touch($this->savePath, strtotime($remoteInfo['last_modified']));

        $this->triggerComplete(true, $this->savePath, ['size' => $actualSize]);
        return true;
    }

    private function triggerComplete(bool $success, string $path, array $info): void
    {
        if ($this->onComplete) {
            $this->onComplete($success, $path, $info);
        }
    }
}

function parseHeaders(string $raw): array
{
    $headers = [];
    foreach (explode("\n", $raw) as $line) {
        $line = trim($line);
        if ($line === '' || str_starts_with($line, 'HTTP/')) continue;
        [$k, $v] = array_map('trim', explode(':', $line, 2));
        $headers[strtolower($k)] = $v;
    }
    return $headers;
}
?>

使用範例

<?php
$url = 'https://releases.ubuntu.com/22.04.3/ubuntu-22.04.3-desktop-amd64.iso';
$save = '/data/iso/ubuntu-22.04.3.iso';
$expectedSha256 = 'sha256:...'; // 從官網取得

$downloader = new Downloader($url, $save, [
    'timeout' => 3600,
    'max_retries' => 5,
    'expected_hash' => $expectedSha256,
    'overwrite' => true,
]);

$downloader->onProgress = function(float $pct, int $dl, int $total, float $speed) {
    static $last = 0;
    if (time() - $last >= 2) { // 每 2 秒輸出一次
        echo sprintf("\r進度: %.1f%% (%s/%s) 速度: %.1f KB/s", $pct, formatBytes($dl), formatBytes($total), $speed);
        $last = time();
    }
};

$downloader->onComplete = function(bool $ok, string $path, array $info) {
    echo "\n";
    if ($ok) {
        echo "下載完成: $path (" . formatBytes($info['size']) . ")";
    } else {
        echo "下載失敗: {$info['error']}";
    }
};

$downloader->download();

function formatBytes(int $bytes): string
{
    $units = ['B','KB','MB','GB'];
    $i = 0;
    while ($bytes >= 1024 && $i < count($units)-1) { $bytes/=1024; $i++; }
    return round($bytes,2).' '.$units[$i];
}
?>

進階:多執行緒分段下載(大檔案加速)

<?php
function multiThreadDownload(string $url, string $savePath, int $threads = 4): bool
{
    // 1. 取得總大小、支援 Range
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_NOBODY=>true, CURLOPT_HEADER=>true, CURLOPT_RETURNTRANSFER=>true, CURLOPT_FOLLOWLOCATION=>true]);
    curl_exec($ch);
    $size = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
    curl_close($ch);
    if (!$size) return false;

    $partSize = ceil($size / $threads);
    $tempDir = sys_get_temp_dir() . '/dl_' . uniqid();
    mkdir($tempDir);

    $mh = curl_multi_init();
    $handles = [];

    for ($i = 0; $i < $threads; $i++) {
        $start = $i * $partSize;
        $end = ($i === $threads - 1) ? $size - 1 : $start + $partSize - 1;
        $partFile = "$tempDir/part_$i";

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RANGE => "$start-$end",
            CURLOPT_FILE => fopen($partFile, 'wb'),
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        curl_multi_add_handle($mh, $ch);
        $handles[] = $ch;
    }

    $running = null;
    do { curl_multi_exec($mh, $running); } while ($running > 0);

    // 合併檔案
    $fp = fopen($savePath, 'wb');
    foreach ($handles as $i => $ch) {
        $partFile = "$tempDir/part_$i";
        $content = file_get_contents($partFile);
        fwrite($fp, $content);
        unlink($partFile);
        curl_multi_remove_handle($mh, $ch);
        curl_close($ch);
    }
    fclose($fp);
    rmdir($tempDir);
    curl_multi_close($mh);

    return filesize($savePath) === $size;
}
?>

關鍵最佳實踐總結

場景建議配置
小檔案 (<10MB)單執行緒、無需斷點續傳、copy() 或簡單 cURL 即可
中型檔案 (10MB-1GB)斷點續傳、進度條、SHA256 驗證、重試 3 次
大檔案 (>1GB)多執行緒分段 (4-8 threads)、斷點續傳、速度限制(避免佔滿頻寬)、磁碟空間預檢
不穩定網路增加重試次數、指數退避延遲、支援斷點續傳必要
生產環境部署使用 Queue (Redis/RabbitMQ) + Worker 進程,避免阻塞 Web 請求

常見坑點避雷

  1. PHP 記憶體限制:下載大檔案時 memory_limit 不影響 cURL 寫入檔案,但若用 file_get_contents 會爆記憶體。
  2. max_execution_time:CLI 模式預設無限制,Web 模式需 set_time_limit(0) 或用 Worker 脫離 Web 進程。
  3. 臨時檔案殘留:下載失敗/中斷時 .part 檔案會殘留,需定期清理或啟動時掃描恢復。
  4. 權限問題:確保 PHP 進程對儲存目錄有寫入權限,rename() 需同檔案系統(跨分區會複製再刪除,大檔案慢)。
  5. SSL 憑證:生產環境必須驗證憑證,內網可用自簽憑證 + CURLOPT_CAINFO。

掌握 cURL 完整下載功能,讓你的 PHP 系統能可靠處理從小圖片到超大 ISO/備份檔的各種下載需求。

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