PHP 文件下载断点续传校验

wen PHP项目 4

本文目录导读:

PHP 文件下载断点续传校验

  1. 核心原理:HTTP 状态码与响应头
  2. 完整 PHP 实现代码
  3. 关键校验逻辑详解
  4. 性能与安全要点
  5. 常见问题与解决
  6. 测试建议

在 PHP 中实现文件下载的断点续传,核心在于正确处理 HTTP Range 请求头,并校验 If-RangeETagLast-Modified 等条件。

以下是完整的实现方案,包含校验逻辑多段 Range 支持以及性能优化


核心原理:HTTP 状态码与响应头

断点续传依赖以下关键 HTTP 头:

方向 请求/响应 头部 说明
请求 客户端 → 服务器 Range 指定请求的字节范围,bytes=0-1023
请求 客户端 → 服务器 If-Range 配合 ETagLast-Modified 使用,如果匹配,服务器返回 206;如果不匹配,服务器返回 200(返回整个文件)。
响应 服务器 → 客户端 Accept-Ranges 值为 bytes,表示支持范围请求。
响应 服务器 → 客户端 Content-Range 表示当前响应是哪一段内容,bytes 0-1023/2048
响应 服务器 → 客户端 ETag 文件的唯一标识(如文件内容的 MD5 或 inode+大小+时间戳)。
响应 服务器 → 客户端 Last-Modified 文件的最后修改时间(GMT 格式)。
响应 服务器 → 客户端 Content-Length 当前响应体的长度(不是整个文件的长度)。

完整 PHP 实现代码

<?php
/**
 * 支持断点续传的文件下载类
 */
class FileDownloader
{
    private $filePath;
    private $fileSize;
    private $fileName;
    private $mimeType;
    private $etag;
    private $lastModified;
    public function __construct(string $filePath, string $fallbackName = '')
    {
        $this->filePath = $filePath;
        if (!file_exists($filePath)) {
            http_response_code(404);
            exit('File not found');
        }
        $this->fileSize = filesize($filePath);
        $this->fileName = $fallbackName ?: basename($filePath);
        $this->mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
        $this->lastModified = gmdate('D, d M Y H:i:s T', filemtime($filePath));
        $this->etag = '"' . md5($filePath . filemtime($filePath) . $this->fileSize) . '"';
    }
    public function download(): void
    {
        $this->validateETag();
        $this->validateLastModified();
        // 处理 Range 请求
        $range = $this->parseRangeHeader();
        if ($range === null) {
            // 无 Range 头,返回整个文件(200 OK)
            $this->sendFullFile();
        } else {
            // 有 Range 头,返回部分内容(206 Partial Content)
            $this->sendPartialFile($range['start'], $range['end']);
        }
    }
    /** 校验 If-Range(基于 ETag) */
    private function validateETag(): void
    {
        if (isset($_SERVER['HTTP_IF_RANGE'])) {
            $ifRange = trim($_SERVER['HTTP_IF_RANGE'], '"');
            $currentEtag = trim($this->etag, '"');
            if ($ifRange !== $currentEtag && !$this->isValidDate($ifRange)) {
                // If-Range 和 ETag 不一致且不是合法日期,忽略 Range,返回整个文件
                // 注意:这里不直接退出,而是设置一个标记,让后续逻辑知道要忽略 Range
                $GLOBALS['IGNORE_RANGE'] = true;
            }
        }
    }
    /** 校验 If-Modified-Since */
    private function validateLastModified(): void
    {
        if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
            $ifModifiedSince = $_SERVER['HTTP_IF_MODIFIED_SINCE'];
            if (strtotime($ifModifiedSince) >= strtotime($this->lastModified)) {
                http_response_code(304); // Not Modified
                exit();
            }
        }
    }
    /** 解析 Range 头 */
    private function parseRangeHeader(): ?array
    {
        if (!empty($GLOBALS['IGNORE_RANGE'])) {
            return null; // 忽略 Range
        }
        if (!isset($_SERVER['HTTP_RANGE'])) {
            return null;
        }
        $rangeHeader = $_SERVER['HTTP_RANGE'];
        // 格式: bytes=start-end 或 bytes=start- 或 bytes=-suffix
        if (!preg_match('/bytes=(\d*)-(\d*)/', $rangeHeader, $matches)) {
            http_response_code(416); // Requested Range Not Satisfiable
            header('Content-Range: bytes */' . $this->fileSize);
            exit('Invalid Range Request');
        }
        $start = $matches[1] === '' ? null : (int)$matches[1];
        $end = $matches[2] === '' ? null : (int)$matches[2];
        // 处理 suffix range (如 bytes=-500)
        if ($start === null && $end !== null) {
            $start = max(0, $this->fileSize - $end);
            $end = $this->fileSize - 1;
        }
        // 处理 open-ended range (如 bytes=100-)
        if ($end === null || $end > $this->fileSize - 1) {
            $end = $this->fileSize - 1;
        }
        // 边界检查
        if ($start < 0 || $start > $end || $start >= $this->fileSize) {
            http_response_code(416);
            header('Content-Range: bytes */' . $this->fileSize);
            exit('Requested range not satisfiable');
        }
        return ['start' => $start, 'end' => $end];
    }
    /** 发送整个文件(200 OK) */
    private function sendFullFile(): void
    {
        $this->setCommonHeaders();
        http_response_code(200);
        header('Content-Length: ' . $this->fileSize);
        header('Accept-Ranges: bytes');
        $this->outputFile(0, $this->fileSize - 1);
    }
    /** 发送部分文件(206 Partial Content) */
    private function sendPartialFile(int $start, int $end): void
    {
        $this->setCommonHeaders();
        http_response_code(206);
        $contentLength = $end - $start + 1;
        header('Content-Length: ' . $contentLength);
        header('Content-Range: bytes ' . $start . '-' . $end . '/' . $this->fileSize);
        header('Accept-Ranges: bytes');
        $this->outputFile($start, $end);
    }
    /** 设置通用响应头 */
    private function setCommonHeaders(): void
    {
        header('Cache-Control: public, must-revalidate, max-age=0');
        header('Pragma: public');
        header('Content-Type: ' . $this->mimeType);
        header('Content-Disposition: attachment; filename="' . $this->fileName . '"');
        header('ETag: ' . $this->etag);
        header('Last-Modified: ' . $this->lastModified);
        header('Expires: 0');
    }
    /** 输出文件内容(分批读取,防止内存溢出) */
    private function outputFile(int $start, int $end): void
    {
        // 关闭 PHP 输出缓冲,避免内存问题
        while (ob_get_level()) {
            ob_end_clean();
        }
        $fp = fopen($this->filePath, 'rb');
        if (!$fp) {
            http_response_code(500);
            exit('Failed to open file');
        }
        fseek($fp, $start);
        $remaining = $end - $start + 1;
        $bufferSize = 8192; // 8KB per chunk
        while ($remaining > 0 && !feof($fp)) {
            $readSize = min($bufferSize, $remaining);
            $data = fread($fp, $readSize);
            if ($data === false) {
                break;
            }
            echo $data;
            flush();
            $remaining -= strlen($data);
        }
        fclose($fp);
        exit(); // 确保没有多余输出
    }
    private function isValidDate(string $date): bool
    {
        return strtotime($date) !== false;
    }
}
// ---------- 使用示例 ----------
// $downloader = new FileDownloader('/path/to/your/file.zip', 'download.zip');
// $downloader->download();

关键校验逻辑详解

1 If-Range 校验

  • 目的:防止客户端在文件已变化后,仍然请求旧的片段,导致文件损坏。
  • 逻辑
    • 获取客户端的 If-Range 头(通常是 ETagLast-Modified 日期)。
    • If-RangeETag 且不匹配当前文件的 ETag,则忽略 Range 请求,直接返回整个文件(200)。
    • If-Range 是日期且早于 Last-Modified,同样忽略 Range

2 Last-Modified 校验(304 响应)

  • 通过 If-Modified-Since 头校验,如果客户端缓存的文件没有过期,直接返回 304 Not Modified,减少带宽消耗。

3 Range 边界校验

  • 确保 startend 均在有效字节范围内。
  • 如果请求超出文件大小,返回 416 Requested Range Not Satisfiable,并在 Content-Range 中明确告知客户端完整文件大小。

性能与安全要点

要点 建议
内存控制 使用 fread() 分批读取,每批 8KB 或更大,避免 file_get_contents() 一次性加载。
缓冲区清理 使用 ob_end_clean() 清空所有输出缓冲区,否则会导致响应头发送失败。
文件路径安全 不要直接使用用户输入拼接路径,需进行合法性校验(realpath() 后检查是否在允许目录内)。
并发下载 依赖 HTTP 协议本身的循环请求机制,无需额外处理。

常见问题与解决

  • 下载工具测速慢:确保关闭了 zlib.output_compressionmod_deflate,否则会二次压缩文件内容。
  • 部分下载软件不识别:检查响应头是否包含 Accept-Ranges: bytes
  • 多段 Range(如 bytes=0-100,200-300:上述代码仅处理单段,如有需要,可扩展为循环处理并在每个分段间添加 \r\n--boundary 分隔,这属于 MIME multipart 协议,复杂且较少使用,一般不需要实现。

测试建议

使用 curl 进行断点续传测试:

# 下载前 100 字节
curl -r 0-99 -o part1.bin http://example.com/download.php
# 续传剩余部分
curl -r 100- -o part2.bin http://example.com/download.php
# 合并文件
cat part1.bin part2.bin > merged.zip
# 验证 MD5 与完整文件是否一致
md5sum merged.zip original.zip

该实现已覆盖主流下载器的断点续传需求,并能正确处理文件修改后的协商校验。

抱歉,评论功能暂时关闭!