強制下載(而非瀏覽器預覽)是文件分享、導出報表、附件下載的常見需求。PHP 通過發送特定 HTTP Headers 配合 readfile() 即可實現。本文詳解 Headers 含義、大文件分塊傳輸、內存控制、安全防範及 Nginx/Apache 配置協同。
| Header | 值示例 | 作用 | 必要性 |
|---|---|---|---|
Content-Description | File Transfer | 文件傳輸描述(參考用) | 可選 |
Content-Type | application/octet-stream | 通用二進制流,強制瀏覽器下載而非預覽 | **核心** |
Content-Disposition | attachment; filename="report.xlsx" | 指示為附件,指定下載文件名(需 URL 編碼) | **核心** |
Content-Transfer-Encoding | binary | 傳輸編碼為二進制 | 推薦 |
Expires | 0 | 過期時間為過去,防止緩存 | 推薦 |
Cache-Control | must-revalidate, post-check=0, pre-check=0 | 強制重新驗證,不緩存(HTTP/1.1) | 推薦 |
Pragma | public | HTTP/1.0 兼容,允許緩存(舊瀏覽器需 public 才能下載) | 兼容 |
Content-Length | 1234567 | 文件字節數,**支持斷點續傳、進度條** | **核心** |
<?php
function forceDownload(string $filePath, string $downloadName = ''): void {
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('文件不存在或不可讀');
}
$downloadName = $downloadName ?: basename($filePath);
$fileSize = filesize($filePath);
// 發送 Headers
header('Content-Description: File Transfer');
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
header('Content-Transfer-Encoding: binary');
header('Expires: 0');
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
header('Pragma: public');
header('Content-Length: ' . $fileSize);
// 清理輸出緩衝,防止內容污染
ob_clean();
flush();
// 直接輸出文件內容
readfile($filePath);
exit;
}
// 使用
forceDownload('/data/exports/report_20240115.xlsx', '月度報表.xlsx');
function forceDownloadLarge(string $filePath, string $downloadName = ''): void {
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('文件不存在');
}
$downloadName = $downloadName ?: basename($filePath);
$fileSize = filesize($filePath);
$chunkSize = 1024 * 1024 * 8; // 8MB 分塊
// 支持 Range 請求(斷點續傳、多線程下載)
$rangeStart = 0;
$rangeEnd = $fileSize - 1;
$httpRange = $_SERVER['HTTP_RANGE'] ?? '';
if ($httpRange) {
// 解析 Range: bytes=0-1023
if (preg_match('/bytes=(\d+)-(\d*)/', $httpRange, $matches)) {
$rangeStart = (int)$matches[1];
$rangeEnd = $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
if ($rangeStart >= $fileSize || $rangeEnd >= $fileSize) {
header('HTTP/1.1 416 Range Not Satisfiable');
header('Content-Range: bytes */' . $fileSize);
exit;
}
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
$contentLength = $rangeEnd - $rangeStart + 1;
}
} else {
header('HTTP/1.1 200 OK');
$contentLength = $fileSize;
}
// 通用 Headers
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
header('Content-Transfer-Encoding: binary');
header('Accept-Ranges: bytes'); // 聲明支持斷點續傳
header('Content-Length: ' . $contentLength);
ob_clean();
flush();
// 分塊讀取輸出
$handle = fopen($filePath, 'rb');
fseek($handle, $rangeStart);
$remaining = $contentLength;
while ($remaining > 0 && !feof($handle) && (connection_status() === CONNECTION_NORMAL)) {
$readSize = min($chunkSize, $remaining);
echo fread($handle, $readSize);
$remaining -= $readSize;
flush(); // 及時發送到客戶端
}
fclose($handle);
exit;
}
basename() + realpath() 確保文件在允許目錄內。
$baseDir = '/data/downloads/';
$requested = $_GET['file'] ?? '';
$realBase = realpath($baseDir);
$realFile = realpath($baseDir . $requested);
if (!$realFile || strpos($realFile, $realBase) !== 0) {
exit('非法文件路徑');
}
rawurlencode() 防止中文文件名亂碼(RFC 5987 規範更複雜,簡單場景此函數足夠)。// PHP 僅驗證權限,發送內部重定向 Header
header('X-Accel-Redirect: /protected/files/' . $relativePath);
header('Content-Disposition: attachment; filename="' . rawurlencode($name) . '"');
exit;
// Nginx 配置
location /protected/files/ {
internal; # 禁止直接外部訪問
alias /data/downloads/; # 真實文件路徑
sendfile on; # 零拷貝
tcp_nopush on;
tcp_nodelay on;
}
優勢:PHP 進程瞬間釋放,Nginx 直接以內核態發送文件,支持超大文件、高併發、斷點續傳,無內存壓力。
// PHP
header('X-Sendfile: /data/downloads/' . $relativePath);
// Apache 配置需啟用 mod_xsendfile: XSendFile On, XSendFilePath /data/downloads
| 現象 | 可能原因 | 解決 |
|---|---|---|
| 下載文件損壞/打不開 | 輸出前有多餘輸出(空白行、BOM、echo、var_dump) | 開啟 ob_clean(),確保文件純淨;檢查文件編碼為無 BOM UTF-8 |
| 大文件下載中斷/內存耗盡 | 使用 readfile() 或 file_get_contents() 讀入內存 | 改用分塊 fread() 或 Nginx X-Accel-Redirect |
| 中文文件名亂碼 | Filename 未編碼或編碼不符合瀏覽器預期 | 使用 rawurlencode(),或 RFC 5987 格式:filename*=UTF-8''%E6%96%87%E4%BB%B6.txt |
| 斷點續傳失效 | 未發送 Accept-Ranges: bytes、未處理 Range Header | 實現 Range 解析與 206 響應 |
| 瀏覽器直接顯示而不下載 | Content-Type 被識別為可預覽類型(如 image/jpeg) | 強制設置 application/octet-stream + Content-Disposition: attachment |
readfile() + 完整 Headers 簡單有效。