類別特性
- 支援 HTTP Range 斷點續傳
- 分塊下載 (可設定塊大小)
- 暫存進度至 .data 檔案,支援跨程序恢復
- 自動處理 301/302 重導向
- Socket 層面實作,無需 cURL 擴展
完整類別代碼
<?php
class Downloader {
/**
* 下載檔案至本地
* @param string $url 來源 URL
* @param string $save_file 本地儲存路徑
* @param int $speed 每次讀取位元組數 (預設 10KB)
* @param array $headers 額外 HTTP 標頭
* @param int $timeout 連線超時秒數
* @return bool
* @throws Exception
*/
public static function get($url, $save_file, $speed = 10240, $headers = [], $timeout = 10) {
$url_info = self::parse_url($url);
if (!$url_info['host']) throw new Exception('Invalid URL');
// 預設標頭
$def_headers = [
'Accept' => '*/*',
'User-Agent' => 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)',
'Accept-Encoding' => 'gzip, deflate',
'Host' => $url_info['host'],
'Connection' => 'Close',
'Accept-Language' => 'zh-cn',
];
$headers = array_merge($def_headers, $headers);
// 取得檔案大小
$content_length = self::get_content_size($url_info['host'], $url_info['port'], $url_info['request'], $headers, $timeout);
if (!$content_length) throw new Exception('Content-Length not exists');
// 檢查現有檔案
$exists_length = is_file($save_file) ? filesize($save_file) : 0;
if ($exists_length == $content_length) return true; // 已完成
// 進度暫存檔
$data_file = $save_file . '.data';
$exists_data = is_file($data_file) ? json_decode(file_get_contents($data_file), true) : [];
// 驗證暫存有效性
if (($exists_data['length'] ?? 0) != $content_length || $exists_length > $content_length) {
$exists_data = ['length' => $content_length];
}
file_put_contents($data_file, json_encode($exists_data));
try {
$result = self::download_content($url_info['host'], $url_info['port'], $url_info['request'], $save_file, $content_length, $exists_length, $speed, $headers, $timeout);
if ($result && is_file($data_file)) @unlink($data_file);
return true;
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
/** 解析 URL */
private static function parse_url($url) {
$info = parse_url($url);
if (!$info['host']) return false;
$info['port'] = $info['port'] ?? 80;
$info['request'] = $info['path'] . ($info['query'] ? '?' . $info['query'] : '');
return $info;
}
/** 分塊下載核心 */
private static function download_content($host, $port, $url_path, $save_file, $content_length, $range_start, $speed, &$headers, $timeout) {
$request = self::build_header('GET', $url_path, $headers, $range_start);
$fsocket = @fsockopen($host, $port, $errno, $errstr, $timeout);
if (!$fsocket) throw new Exception("Connect failed: $errstr");
stream_set_blocking($fsocket, true);
stream_set_timeout($fsocket, $timeout);
fwrite($fsocket, $request);
$status = stream_get_meta_data($fsocket);
if ($status['timed_out']) throw new Exception('Socket connect timeout');
$header_end = false;
$total = $range_start;
$fp = fopen($save_file, 'a+');
while (!feof($fsocket)) {
if (!$header_end) {
$line = fgets($fsocket);
if (in_array($line, ["\n", "\r\n"])) $header_end = true;
continue;
}
$chunk = fread($fsocket, $speed);
$len = strlen($chunk);
if ($chunk === false || $content_length < $total + $len) {
fclose($fsocket); fclose($fp);
throw new Exception('Socket I/O error or file changed');
}
$total += $len;
fwrite($fp, $chunk);
if ($content_length == $total) break;
sleep(1); // 節流,實際可移除
}
fclose($fsocket);
fclose($fp);
return true;
}
/** 取得 Content-Length (HEAD 請求) */
private static function get_content_size($host, $port, $url_path, &$headers, $timeout) {
$request = self::build_header('HEAD', $url_path, $headers);
$fsocket = @fsockopen($host, $port, $errno, $errstr, $timeout);
if (!$fsocket) return 0;
stream_set_blocking($fsocket, true);
stream_set_timeout($fsocket, $timeout);
fwrite($fsocket, $request);
if (stream_get_meta_data($fsocket)['timed_out']) { fclose($fsocket); return 0; }
$length = 0;
while (!feof($fsocket)) {
$line = fgets($fsocket);
if (in_array($line, ["\n", "\r\n"])) break;
$line = strtolower($line);
// 處理重導向
if (strpos($line, 'location:') === 0) {
$location = trim(substr($line, 9));
$info = self::parse_url($location);
if ($info['host']) {
fclose($fsocket);
return self::get_content_size($info['host'], $info['port'], $info['request'], $headers, $timeout);
}
}
if (strpos($line, 'content-length:') !== false) {
$length = (int)trim(explode(':', $line)[1]);
}
}
fclose($fsocket);
return $length;
}
/** 建立 HTTP 請求標頭 */
private static function build_header($action, $url_path, &$headers, $range_start = -1) {
$out = "$action $url_path HTTP/1.0\r\n";
foreach ($headers as $k => $v) $out .= "$k: $v\r\n";
if ($range_start > -1) {
$out .= "Accept-Ranges: bytes\r\n";
$out .= "Range: bytes=$range_start-\r\n";
}
$out .= "\r\n";
return $out;
}
}
// 使用範例
try {
if (Downloader::get('https://example.com/largefile.zip', 'largefile.zip')) {
echo '下載完成';
}
} catch (Exception $e) {
echo '下載失敗: ' . $e->getMessage();
}
?>
核心機制解析
| 機制 | 實作方式 |
| 斷點續傳 | HTTP Range 標頭 bytes=START-,續傳位置從現有檔案大小計算 |
| 進度持久化 | JSON 暫存檔記錄總長度,跨程序/重啟可恢復 |
| 重導向處理 | HEAD 請求遇到 Location 標頭遞迴追蹤 |
| 錯誤恢復 | Socket 逾時、檔案大小不符、連線中斷皆拋出異常,上層可重試 |
生產環境建議
- 大檔案建議使用
cURL (支援多執行緒、斷點續傳、限速、代理)
- 併發下載:將檔案分段,多執行緒/進程平行下載再合併
- 驗證完整性:下載完成後驗證 MD5/SHA256/CRC32
- 考慮使用
aria2c、wget -c 等成熟工具