限制檔案下載速度不僅能避免瞬間頻寬耗盡導致網路擁塞,更是實作下載進度條的基礎。本文詳細解析 PHP 限速下載的核心原理與完整實作代碼。
flush() 才能即時更新前端進度<?php
/**
* 限速下載核心邏輯
*/
$file_path = '/path/to/large_file.zip';
$download_speed = 20.5 * 1024; // 限制 20.5 KB/s (位元組/秒)
if (!file_exists($file_path)) {
header('HTTP/1.1 404 Not Found');
exit('檔案不存在');
}
$file_size = filesize($file_path);
$file_name = basename($file_path);
// 關鍵 Header 設定
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file_name . '"');
header('Content-Length: ' . $file_size);
header('Content-Transfer-Encoding: binary');
header('Accept-Ranges: bytes'); // 支援斷點續傳
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Expires: 0');
// 開啟檔案
$fp = fopen($file_path, 'rb');
if (!$fp) {
header('HTTP/1.1 500 Internal Server Error');
exit('無法開啟檔案');
}
// 分塊輸出迴圈
$buffer_size = 1024 * 8; // 8KB 緩衝區
$start_time = microtime(true);
$bytes_sent = 0;
while (!feof($fp) && connection_status() === CONNECTION_NORMAL) {
$chunk = fread($fp, $buffer_size);
echo $chunk;
// 關鍵:強制輸出緩衝區到瀏覽器
flush();
$bytes_sent += strlen($chunk);
// 限速計算:已傳輸位元組 / 目標速度 = 應耗費時間
$elapsed = microtime(true) - $start_time;
$target_elapsed = $bytes_sent / $download_speed;
if ($target_elapsed > $elapsed) {
// 睡眠補足時間差(微秒)
usleep(($target_elapsed - $elapsed) * 1000000);
}
}
fclose($fp);
// 連線中斷處理
if (connection_status() !== CONNECTION_NORMAL) {
// 記錄未完成下載、清理暫存資源等
error_log("下載中斷: $file_name, 已傳輸 $bytes_sent / $file_size bytes");
}
?>
| 函數/特性 | 作用 |
|---|---|
flush() | 將 PHP 輸出緩衝區強制發送至 Web Server(再由 Web Server 發送給瀏覽器) |
connection_status() | 檢查客戶端連線狀態,CONNECTION_NORMAL 表示正常 |
usleep() | 微秒級睡眠,精確控制傳輸節奏 |
Content-Length | 告知瀏覽器檔案總大小,啟用進度條顯示 |
Accept-Ranges: bytes | 宣支援斷點續傳(需額外實作 Range 請求處理) |
<?php
class ThrottledDownload {
private $speed_limit; // bytes/second
private $buffer_size; // bytes
public function __construct($speed_kb = 100, $buffer_kb = 8) {
$this->speed_limit = $speed_kb * 1024;
$this->buffer_size = $buffer_kb * 1024;
}
public function download($file_path, $download_name = null) {
if (!is_file($file_path) || !is_readable($file_path)) {
throw new Exception('檔案不存在或不可讀');
}
$file_size = filesize($file_path);
$download_name = $download_name ?: basename($file_path);
// 發送 Header
$this->sendHeaders($file_size, $download_name);
// 執行限速傳輸
return $this->streamFile($file_path, $file_size);
}
private function sendHeaders($file_size, $file_name) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $file_name . '"');
header('Content-Length: ' . $file_size);
header('Content-Transfer-Encoding: binary');
header('Accept-Ranges: bytes');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
}
private function streamFile($file_path, $file_size) {
$fp = fopen($file_path, 'rb');
if (!$fp) throw new Exception('無法開啟檔案');
$bytes_sent = 0;
$start_time = microtime(true);
try {
while (!feof($fp) && connection_status() === CONNECTION_NORMAL) {
$chunk = fread($fp, $this->buffer_size);
if ($chunk === false) break;
echo $chunk;
flush();
$bytes_sent += strlen($chunk);
$this->throttle($bytes_sent, $start_time);
}
} finally {
fclose($fp);
}
return [
'completed' => connection_status() === CONNECTION_NORMAL,
'bytes_sent' => $bytes_sent,
'total_size' => $file_size
];
}
private function throttle($bytes_sent, $start_time) {
$elapsed = microtime(true) - $start_time;
$target_elapsed = $bytes_sent / $this->speed_limit;
if ($target_elapsed > $elapsed) {
usleep(($target_elapsed - $elapsed) * 1000000);
}
}
}
// 使用範例
$downloader = new ThrottledDownload(50); // 限速 50 KB/s
$result = $downloader->download('/var/files/large_video.mp4', 'my_video.mp4');
if ($result['completed']) {
echo "下載完成";
} else {
echo "下載中斷,已傳輸 {$result['bytes_sent']} bytes";
}
?>
配合限速下載,前端可使用 XMLHttpRequest 或 fetch 的 progress 事件即時更新進度條:
// JavaScript 範例
const xhr = new XMLHttpRequest();
xhr.open('GET', '/download.php?file=large.zip');
xhr.responseType = 'blob';
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total * 100).toFixed(1);
document.getElementById('progress-bar').style.width = percent + '%';
document.getElementById('progress-text').textContent = `${percent}%`;
}
});
xhr.addEventListener('load', () => {
// 處理 blob 下載儲存
const url = URL.createObjectURL(xhr.response);
const a = document.createElement('a');
a.href = url;
a.download = 'large.zip';
a.click();
});
xhr.send();
@ob_end_clean(); @ob_implicit_flush(true); 確保 flush() 生效fastcgi_buffering off; 或 Apache SetOutputFilter DEFLATE 可能干擾即時輸出fread 分塊讀取而非 file_get_contents 載入整個檔案../etc/passwd)