本文目录导读:

在 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
该实现已覆盖主流下载器的断点续传需求,并能正确处理文件修改后的协商校验。