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

PHP 實現檔案下載斷點續傳:HTTP Range 處理完整指南

     編輯:PHP 綜合知識

前言:大檔案下載的必備技能

提供大型檔案 (安裝包、視頻、備份) 下載時,**斷點續傳** 是用戶體驗與頻寬節省的關鍵。核心在於正確處理 HTTP **Range 請求標頭**,回應 **206 Partial Content** 並輸出指定位元組範圍。本文提供生產級完整實現與細節解析。

HTTP Range 協議核心

標頭方向格式範例說明
Range請求`bytes=0-499` / `bytes=500-` / `bytes=-500`客戶端請求特定位元組範圍
Accept-Ranges回應`bytes`伺服器宣告支援範圍請求
Content-Range回應`bytes 0-499/2000`回應實際輸出範圍/總大小
Content-Length回應`500`本次回應實體長度 (非總檔案大小)
ETag / Last-Modified回應`"abc123"` / `Wed, 21 Oct 2015 07:28:00 GMT`快取驗證,配合 If-Range 使用

核心實現:dl_file_resume() 完整函數

<?php
/**
 * 檔案下載斷點續傳核心函數
 * @param string $file 檔案絕對路徑
 * @param array $options 選項: ['chunkSize'=>8192, 'speedLimit'=>0, 'disposition'=>'attachment']
 */
function dl_file_resume(string $file, array $options = []) {
    // 1. 基礎驗證
    if (!is_file($file) || !is_readable($file)) {
        http_response_code(404);
        exit('File not found or not readable');
    }
    
    $fileSize = filesize($file);
    $fileName = basename($file);
    $mimeType = getMimeType($file); // 見下方輔助函數
    
    // 2. 基礎回應標頭 (無論是否 Range 請求都需發送)
    header('Accept-Ranges: bytes');
    header('Content-Type: ' . $mimeType);
    header('Cache-Control: public, must-revalidate');
    header('Pragma: public'); // HTTP 1.0 相容
    
    // Content-Disposition: 处理文件名编码 (RFC 5987)
    $encodedName = rawurlencode($fileName);
    header("Content-Disposition: attachment; filename*=UTF-8''{$encodedName}; filename=\"{$fileName}\"");
    
    // 3. 處理 Range 請求
    $rangeHeader = $_SERVER['HTTP_RANGE'] ?? '';
    $start = 0;
    $end = $fileSize - 1;
    $statusCode = 200;
    
    if ($rangeHeader) {
        // 解析 Range: bytes=start-end
        if (!preg_match('/^bytes=(\d*)-(\d*)$/i', $rangeHeader, $matches)) {
            // 格式錯誤:忽略 Range,返回完整檔案
            error_log("Invalid Range header: $rangeHeader");
        } else {
            $start = $matches[1] !== '' ? (int)$matches[1] : 0;
            $end = $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
            
            // 邊界檢查
            if ($start >= $fileSize || $end >= $fileSize || $start > $end) {
                // 範圍不滿足:返回 416 Range Not Satisfiable
                header('Content-Range: bytes */' . $fileSize);
                http_response_code(416);
                exit;
            }
            
            $statusCode = 206; // Partial Content
            $contentLength = $end - $start + 1;
            
            // 關鍵標頭
            header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
            header('Content-Length: ' . $contentLength);
        }
    } else {
        // 無 Range:完整檔案
        header('Content-Length: ' . $fileSize);
    }
    
    // 4. 發送狀態碼 (必須在輸出內容前)
    http_response_code($statusCode);
    
    // 5. 輸出檔案內容
    $fp = fopen($file, 'rb');
    if (!$fp) {
        http_response_code(500);
        exit('Cannot open file');
    }
    
    // 定位到起始位置
    if ($start > 0) {
        fseek($fp, $start);
    }
    
    $chunkSize = $options['chunkSize'] ?? 8192; // 8KB
    $remaining = ($statusCode === 206) ? ($end - $start + 1) : $fileSize;
    $speedLimit = $options['speedLimit'] ?? 0; // 0 = 不限速
    
    try {
        while (!feof($fp) && $remaining > 0) {
            // 限速邏輯
            if ($speedLimit > 0) {
                $startTime = microtime(true);
            }
            
            $readSize = min($chunkSize, $remaining);
            $buffer = fread($fp, $readSize);
            if ($buffer === false || $buffer === '') break;
            
            echo $buffer;
            
            // 刷新輸出緩衝 (關鍵:讓瀏覽器即時收到資料)
            @ob_flush();
            @flush();
            
            $remaining -= strlen($buffer);
            
            // 限速延遲
            if ($speedLimit > 0) {
                $elapsed = microtime(true) - $startTime;
                $targetTime = strlen($buffer) / $speedLimit; // 秒
                if ($elapsed < $targetTime) {
                    usleep(($targetTime - $elapsed) * 1000000);
                }
            }
            
            // 檢查客戶端是否斷開連線
            if (connection_aborted()) {
                break; // 正常中斷,不報錯
            }
        }
    } finally {
        fclose($fp);
    }
    
    // 正常結束
    exit;
}

/**
 * 簡易 MIME 類型映射
 */
function getMimeType(string $file): string {
    $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
    $map = [
        'pdf' => 'application/pdf',
        'zip' => 'application/zip',
        'rar' => 'application/x-rar-compressed',
        '7z'  => 'application/x-7z-compressed',
        'exe' => 'application/octet-stream',
        'mp3' => 'audio/mpeg',
        'mp4' => 'video/mp4',
        'avi' => 'video/x-msvideo',
        'mov' => 'video/quicktime',
        'jpg' => 'image/jpeg',
        'jpeg'=> 'image/jpeg',
        'png' => 'image/png',
        'gif' => 'image/gif',
        'txt' => 'text/plain',
        'csv' => 'text/csv',
        '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';
}

// 使用範例
// dl_file_resume('/var/www/files/video.mp4', ['speedLimit' => 1024*1024]); // 限速 1MB/s
?>

關鍵細節深度解析

1. Range 解析的各種形式

bytes=0-499     // 前 500 位元組 (0~499)
bytes=500-999   // 第 500~999 位元組
bytes=-500      // 最後 500 位元組
bytes=500-      // 第 500 位元組到結尾
bytes=0-0,-1    // 第一個和最後一個位元組 (多範圍,本實現不支援,返回 200 全檔)

2. 狀態碼語意

  • 200 OK:無 Range 請求,或 Range 格式錯誤/不滿足時忽略 Range 返回完整檔案。
  • 206 Partial Content:成功處理 Range 請求,回應部份內容。
  • 416 Range Not Satisfiable:請求範圍無效 (超過檔案大小、start > end)。**必須**回應 `Content-Range: bytes */檔案大小`。

3. 大檔案記憶體安全:分塊輸出 + 刷新緩衝

// 關鍵三行:分塊讀取、輸出、強制刷新
$buffer = fread($fp, $chunkSize);
echo $buffer;
@ob_flush(); @flush(); // 穿透 PHP/Server 緩衝,直達瀏覽器
  • **chunkSize 8KB~64KB** 為佳,平衡系統呼叫開銷與記憶體佔用。
  • **必須 `flush()`**,否則大檔案會在伺服器端緩衝至記憶體耗盡或超時。
  • **`connection_aborted()` 檢測** 客戶端斷開,避免無意義繼續讀取/輸出。

4. 檔案名編碼:RFC 5987 標準

// 現代瀏覽器支援 filename*=UTF-8''encoded_name
$encoded = rawurlencode($fileName); // 空格→%20 非 ASCII→%XX
header("Content-Disposition: attachment; filename*=UTF-8''{$encoded}; filename=\"{$fileName}\"");
  • 雙重提供:`filename*` (現代) + `filename` (舊版 IE 相容)。
  • `rawurlencode` 而非 `urlencode` (後者空格編碼為 `+`,不符合 RFC)。

5. 限速實現 (Token Bucket 簡化版)


// 目標:平均速度 ≤ $speedLimit bytes/秒
$elapsed = microtime(true) - $chunkStartTime;
$expected = strlen($buffer) / $speedLimit;
if ($elapsed < $expected) usleep(($expected - $elapsed) * 1e6);

Nginx/Apache 協同優化 (X-Sendfile / X-Accel-Redirect)

**生產環境強烈建議**將檔案傳輸交給 Web Server 處理,PHP 僅做權限驗證與標頭設置:

Nginx: X-Accel-Redirect

# nginx.conf
location /protected/ {
    internal;           // 僅允許內部重定向
    alias /var/www/files/;
    # 可選:限速、斷點續傳由 Nginx 原生支援
    # limit_rate 1m;
}

# PHP
header('X-Accel-Redirect: /protected/video.mp4');
header('Content-Type: video/mp4');
header('Content-Disposition: ...');
exit; // PHP 結束,Nginx 接管傳輸

Apache: X-Sendfile (需 mod_xsendfile)

# httpd.conf
XSendFile on
XSendFilePath /var/www/files

# PHP
header('X-Sendfile: /var/www/files/video.mp4');
header('Content-Type: video/mp4');
exit;
  • **優勢**:零拷貝、高併發、原生斷點續傳、限速、SSL 卸載、PHP 進程即時釋放。
  • **前提**:檔案路徑在 Web Server 可讀範圍內、權限正確。

前端整合:支援斷點續傳的下載器

<script>
// 簡易斷點續傳下載器 (現代瀏覽器原生支援 Range,但手動控制可實現多線程)
async function downloadWithResume(url, fileName) {
    const response = await fetch(url, {method: 'HEAD'});
    const totalSize = parseInt(response.headers.get('Content-Length'));
    const supportsRange = response.headers.get('Accept-Ranges') === 'bytes';
    
    if (!supportsRange) {
        // 不支援斷點續傳,直接下載
        window.location.href = url;
        return;
    }
    
    // 這裡可實現多線程分段下載 + Blob 合併
    // 參考成熟庫:StreamSaver.js, FileSaver.js
}
</script>

對比:不支援斷點續傳的簡易下載

<?php
function simpleDownload($file) {
    if (!is_file($file)) die('404');
    
    $size = filesize($file);
    $name = basename($file);
    
    header('Content-Type: application/octet-stream');
    header('Content-Disposition: attachment; filename="' . $name . '"');
    header('Content-Length: ' . $size);
    header('Cache-Control: public');
    
    readfile($file); // 內部已處理分塊輸出,但無 Range 支援
    exit;
}
?>

總結:生產級檢查清單

  • ✅ 正確解析 `Range` 標頭,支援 `bytes=start-end`、`bytes=start-`、`bytes=-end`
  • ✅ 回應 `206 Partial Content` + `Content-Range` + `Content-Length` (範圍長度)
  • ✅ 邊界檢查:無效範圍返回 `416` + `Content-Range: bytes */total`
  • ✅ 分塊讀取 (`fread`) + `ob_flush()` + `flush()` 即時輸出,防記憶體溢出
  • ✅ `connection_aborted()` 檢測客戶端斷開
  • ✅ 檔案名 RFC 5987 編碼 (`filename*=UTF-8''...`)
  • ✅ MIME 類型正確映射
  • ✅ **生產環境啟用 Nginx X-Accel-Redirect / Apache X-Sendfile** 卸載傳輸
  • ✅ 權限驗證 (登入檢查、防盜鏈、下載次數限制) 在輸出前完成
  • ✅ ETag/Last-Modified 配合快取驗證

掌握此完整實現,即能為用戶提供企業級的大檔案下載體驗:斷點續傳、多線程下載支援、限速控制、高併發穩定傳輸。

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