基本下載類別
<?php
class DownFile {
private $basePath = './files/'; // 檔案實際儲存目錄
/**
* 輸出檔案供下載
* @param string $fileName 用戶看到的下載檔名
* @param string $saveAs 實體檔名(可不同)
*/
public function download(string $fileName, string $saveAs = '') {
$saveAs = $saveAs ?: $fileName;
$filePath = $this->basePath . $saveAs;
// 1. 檔案存在性檢查
if (!file_exists($filePath) || !is_readable($filePath)) {
header('HTTP/1.1 404 Not Found');
exit('檔案不存在或不可讀');
}
// 2. 中文檔名編碼處理(相容 IE/Edge/Chrome/Firefox)
$encodedName = $this->encodeFilename($fileName);
// 3. 取得檔案資訊
$fileSize = filesize($filePath);
$mimeType = $this->getMimeType($filePath);
// 4. 輸出標頭
header('Content-Type: ' . $mimeType);
header('Content-Disposition: attachment; filename="' . $encodedName . '"');
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes'); // 支援斷點續傳
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
// 5. 斷點續傳支援
$range = $this->parseRangeHeader($fileSize);
if ($range) {
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $range[0] . '-' . $range[1] . '/' . $fileSize);
header('Content-Length: ' . ($range[1] - $range[0] + 1));
}
// 6. 串流輸出(避免大檔佔用記憶體)
$this->streamFile($filePath, $range);
exit;
}
/** RFC 5987 編碼:filename*=UTF-8''encoded_name */
private function encodeFilename(string $name): string {
$encoded = rawurlencode($name);
// 替換保留字元
$encoded = str_replace(['%', '+'], ['%25', '%20'], $encoded);
return "UTF-8''{$encoded}";
}
/** 解析 Range 標頭 */
private function parseRangeHeader(int $fileSize): ?array {
$rangeHeader = $_SERVER['HTTP_RANGE'] ?? '';
if (!$rangeHeader || !preg_match('/bytes=(\d+)-(\d*)/', $rangeHeader, $m)) {
return null;
}
$start = (int)$m[1];
$end = $m[2] ? (int)$m[2] : $fileSize - 1;
$end = min($end, $fileSize - 1);
return ($start <= $end) ? [$start, $end] : null;
}
/** 分塊讀取輸出 */
private function streamFile(string $path, ?array $range) {
$handle = fopen($path, 'rb');
if (!$handle) return;
if ($range) fseek($handle, $range[0]);
$bufferSize = 1024 * 8; // 8KB
$remaining = $range ? ($range[1] - $range[0] + 1) : filesize($path);
while (!feof($handle) && $remaining > 0) {
$readSize = min($bufferSize, $remaining);
echo fread($handle, $readSize);
$remaining -= $readSize;
@ob_flush();
@flush();
}
fclose($handle);
}
/** 簡易 MIME 判斷 */
private function getMimeType(string $path): string {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$map = [
'pdf' => 'application/pdf',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif',
'txt' => 'text/plain', 'csv' => 'text/csv',
'mp4' => 'video/mp4', 'mp3' => 'audio/mpeg',
];
return $map[$ext] ?? 'application/octet-stream';
}
}
// 使用範例
$down = new DownFile();
$down->download('產品手冊_2024.pdf', 'manual_v2.pdf');
?>
關鍵技術細節
| 功能 | 實作要點 |
| 中文檔名 |
RFC 5987 filename*=UTF-8''... 相容現代瀏覽器;舊版 IE 需額外 filename="..." 雙重寫入 |
| 大檔不佔記憶體 |
fread() 分塊讀取 + ob_flush()/flush() 即時輸出 |
| 斷點續傳 |
解析 HTTP_RANGE,回應 206 Partial Content + Content-Range |
| 防止腳本超時 |
長時間傳輸前 set_time_limit(0); ignore_user_abort(true); |
| 安全性 |
嚴格驗證路徑遍歷(basename()、realpath() 比對基底目錄) |
Nginx/Apache 代理環境注意事項
- Nginx
proxy_buffering off; 避免緩衝導致大檔案記憶體暴增
- Apache
mod_xsendfile 或 Nginx X-Accel-Redirect 讓 Web Server 直接傳檔,PHP 僅驗證權限
- 關閉
gzip 對二進位檔案壓縮(gzip_types 排除 application/octet-stream)
進階:X-Sendfile / X-Accel-Redirect 零拷貝下載
<?php
// 權限驗證通過後
$filePath = '/var/www/files/secret/manual.pdf';
$fileSize = filesize($filePath);
// Nginx 設定需在 location 內:
// location /protected/ { internal; alias /var/www/files/; }
header('X-Accel-Redirect: /protected/manual.pdf');
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="manual.pdf"');
header('Content-Length: ' . $fileSize);
header('Accept-Ranges: bytes');
exit;
// PHP 進程立即釋放,由 Nginx 高效傳輸檔案
?>
總結
- 小檔、簡單場景:
readfile() 或分塊 fread() 即可
- 中文檔名:務必雙重編碼(RFC 5987 + 降級)
- 大檔、高併發:改用
X-Sendfile/X-Accel-Redirect 卸載 PHP
- 斷點續傳:實作 Range 請求處理,提升用戶體驗
- 安全第一:路徑遍歷防禦、權限驗證、敏感目錄隔離