提供檔案下載是 Web 應用常見需求。單純 readfile() 存在諸多問題:無法斷點續傳、佔用大量記憶體、無速度控制、中文檔名編碼錯誤、無快取驗證。本文實作一個生產級 FileDownloader 類別,完整支援 HTTP Range 斷點續傳、流式輸出恆定記憶體、下載速度限制、ETag/Last-Modified 快取驗證、RFC 5987 中文檔名。
<?php
/**
* 生產級檔案下載器
* 功能:Range 斷點續傳、速度限制、快取驗證、中文檔名、大檔案流式輸出
*/
class FileDownloader {
private string $filePath;
private string $downloadName;
private bool $forceDownload;
private int $speedLimit; // bytes/sec, 0 = 無限制
private string $mimeType;
private int $bufferSize = 8192; // 8KB 緩衝區
public function __construct(
string $filePath,
string $downloadName = '',
bool $forceDownload = true,
int $speedLimit = 0,
string $mimeType = ''
) {
$this->filePath = $filePath;
$this->downloadName = $downloadName ?: basename($filePath);
$this->forceDownload = $forceDownload;
$this->speedLimit = $speedLimit;
$this->mimeType = $mimeType ?: $this->guessMimeType();
}
public function send(): bool {
// 1. 基本檢查
if (!is_file($this->filePath) || !is_readable($this->filePath)) {
$this->sendError(404, 'File not found');
return false;
}
$stat = stat($this->filePath);
$fileSize = $stat['size'];
$lastModified = $stat['mtime'];
$etag = $this->generateEtag($stat);
// 2. 快取驗證 (If-Modified-Since, If-None-Match)
if ($this->checkCache($lastModified, $etag)) {
return true; // 304 Not Modified 已送出
}
// 3. 解析 Range 標頭
$range = $this->parseRange($fileSize);
$isPartial = $range !== null;
$start = $isPartial ? $range[0] : 0;
$end = $isPartial ? $range[1] : $fileSize - 1;
$contentLength = $end - $start + 1;
// 4. 發送回應標頭
$this->sendHeaders($fileSize, $lastModified, $etag, $isPartial, $start, $end, $contentLength);
// 5. 流式輸出檔案內容
return $this->streamFile($start, $contentLength);
}
private function guessMimeType(): string {
$ext = strtolower(pathinfo($this->filePath, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp',
'zip' => 'application/zip', 'gz' => 'application/gzip',
'txt' => 'text/plain', 'csv' => 'text/csv',
'mp4' => 'video/mp4', 'mp3' => 'audio/mpeg',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
];
return $map[$ext] ?? 'application/octet-stream';
}
private function generateEtag(array $stat): string {
$hash = md5($stat['ino'] . '-' . $stat['mtime'] . '-' . $stat['size']);
return '"' . substr($hash, 0, 16) . '"';
}
private function checkCache(int $lastModified, string $etag): bool {
// If-Modified-Since
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$ims = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($ims !== false && $ims >= $lastModified) {
$this->sendError(304, 'Not Modified');
return true;
}
}
// If-None-Match
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
$this->sendError(304, 'Not Modified');
return true;
}
return false;
}
private function parseRange(int $fileSize): ?array {
if (empty($_SERVER['HTTP_RANGE'])) return null;
// Range: bytes=start-end
if (!preg_match('/^bytes=(\d*)-(\d*)$/i', $_SERVER['HTTP_RANGE'], $m)) {
return null; // 格式錯誤,忽略 Range
}
$start = $m[1] !== '' ? (int)$m[1] : 0;
$end = $m[2] !== '' ? (int)$m[2] : $fileSize - 1;
// 處理尾部範圍: bytes=-500 (最後 500 位元組)
if ($m[1] === '' && $m[2] !== '') {
$start = $fileSize - $end;
$end = $fileSize - 1;
}
// 邊界修正
$start = max(0, min($start, $fileSize - 1));
$end = max($start, min($end, $fileSize - 1));
if ($start > $end) return null;
return [$start, $end];
}
private function sendHeaders(
int $fileSize, int $lastModified, string $etag,
bool $isPartial, int $start, int $end, int $contentLength
): void {
// 狀態碼
if ($isPartial) {
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes $start-$end/$fileSize");
} else {
header('HTTP/1.1 200 OK');
}
// 快取相關
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $lastModified) . ' GMT');
header('ETag: ' . $etag);
header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=31536000'); // 1 年快取
header('Pragma: public');
// 內容類型與長度
header('Content-Type: ' . $this->mimeType);
header('Content-Length: ' . $contentLength);
// 下載檔名 (RFC 5987 + 相容舊瀏覽器)
$encoded = rawurlencode($this->downloadName);
if ($this->forceDownload) {
header("Content-Disposition: attachment; filename*=UTF-8''{$encoded}; filename=\"{$encoded}\"");
} else {
header("Content-Disposition: inline; filename*=UTF-8''{$encoded}; filename=\"{$encoded}\"");
}
header('Content-Transfer-Encoding: binary');
}
private function streamFile(int $start, int $contentLength): bool {
$handle = fopen($this->filePath, 'rb');
if (!$handle) {
$this->sendError(500, 'Cannot open file');
return false;
}
fseek($handle, $start);
$sent = 0;
// 速度限制計算
$packetTime = $this->speedLimit > 0
? ($this->bufferSize * 1_000_000 / $this->speedLimit) // 微秒
: 0;
while ($sent < $contentLength && !feof($handle) && connection_status() === CONNECTION_NORMAL) {
if ($packetTime > 0) {
$t0 = microtime(true);
}
$readLen = min($this->bufferSize, $contentLength - $sent);
$buffer = fread($handle, $readLen);
if ($buffer === false) break;
echo $buffer;
$sent += strlen($buffer);
// 刷新輸出緩衝
if (ob_get_level() > 0) ob_flush();
flush();
if ($packetTime > 0) {
$t1 = microtime(true);
$elapsed = ($t1 - $t0) * 1_000_000; // 微秒
$sleep = $packetTime - $elapsed;
if ($sleep > 0) usleep((int)$sleep);
}
}
fclose($handle);
return connection_status() === CONNECTION_NORMAL;
}
private function sendError(int $code, string $message): void {
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
}
}
// === 使用範例 ===
// 1. 基本下載
$downloader = new FileDownloader('/var/www/files/report.pdf', '月報.pdf');
$downloader->send();
// 2. 限速 100 KB/s,線上預覽
$downloader = new FileDownloader(
'/var/www/files/video.mp4',
'教學影片.mp4',
forceDownload: false,
speedLimit: 100 * 1024 // 100 KB/s
);
$downloader->send();
// 3. 在控制器中整合
function downloadAction(string $fileId) {
$file = FileRepository::find($fileId);
if (!$file) { http_response_code(404); return; }
$downloader = new FileDownloader(
$file->getStoragePath(),
$file->getOriginalName(),
forceDownload: true,
speedLimit: $file->isPremium() ? 0 : 512 * 1024 // 免費用戶限速 512KB/s
);
$downloader->send();
exit; // 確保腳本結束
}
?>
Range: bytes=start-end,支援 bytes=500- (從 500 到結尾)、bytes=-500 (最後 500 位元組)。206 Partial Content + Content-Range + Accept-Ranges: bytes。fread($handle, 8192) 分塊讀取,echo + flush() 即時輸出。bufferSize (8KB),支援任意大小檔案 (TB 級)。connection_status() 檢測客戶端斷線,立即停止讀取釋放資源。packetTime = bufferSize * 1e6 / speedLimit (微秒)。fread+echo+flush 後測量實際耗時,不足則 usleep() 補齊。ETag 基於 inode+mtime+size 產生,檔案內容變更即改變。If-None-Match、If-Modified-Since,命中回傳 304 Not Modified 零流量。Cache-Control: public, max-age=31536000 允許瀏覽器/CDN 快取 1 年。Content-Disposition: attachment; filename*=UTF-8''%E6%9C%88%E5%A0%B1.pdf; filename="%E6%9C%88%E5%A0%B1.pdf"
filename* 現代瀏覽器支援 UTF-8 編碼。filename 相容舊瀏覽器 (URL 編碼)。rawurlencode() 而非 urlencode() (空格編為 %20 而非 +)。
# Nginx 必須關閉 proxy_buffering,否則大檔案會被緩衝到磁碟再傳給客戶端
proxy_buffering off;
proxy_request_buffering off;
# 若使用 X-Accel-Redirect (Nginx 直接傳檔,PHP 僅驗證權限)
location /protected/ {
internal;
alias /var/www/files/;
# 仍可支援 Range、速度限制需在 Nginx 層設定 limit_rate
}
FileDownloader 類別以 單一職責、零依賴、生產就緒 解決檔案下載所有核心痛點。關鍵設計:Range 解析 + 206 回應、fread/flush 恆定記憶體、微秒級速度控制、ETag/Last-Modified 雙重快取、RFC 5987 檔名編碼。直接複製到專案 Utils/ 目錄即可使用,亦可擴充加入 權限驗證、下載日誌、簽名 URL (預簽名) 等功能。