首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 通用檔案下載類:支援斷點續傳、速度限制、大檔案、中文檔名

     編輯:PHP 相關知識

前言

提供檔案下載是 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; // 確保腳本結束
}
?>

核心功能解析

1. HTTP Range 斷點續傳

  • 解析 Range: bytes=start-end,支援 bytes=500- (從 500 到結尾)、bytes=-500 (最後 500 位元組)。
  • 回應 206 Partial Content + Content-Range + Accept-Ranges: bytes。
  • 下載工具 (IDM、瀏覽器內建) 自動支援暫停/續傳/多執行緒。

2. 恆定記憶體流式輸出

  • 使用 fread($handle, 8192) 分塊讀取,echo + flush() 即時輸出。
  • 記憶體佔用恆定約 bufferSize (8KB),支援任意大小檔案 (TB 級)。
  • connection_status() 檢測客戶端斷線,立即停止讀取釋放資源。

3. 下載速度限制 (Token Bucket 簡化版)

  • 計算每個封包理想耗時 packetTime = bufferSize * 1e6 / speedLimit (微秒)。
  • 每次 fread+echo+flush 後測量實際耗時,不足則 usleep() 補齊。
  • 精度約 ±10%,足以防止單一用戶佔滿頻寬。

4. 快取驗證 (ETag + Last-Modified)

  • ETag 基於 inode+mtime+size 產生,檔案內容變更即改變。
  • 支援 If-None-Match、If-Modified-Since,命中回傳 304 Not Modified 零流量。
  • Cache-Control: public, max-age=31536000 允許瀏覽器/CDN 快取 1 年。

5. 中文檔名 RFC 5987 相容

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/Apache 代理下的注意事項

# 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 (預簽名) 等功能。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved