PHP项目大文件下载如何断点续传

wen PHP项目 26

本文目录导读:

PHP项目大文件下载如何断点续传

  1. 基本断点续传实现
  2. 增强版(支持重试、多线程)
  3. 客户端断点续传(JavaScript)
  4. 性能优化建议
  5. 注意事项

在PHP中实现大文件下载的断点续传,核心是利用HTTP协议的Range请求头和Content-Range响应头,以下是完整的实现方案:

基本断点续传实现

<?php
function downloadFile($filePath) {
    if (!file_exists($filePath)) {
        header("HTTP/1.1 404 Not Found");
        exit;
    }
    $fileSize = filesize($filePath);
    $fileName = basename($filePath);
    // 设置响应头
    header("Content-Type: application/octet-stream");
    header("Content-Disposition: attachment; filename=\"{$fileName}\"");
    header("Accept-Ranges: bytes");
    header("Content-Length: {$fileSize}");
    // 处理断点续传
    if (isset($_SERVER['HTTP_RANGE'])) {
        $range = $_SERVER['HTTP_RANGE'];
        // 解析Range头
        preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
        $startPos = intval($matches[1]);
        $endPos = $matches[2] === '' ? $fileSize - 1 : intval($matches[2]);
        // 验证范围有效性
        if ($startPos > $endPos || $startPos >= $fileSize) {
            header("HTTP/1.1 416 Range Not Satisfiable");
            header("Content-Range: bytes */{$fileSize}");
            exit;
        }
        // 设置206 Partial Content状态码
        header("HTTP/1.1 206 Partial Content");
        header("Content-Range: bytes {$startPos}-{$endPos}/{$fileSize}");
        header("Content-Length: " . ($endPos - $startPos + 1));
        header("Cache-Control: no-cache");
        // 输出文件内容
        $fp = fopen($filePath, 'rb');
        fseek($fp, $startPos);
        $bytesToSend = $endPos - $startPos + 1;
        $chunkSize = 8192; // 8KB chunks
        while ($bytesToSend > 0 && !feof($fp)) {
            $readSize = min($chunkSize, $bytesToSend);
            echo fread($fp, $readSize);
            $bytesToSend -= $readSize;
            flush();
            ob_flush();
        }
        fclose($fp);
    } else {
        // 完整文件下载
        readfile($filePath);
    }
    exit;
}
// 使用示例
$filePath = '/path/to/large/file.zip';
downloadFile($filePath);
?>

增强版(支持重试、多线程)

<?php
class DownloadManager {
    private $filePath;
    private $fileName;
    private $fileSize;
    public function __construct($filePath) {
        $this->filePath = $filePath;
        $this->fileName = basename($filePath);
        $this->fileSize = filesize($filePath);
    }
    public function handleRequest() {
        if (!file_exists($this->filePath)) {
            header("HTTP/1.1 404 Not Found");
            exit("File not found");
        }
        $this->setHeaders();
        if (isset($_SERVER['HTTP_RANGE'])) {
            $this->handleRangeRequest();
        } else {
            $this->handleFullDownload();
        }
    }
    private function setHeaders() {
        header("Content-Type: application/octet-stream");
        header("Content-Disposition: attachment; filename=\"{$this->fileName}\"");
        header("Accept-Ranges: bytes");
        header("Cache-Control: no-store, no-cache, must-revalidate");
        header("Expires: 0");
        header("Pragma: no-cache");
    }
    private function handleRangeRequest() {
        $range = $_SERVER['HTTP_RANGE'];
        if (!preg_match('/bytes=(\d+)-(\d*)/', $range, $matches)) {
            header("HTTP/1.1 400 Bad Request");
            exit("Invalid Range header");
        }
        $startPos = intval($matches[1]);
        $endPos = $matches[2] === '' ? $this->fileSize - 1 : intval($matches[2]);
        // 验证范围
        if ($startPos > $endPos || $startPos >= $this->fileSize) {
            header("HTTP/1.1 416 Range Not Satisfiable");
            header("Content-Range: bytes */{$this->fileSize}");
            exit;
        }
        // 设置206状态码
        header("HTTP/1.1 206 Partial Content");
        header("Content-Range: bytes {$startPos}-{$endPos}/{$this->fileSize}");
        header("Content-Length: " . ($endPos - $startPos + 1));
        // 输出文件
        $this->outputFile($startPos, $endPos);
    }
    private function handleFullDownload() {
        header("Content-Length: {$this->fileSize}");
        $this->outputFile(0, $this->fileSize - 1);
    }
    private function outputFile($startPos, $endPos) {
        $fp = fopen($this->filePath, 'rb');
        if (!$fp) {
            header("HTTP/1.1 500 Internal Server Error");
            exit("Unable to open file");
        }
        fseek($fp, $startPos);
        $bytesRemaining = $endPos - $startPos + 1;
        $bufferSize = 8192; // 8KB
        set_time_limit(0); // 取消脚本执行时间限制
        while ($bytesRemaining > 0 && !feof($fp)) {
            $readSize = min($bufferSize, $bytesRemaining);
            echo fread($fp, $readSize);
            $bytesRemaining -= $readSize;
            // 刷新输出缓冲区
            if (ob_get_level()) {
                ob_flush();
            }
            flush();
            // 防止内存溢出
            if (connection_aborted()) {
                break;
            }
        }
        fclose($fp);
    }
}
// 使用示例
$downloader = new DownloadManager('/path/to/large/file.mkv');
$downloader->handleRequest();
?>

客户端断点续传(JavaScript)

class ResumeDownload {
    constructor(url, outputStream) {
        this.url = url;
        this.outputStream = outputStream;
        this.downloadedBytes = 0;
        this.abortController = new AbortController();
    }
    async start() {
        try {
            const response = await this.downloadChunk();
            await this.processResponse(response);
        } catch (error) {
            if (error.name === 'AbortError') {
                console.log('Download cancelled');
            } else {
                console.error('Download failed:', error);
                // 尝试恢复
                await this.resume();
            }
        }
    }
    async downloadChunk(startPos = 0) {
        const headers = {};
        if (startPos > 0) {
            headers['Range'] = `bytes=${startPos}-`;
        }
        return fetch(this.url, {
            headers: headers,
            signal: this.abortController.signal
        });
    }
    async processResponse(response) {
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const contentLength = response.headers.get('content-length');
        const totalSize = parseInt(contentLength) + this.downloadedBytes;
        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        while (true) {
            const { done, value } = await reader.read();
            if (done) {
                console.log('Download complete');
                break;
            }
            this.downloadedBytes += value.length;
            const progress = ((this.downloadedBytes / totalSize) * 100).toFixed(2);
            // 写入到输出流
            this.outputStream.write(value);
            // 更新进度
            this.onProgress(progress);
        }
    }
    onProgress(percent) {
        console.log(`Download progress: ${percent}%`);
    }
    async resume() {
        // 从上次下载位置继续
        await this.downloadChunk(this.downloadedBytes);
    }
    cancel() {
        this.abortController.abort();
    }
}
// 使用示例
async function downloadLargeFile() {
    const downloader = new ResumeDownload(
        'http://example.com/large-file.zip',
        new WritableStream()
    );
    try {
        await downloader.start();
    } catch (error) {
        console.error('Download failed:', error);
    }
}

性能优化建议

使用X-Sendfile(推荐)

// 使用Apache mod_xsendfile
header("X-Sendfile: {$filePath}");
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"{$fileName}\"");
// 使用Nginx X-Accel-Redirect
header("X-Accel-Redirect: /download/{$fileName}");
header("Content-Disposition: attachment; filename=\"{$fileName}\"");

数据库下载记录

// 记录下载进度到数据库
class DownloadTracker {
    private $db;
    public function saveProgress($fileId, $downloadedBytes) {
        $stmt = $this->db->prepare("UPDATE download_progress 
                                    SET downloaded_bytes = ? 
                                    WHERE file_id = ?");
        $stmt->execute([$downloadedBytes, $fileId]);
    }
    public function getProgress($fileId) {
        $stmt = $this->db->prepare("SELECT downloaded_bytes 
                                    FROM download_progress 
                                    WHERE file_id = ?");
        $stmt->execute([$fileId]);
        return $stmt->fetchColumn() ?: 0;
    }
}

注意事项

  1. 内存管理:使用chunk方式读取,避免一次性加载大文件到内存
  2. 超时设置:取消脚本执行时间限制 set_time_limit(0)
  3. 错误处理:处理连接中断、范围无效等情况
  4. 缓存头:设置正确的Cache-Control头,防止代理缓存
  5. 会话锁:使用session_write_close()避免会话锁

这个实现支持标准的断点续传功能,也兼容多线程下载工具(如IDM、迅雷等)的并发请求。

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