PHP 實現文件下載的核心在於發送正確的 HTTP 響應頭,告知瀏覽器「這是附件、文件名、大小、類型」,並以二進制流輸出內容。但生產環境常面臨 大文件內存溢出、斷點續傳、中文文件名編碼、權限校驗、並發限流 等挑戰。本文從基礎 Headers 機制到生產級分塊傳輸類,構建完整知識體系。
| Header | 作用 | 推薦值 |
|---|---|---|
| Content-Type | MIME 類型 | application/octet-stream(通用二進制)或具體類型如 application/pdf |
| Content-Disposition | 指定下載行為與文件名 | attachment; filename="file.pdf"; filename*=UTF-8''file.pdf |
| Content-Length | 文件大小(字節),支持進度條 | filesize($path) |
| Content-Transfer-Encoding | 傳輸編碼 | binary |
| Accept-Ranges | 聲明支持斷點續傳 | bytes |
| Cache-Control | 緩存策略 | must-revalidate, post-check=0, pre-check=0(下載不緩存) |
| Pragma / Expires | 兼容 HTTP/1.0 緩存控制 | public / 0 |
<?php
function downloadFile(string $filePath, string $fileName = ''): void
{
if (!is_file($filePath) || !is_readable($filePath)) {
http_response_code(404);
exit('文件不存在或不可讀');
}
$fileName = $fileName ?: basename($filePath);
$fileSize = filesize($filePath);
// 發送響應頭
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode($fileName) . '"; filename*=UTF-8\'\'' . rawurlencode($fileName));
header('Content-Length: ' . $fileSize);
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');
// 輸出文件內容
readfile($filePath); // 內部已優化,比 file_get_contents + echo 更省內存
exit;
}
<?php
/**
* 生產級文件下載響應器
* 支持:分塊讀取、Range 斷點續傳、限速、權限回調、中文文件名
*/
class FileDownloader
{
private const CHUNK_SIZE = 1024 * 1024; // 1MB 分塊
private const DEFAULT_SPEED_LIMIT = 0; // 0 = 不限速 (B/s)
private string $filePath;
private string $fileName;
private int $fileSize;
private ?callable $authCallback;
private int $speedLimit;
private int $start = 0;
private int $end = 0;
public function __construct(
string $filePath,
string $fileName = '',
?callable $authCallback = null,
int $speedLimit = self::DEFAULT_SPEED_LIMIT
) {
$this->filePath = $filePath;
$this->fileName = $fileName ?: basename($filePath);
$this->authCallback = $authCallback;
$this->speedLimit = $speedLimit;
}
public function send(): void
{
$this->validate();
$this->parseRangeHeader();
$this->sendHeaders();
$this->streamBody();
}
private function validate(): void
{
if (!is_file($this->filePath) || !is_readable($this->filePath)) {
$this->error(404, '文件不存在');
}
$this->fileSize = filesize($this->filePath);
if ($this->authCallback && !$this->authCallback($this->filePath)) {
$this->error(403, '權限不足');
}
}
private function parseRangeHeader(): void
{
$range = $_SERVER['HTTP_RANGE'] ?? '';
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
$this->start = (int)$matches[1];
$this->end = $matches[2] !== '' ? (int)$matches[2] : $this->fileSize - 1;
$this->start = max(0, min($this->start, $this->fileSize - 1));
$this->end = max($this->start, min($this->end, $this->fileSize - 1));
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $this->start . '-' . $this->end . '/' . $this->fileSize);
} else {
$this->start = 0;
$this->end = $this->fileSize - 1;
}
}
private function sendHeaders(): void
{
$encodedName = rawurlencode($this->fileName);
header('Content-Type: application/octet-stream');
header("Content-Disposition: attachment; filename=\"$encodedName\"; filename*=UTF-8''$encodedName");
header('Content-Length: ' . ($this->end - $this->start + 1));
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');
}
private function streamBody(): void
{
$handle = fopen($this->filePath, 'rb');
if (!$handle) $this->error(500, '無法打開文件');
fseek($handle, $this->start);
$remaining = $this->end - $this->start + 1;
$lastTime = microtime(true);
while ($remaining > 0 && !feof($handle) && connection_status() === CONNECTION_NORMAL) {
$chunkSize = min(self::CHUNK_SIZE, $remaining);
$data = fread($handle, $chunkSize);
if ($data === false) break;
echo $data;
@flush(); // 刷新輸出緩衝區
$remaining -= strlen($data);
// 限速邏輯
if ($this->speedLimit > 0) {
$elapsed = microtime(true) - $lastTime;
$expected = strlen($data) / $this->speedLimit;
if ($elapsed < $expected) {
usleep(($expected - $elapsed) * 1_000_000);
}
$lastTime = microtime(true);
}
}
fclose($handle);
exit;
}
private function error(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
exit($message);
}
}
// 使用示例
$downloader = new FileDownloader(
'/data/exports/report_2024.xlsx',
'年度報表.xlsx',
function ($path) { return checkUserPermission($path); }, // 權限回調
1024 * 512 // 限速 512 KB/s
);
$downloader->send();
// 兼容舊瀏覽器 + 現代標準
header("Content-Disposition: attachment; filename=\"$encodedName\"; filename*=UTF-8''$encodedName");
// $encodedName = rawurlencode($fileName) // 空格→%20,非 ASCII→%XX
舊版 IE 只認 filename,現代瀏覽器優先用 filename*。雙重寫法全兼容。
Range: bytes=1000-(從 1000 字節到結尾)206 Partial Content + Content-Range: bytes 1000-1999/2000readfile() 內部已分塊,但無法控制限速、Range。手動 fread(1MB) + flush() 可精確控制內存峰值 ≈ 分塊大小,支撐 GB 級文件下載不爆內存。
realpath($path) 確保在允許目錄內,禁止 ../../etc/passwdPHP 負責權限校驗、生成內部路徑,Web 服務器直接發送文件,PHP 進程立即釋放:
<?php
// PHP 僅校驗權限、設置內部重定向頭
header('X-Accel-Redirect: /protected/files/report.xlsx'); // Nginx
// header('X-Sendfile: /protected/files/report.xlsx'); // Apache (需 mod_xsendfile)
header('Content-Disposition: attachment; filename="report.xlsx"');
header('Content-Type: application/octet-stream');
exit;
// Nginx 配置:
// location /protected/files/ {
// internal; // 禁止直接外部訪問
// alias /data/exports/; // 真實路徑映射
// sendfile on; // 內核零拷貝
// tcp_nopush on;
// }
優勢:PHP 進程毫秒級釋放、內核零拷貝零 CPU、支持超大文件、天然斷點續傳。生產環境大文件下載強烈推薦此架構。
readfile() 基礎版 |
| 10MB - 1GB | 中 | FileDownloader 分塊類(支持 Range、限速) |
| > 1GB 或 高併發 | 高 | Nginx X-Accel-Redirect 卸載到 Web 服務器 |
| 需權限控制 | 任意 | PHP 校驗 + X-Accel-Redirect 混合模式 |
掌握從基礎 Headers 到生產級分塊傳輸、再到 Web 服務器卸載的完整鏈路,能讓你根據業務體量靈活選型,構建高性能、安全、用戶體驗優異的文件下載服務。