本文目录导读:

在 PHP 中实现文件下载的断点续传,核心在于正确处理 HTTP Range 请求头,并校验 If-Range、ETag 或 Last-Modified 等条件。
以下是完整的实现方案,包含校验逻辑、多段 Range 支持以及性能优化。
核心原理:HTTP 状态码与响应头
断点续传依赖以下关键 HTTP 头:
| 方向 | 请求/响应 | 头部 | 说明 |
|---|---|---|---|
| 请求 | 客户端 → 服务器 | Range |
指定请求的字节范围,bytes=0-1023 |
| 请求 | 客户端 → 服务器 | If-Range |
配合 ETag 或 Last-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头(通常是ETag或Last-Modified日期)。 If-Range是ETag且不匹配当前文件的ETag,则忽略Range请求,直接返回整个文件(200)。If-Range是日期且早于Last-Modified,同样忽略Range。
- 获取客户端的
2 Last-Modified 校验(304 响应)
- 通过
If-Modified-Since头校验,如果客户端缓存的文件没有过期,直接返回304 Not Modified,减少带宽消耗。
3 Range 边界校验
- 确保
start和end均在有效字节范围内。 - 如果请求超出文件大小,返回
416 Requested Range Not Satisfiable,并在Content-Range中明确告知客户端完整文件大小。
性能与安全要点
| 要点 | 建议 |
|---|---|
| 内存控制 | 使用 fread() 分批读取,每批 8KB 或更大,避免 file_get_contents() 一次性加载。 |
| 缓冲区清理 | 使用 ob_end_clean() 清空所有输出缓冲区,否则会导致响应头发送失败。 |
| 文件路径安全 | 不要直接使用用户输入拼接路径,需进行合法性校验(realpath() 后检查是否在允许目录内)。 |
| 并发下载 | 依赖 HTTP 协议本身的循环请求机制,无需额外处理。 |
常见问题与解决
- 下载工具测速慢:确保关闭了
zlib.output_compression或mod_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
该实现已覆盖主流下载器的断点续传需求,并能正确处理文件修改后的协商校验。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播