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

PHP 實作檔案斷點續傳(Resume Download)完整教學

     編輯:PHP 綜合知識

核心原理

斷點續傳靠 HTTP Range 標頭實現:

  1. Client 發送 Range: bytes=1000- 表示從第 1001 byte 開始傳輸。
  2. Server 回應 206 Partial Content,附上 Content-Range 與 Content-Length。
  3. Server 使用 fseek() 移動檔案指標,從指定位置讀取並輸出。

最小可用實作 (單一檔案)

<?php
// 配置
$filePath = __DIR__ . '/downloads/large-video.mp4';
$fileName = 'large-video.mp4';

if (!is_file($filePath)) {
    http_response_code(404);
    exit('檔案不存在');
}

$fileSize = filesize($filePath);
$start = 0;
$end = $fileSize - 1;

// 解析 Range 標頭
if (isset($_SERVER['HTTP_RANGE'])) {
    $range = $_SERVER['HTTP_RANGE'];
    // 格式: bytes=start-end 或 bytes=start-
    if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $matches)) {
        $start = (int)$matches[1];
        $end   = $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
        
        // 邊界檢查
        if ($start >= $fileSize || $end >= $fileSize || $start > $end) {
            header('HTTP/1.1 416 Range Not Satisfiable');
            header("Content-Range: bytes */$fileSize");
            exit;
        }
    }
}

// 設定回應標頭
header('Content-Type: video/mp4');
header('Accept-Ranges: bytes');
header('Content-Length: ' . ($end - $start + 1));

if ($start > 0 || $end < $fileSize - 1) {
    // 部分內容
    header('HTTP/1.1 206 Partial Content');
    header("Content-Range: bytes $start-$end/$fileSize");
} else {
    // 完整內容
    header('HTTP/1.1 200 OK');
}

// 快取控制(可選)
header('Cache-Control: public, max-age=3600');
header("Content-Disposition: inline; filename=\"$fileName\"");

// 開啟檔案並輸出
$fp = fopen($filePath, 'rb');
fseek($fp, $start);

$bufferSize = 1024 * 8; // 8KB
$remaining = $end - $start + 1;

while (!feof($fp) && $remaining > 0) {
    $readSize = min($bufferSize, $remaining);
    echo fread($fp, $readSize);
    $remaining -= $readSize;
    
    // 釋放輸出緩衝,避免大檔案佔用記憶體
    if (ob_get_level()) ob_flush();
    flush();
}
fclose($fp);

進階版:支援多 Range、ETag、大檔案優化

class ResumableDownload {
    private $filePath;
    private $fileName;
    private $mimeType;
    private $bufferSize = 8192;
    
    public function __construct(string $filePath, string $fileName = '', string $mimeType = '') {
        $this->filePath = $filePath;
        $this->fileName = $fileName ?: basename($filePath);
        $this->mimeType = $mimeType ?: $this->guessMimeType();
    }
    
    public function send(): void {
        if (!is_file($this->filePath)) {
            $this->error(404, 'File not found');
        }
        
        $fileSize = filesize($this->filePath);
        $etag = md5($this->filePath . $fileSize . filemtime($this->filePath));
        
        // If-None-Match / If-Modified-Since 快取驗證
        if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
            $this->error(304, 'Not Modified');
        }
        if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
            $since = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
            if ($since >= filemtime($this->filePath)) {
                $this->error(304, 'Not Modified');
            }
        }
        
        // 處理 Range
        $ranges = $this->parseRangeHeader($fileSize);
        
        // 設定通用標頭
        header("ETag: $etag");
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($this->filePath)) . ' GMT');
        header('Accept-Ranges: bytes');
        header('Content-Type: ' . $this->mimeType);
        header("Content-Disposition: inline; filename=\"{$this->fileName}\"");
        
        if ($ranges === null) {
            // 無 Range:傳送完整檔案
            header('HTTP/1.1 200 OK');
            header("Content-Length: $fileSize");
            $this->outputFile(0, $fileSize - 1);
        } elseif (count($ranges) === 1) {
            // 單一 Range
            [$start, $end] = $ranges[0];
            header('HTTP/1.1 206 Partial Content');
            header("Content-Range: bytes $start-$end/$fileSize");
            header("Content-Length: " . ($end - $start + 1));
            $this->outputFile($start, $end);
        } else {
            // 多重 Range (multipart/byteranges) - 略,實務較少用
            $this->sendMultipartRanges($ranges, $fileSize);
        }
    }
    
    private function parseRangeHeader(int $fileSize): ?array {
        if (empty($_SERVER['HTTP_RANGE'])) return null;
        
        $rangeHeader = $_SERVER['HTTP_RANGE'];
        if (!preg_match('/^bytes=(.+)$/', $rangeHeader, $m)) return null;
        
        $ranges = [];
        foreach (explode(',', $m[1]) as $part) {
            $part = trim($part);
            if (preg_match('/^(\d*)-(\d*)$/', $part, $m2)) {
                $start = $m2[1] !== '' ? (int)$m2[1] : 0;
                $end   = $m2[2] !== '' ? (int)$m2[2] : $fileSize - 1;
                
                // suffix-byte-range: bytes=-500 (最後 500 bytes)
                if ($m2[1] === '' && $m2[2] !== '') {
                    $start = $fileSize - $end;
                    $end = $fileSize - 1;
                }
                
                if ($start < 0) $start = 0;
                if ($end >= $fileSize) $end = $fileSize - 1;
                if ($start <= $end) {
                    $ranges[] = [$start, $end];
                }
            }
        }
        return $ranges ?: null;
    }
    
    private function outputFile(int $start, int $end): void {
        $fp = fopen($this->filePath, 'rb');
        fseek($fp, $start);
        $remaining = $end - $start + 1;
        
        while (!feof($fp) && $remaining > 0) {
            $read = min($this->bufferSize, $remaining);
            echo fread($fp, $read);
            $remaining -= $read;
            
            if (ob_get_level()) ob_flush();
            flush();
        }
        fclose($fp);
    }
    
    private function guessMimeType(): string {
        $ext = strtolower(pathinfo($this->filePath, PATHINFO_EXTENSION));
        $map = [
            'mp4' => 'video/mp4', 'mkv' => 'video/x-matroska',
            'mp3' => 'audio/mpeg', 'wav' => 'audio/wav',
            'pdf' => 'application/pdf', 'zip' => 'application/zip',
            'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
            'png' => 'image/png', 'gif' => 'image/gif',
        ];
        return $map[$ext] ?? 'application/octet-stream';
    }
    
    private function error(int $code, string $msg): void {
        http_response_code($code);
        exit($msg);
    }
}

// 使用方式
$downloader = new ResumableDownload('/data/videos/course01.mp4');
$downloader->send();

前端整合:HTML5 Video / Audio 原生支援

瀏覽器原生 `

常見問題與解決

生產環境最佳架構:X-Sendfile / X-Accel-Redirect

讓 PHP 僅負責權限驗證,實際檔案傳輸交給 Web Server (Nginx/Apache) 處理,效能最佳且支援所有斷點續傳特性:

// PHP 僅驗證權限並發送特殊 Header
header('X-Accel-Redirect: /protected-files/video.mp4'); // Nginx
// 或 header('X-Sendfile: /protected-files/video.mp4'); // Apache + mod_xsendfile
header('Content-Type: video/mp4');
header('Content-Disposition: inline; filename="video.mp4"');
exit;

Nginx 設定:

location /protected-files/ {
    internal;                    # 僅允許內部重導向
    alias /data/private-files/;  # 實際檔案路徑
    sendfile on;                 # 核心層零拷貝傳輸
    tcp_nopush on;
    tcp_nodelay on;
}

總結

  • 核心在於正確解析 Range、回應 206 Partial Content、設定 Content-Range 與 Accept-Ranges: bytes。
  • 大檔案務必分塊輸出並 flush(),避免 PHP 記憶體耗盡。
  • 生產環境強烈建議使用 Nginx X-Accel-Redirect 或 Apache X-Sendfile 卸載傳輸負擔。
  • 搭配 ETag、Last-Modified 實現完整 HTTP 快取機制。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved
問題原因解決
拖曳進度條無效/重新載入Server 未回應 206 或缺少 Accept-Ranges確認回應標頭正確,啟用 Accept-Ranges: bytes
大檔案記憶體耗盡一次讀取整個檔案或輸出緩衝未釋放分塊讀取 + ob_flush()/flush(),或用 X-Sendfile/NGINX X-Accel-Redirect
Nginx 代理切斷傳輸proxy_buffering / proxy_max_temp_file_size 限制設定 proxy_buffering off; 或調大快取參數
多執行緒下載工具失敗不支援多 Range 或合併邏輯錯誤實作 multipart/byteranges 或引導使用單線程