支援 HTTP Range 請求的檔案下載功能,能讓使用者暫停/繼續下載、多執行緒下載、拖曳播放影音進度。本文提供生產級 PHP 實作,包含 MIME 偵測、Range 解析、206 Partial Content 回應、大檔案記憶體控制等完整邏輯。
<?php
/**
* 檔案下載器 - 支援 Range/斷點續傳/大檔案/MIME 自動偵測
*/
class FileDownloader {
private $filePath;
private $fileName;
private $mimeType;
private $fileSize;
private $fp;
public function __construct(string $filePath, string $fileName = '') {
$this->filePath = $filePath;
$this->fileName = $fileName ?: basename($filePath);
$this->fileSize = filesize($filePath);
$this->mimeType = $this->detectMimeType($filePath);
}
public function download(): void {
// 基礎檢查
if (!file_exists($this->filePath) || !is_readable($this->filePath)) {
$this->sendError(404, '檔案不存在或不可讀');
}
$this->fp = fopen($this->filePath, 'rb');
if (!$this->fp) {
$this->sendError(500, '無法開啟檔案');
}
// 解析 Range 標頭
$range = $this->parseRangeHeader();
[$start, $end, $length] = $range;
// 發送回應標頭
$this->sendHeaders($start, $end, $length);
// 輸出檔案內容
$this->outputFile($start, $length);
fclose($this->fp);
exit;
}
/**
* 解析 HTTP_RANGE 標頭
* @return array [start, end, length]
*/
private function parseRangeHeader(): array {
$start = 0;
$end = $this->fileSize - 1;
$isRange = false;
if (isset($_SERVER['HTTP_RANGE'])) {
$rangeHeader = $_SERVER['HTTP_RANGE'];
if (preg_match('/^bytes=(\d+)-(\d*)$/i', $rangeHeader, $matches)) {
$isRange = true;
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $this->fileSize - 1;
} elseif (preg_match('/^bytes=-(\d+)$/i', $rangeHeader, $matches)) {
// 最後 N 位元組:bytes=-1024
$isRange = true;
$end = $this->fileSize - 1;
$start = $this->fileSize - (int)$matches[1];
}
}
// 邊界修正
$start = max(0, min($start, $this->fileSize - 1));
$end = max($start, min($end, $this->fileSize - 1));
$length = $end - $start + 1;
return [$start, $end, $length];
}
/**
* 發送 HTTP 標頭
*/
private function sendHeaders(int $start, int $end, int $length): void {
// 清除輸出緩衝
if (ob_get_level()) ob_end_clean();
// 通用標頭
header('Content-Type: ' . $this->mimeType);
header('Content-Disposition: attachment; filename="' . $this->fileName . '"');
header('Accept-Ranges: bytes');
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
if ($start > 0 || $end < $this->fileSize - 1) {
// 部分內容回應
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $this->fileSize);
header('Content-Length: ' . $length);
} else {
// 完整檔案
header('Content-Length: ' . $this->fileSize);
}
// 針對舊版 IE 的快取修正
if (isset($_SERVER['HTTP_USER_AGENT']) && strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE') !== false) {
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
}
}
/**
* 分塊輸出檔案內容
*/
private function outputFile(int $start, int $length): void {
fseek($this->fp, $start);
$bufferSize = 1024 * 16; // 16KB
$remaining = $length;
while ($remaining > 0 && !feof($this->fp) && connection_status() === CONNECTION_NORMAL) {
$readSize = min($bufferSize, $remaining);
echo fread($this->fp, $readSize);
$remaining -= $readSize;
flush(); // 立即發送給客戶端
}
}
/**
* MIME 類型偵測
*/
private function detectMimeType(string $path): string {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$mimeMap = [
// 圖片
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'bmp' => 'image/bmp',
'svg' => 'image/svg+xml', 'ico' => 'image/vnd.microsoft.icon',
// 文件
'pdf' => 'application/pdf', 'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'txt' => 'text/plain', 'csv' => 'text/csv', 'rtf' => 'application/rtf',
// 壓縮
'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed',
'7z' => 'application/x-7z-compressed', 'gz' => 'application/gzip',
'tar' => 'application/x-tar',
// 音視訊
'mp3' => 'audio/mpeg', 'wav' => 'audio/wav', 'ogg' => 'audio/ogg',
'mp4' => 'video/mp4', 'webm' => 'video/webm', 'avi' => 'video/x-msvideo',
'mov' => 'video/quicktime', 'flv' => 'video/x-flv', 'mkv' => 'video/x-matroska',
// 程式碼
'js' => 'application/javascript', 'json' => 'application/json',
'xml' => 'application/xml', 'css' => 'text/css', 'html' => 'text/html',
'php' => 'text/html',
// 執行檔
'exe' => 'application/x-msdownload', 'msi' => 'application/x-msdownload',
'apk' => 'application/vnd.android.package-archive',
];
return $mimeMap[$ext] ?? 'application/octet-stream';
}
private function sendError(int $code, string $message): void {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
exit($message);
}
}
// 使用範例
$downloader = new FileDownloader('/var/www/files/video.mp4', '我的影片.mp4');
$downloader->download();
?>
瀏覽器極少發送多範圍,但標準要求支援。回應需使用 multipart/byteranges:
// 簡易實作概念
if (preg_match_all('/bytes=(\d+)-(\d*)/i', $rangeHeader, $matches, PREG_SET_ORDER)) {
if (count($matches) > 1) {
header('Content-Type: multipart/byteranges; boundary=THIS_STRING_SEPARATES');
foreach ($matches as $m) {
echo "--THIS_STRING_SEPARATES\r\n";
echo "Content-Type: {$this->mimeType}\r\n";
echo "Content-Range: bytes {$m[1]}-{$m[2]}/{$this->fileSize}\r\n\r\n";
// 輸出該範圍內容...
}
echo "--THIS_STRING_SEPARATES--\r\n";
exit;
}
}
// 在 outputFile 迴圈中加入
$bytesPerSecond = 1024 * 1024; // 1MB/s
$startTime = microtime(true);
$totalSent = 0;
while (...) {
// ...輸出資料...
$totalSent += $readSize;
$elapsed = microtime(true) - $startTime;
$expectedTime = $totalSent / $bytesPerSecond;
if ($expectedTime > $elapsed) {
usleep(($expectedTime - $elapsed) * 1000000);
}
}
// 下載前記錄
$log = [
'file' => $this->fileName,
'ip' => $_SERVER['REMOTE_ADDR'],
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'range' => $_SERVER['HTTP_RANGE'] ?? 'full',
'time' => date('Y-m-d H:i:s'),
];
file_put_contents('/var/log/downloads.log', json_encode($log) . "\n", FILE_APPEND | LOCK_EX);
PHP 讀取檔案輸出會佔用 PHP-FPM 進程,高併發下影響效能。生產環境建議交給 Web Server 處理:
# nginx.conf
location /protected/ {
internal; # 禁止直接外部存取
alias /var/www/files/; # 實際檔案目錄
# 可加入限速、權限驗證等
}
<?php
// PHP 僅驗證權限,發送特殊 Header 交給 Nginx 處理傳輸
if ($userHasPermission) {
header('X-Accel-Redirect: /protected/video.mp4');
header('Content-Type: video/mp4');
header('Content-Disposition: attachment; filename="video.mp4"');
exit;
}
?>
# httpd.conf
XSendFile On
XSendFilePath /var/www/files/
<?php
header('X-Sendfile: /var/www/files/video.mp4');
header('Content-Type: video/mp4');
header('Content-Disposition: attachment; filename="video.mp4"');
exit;
?>
fread + flush 分塊輸出,避免 readfile 佔用大量記憶體set_time_limit(0) 防止大檔案傳輸中途被殺ob_end_clean() 清除輸出緩衝Accept-Ranges: bytes 支援拖曳播放Content-Length 讓下載進度條正確顯示總結:完整的斷點續傳下載需要正確解析 Range 標頭、回應 206 狀態碼、發送 Content-Range、分塊輸出控制記憶體。生產環境強烈建議配合 Nginx/Apache 的 X-Sendfile 機制,將檔案傳輸卸載給 Web Server,PHP 僅負責權限驗證與 Header 發送。