單純用 copy() 或 file_get_contents() 下載遠端檔案,大檔案易逾時、無進度、無法斷點續傳。本文提供生產級 Downloader 類別,基於 cURL 實現:**斷點續傳**、**即時進度回調**、**多執行緒分段下載**、**速度限制**、**完整性驗證(SHA256)**、**自動重試**。
<?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 請求 |
memory_limit 不影響 cURL 寫入檔案,但若用 file_get_contents 會爆記憶體。set_time_limit(0) 或用 Worker 脫離 Web 進程。rename() 需同檔案系統(跨分區會複製再刪除,大檔案慢)。CURLOPT_CAINFO。掌握 cURL 完整下載功能,讓你的 PHP 系統能可靠處理從小圖片到超大 ISO/備份檔的各種下載需求。