使用 PHP 实现客户端提供断点下载的服务
需求描述
在 B/S 架构中,我们常常需要提供文件下载的功能。对于小文件,由于下载的时间短,一般不会出现什么问题。但是大文件就不一样,比如下载视频或者一些数据集,偶尔的网络原因可能会导致下载中断,又得需要从头再来,因而允许恢复下载很有用,能够大大提高用户体验。结合 Http 头,Accept-Ranges,Content-Range,Range,可以实现断点下载。
Http头
Accept-Ranges
服务器使用 HTTP 响应头 Accept-Ranges
标识自身支持范围请求(partial requests)。字段的具体值用于定义范围请求的单位。当浏览器发现 Accept-Ranges
头时,可以尝试继续中断了的下载,而不是重新开始。
语法:
Accept-Ranges: bytes
Accept-Ranges: none
none
不支持任何范围请求单位,由于其等同于没有返回此头部,因此很少使用。不过一些浏览器,比如IE9,会依据该头部去禁用或者移除下载管理器的暂停按钮。
bytes
范围请求的单位是 bytes (字节)。
Content-Range
在HTTP协议中,响应首部 Content-Range
显示的是一个数据片段在整个文件中的位置。
语法:
Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>
unit
数据区间所采用的单位。通常是字节(byte)。
range-start
一个整数,表示在给定单位下,区间的起始值。
range-end
一个整数,表示在给定单位下,区间的结束值。
size
整个文件的大小(如果大小未知则用"*"表示)。
Range
Range
是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range
首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content
状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable
状态码,表示客户端错误。服务器允许忽略 Range
首部,从而返回整个文件,状态码用 200
。
语法:
Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>
unit
范围所采用的单位,通常是字节(bytes)。
range-start
一个整数,表示在特定单位下,范围的起始值。
range-end
一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。
PHP 的实现示例
假设客户端的 Range
请求头只有一个范围。
<?php
$filePath = filter_input(INPUT_GET, "path");
if (empty($filePath)) {
exit("name empty");
}
if (!file_exists($filePath)) {
exit("file not exist: " . $filePath);
}
$fileSize = filesize($filePath);
$fInfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($fInfo, $filePath);
$start = 0;
$end = $fileSize;
if (isset($_SERVER['HTTP_RANGE'])) {
// if the HTTP_RANGE header is set we're dealing with partial content
$partialContent = true;
// find the requested range
// this might be too simplistic, apparently the client can request
// multiple ranges, which can become pretty complex, so ignore it for now
preg_match('/bytes=(\d+)-(\d+)?/', $_SERVER['HTTP_RANGE'], $matches);
$start = intval($matches[1]);
if (isset($matches[2])) {
$end = intval($matches[2]);
} else {
$end = $fileSize - 1;
}
} else {
$partialContent = false;
}
$readSize = $end - $start + 1;
header("Content-Disposition: attachment; filename=\"{$filePath}\"");
header("Content-Type: $mimeType");
header("Content-Length: " . $readSize);
//echo "Content-Length: " . $readSize . PHP_EOL;
if ($partialContent) {
// output the right headers for partial content
header('HTTP/1.1 206 Partial Content');
header('Content-Range: bytes ' . $start . '-' . ($end) . '/' . $fileSize);
// echo 'Content-Range: bytes ' . $start . '-' . ($end) . '/' . $fileSize . PHP_EOL;
} else {
header('Accept-Ranges: bytes');
}
$file = fopen($filePath, "r");
if ($start > 0) {
// seek to the requested offset, this is 0 if it's not a partial content request
fseek($file, $start);
}
$total = 0;
$chunk = 1024 * 16;
while (!feof($file) && $total < $readSize) {
if ($total + $chunk > $readSize) {
$length = $readSize - $total;
} else {
$length = $chunk;
}
$total += $length;
print(fread($file, $length));
// fread($file, $length);
ob_flush();
flush();
}
fclose($file);
运行示例
这里使用 curl
命令,只输出 PHP 返回的响应头,第一次请求:
$ curl -I "http://localhost/index.php?path=test.zip"
HTTP/1.1 200 OK
Server: nginx/1.19.0
Date: Sun, 02 Aug 2020 10:39:30 GMT
Content-Type: application/zip
Content-Length: 778
Connection: keep-alive
X-Powered-By: PHP/5.4.45
Content-Disposition: attachment; filename="test.zip"
Accept-Ranges: bytes
第2次请求,发送 Range
请求:
% curl -I -H "range: bytes=3-10" "http://localhost/index.php?path=test.zip"
HTTP/1.1 206 Partial Content
Server: nginx/1.19.0
Date: Sun, 02 Aug 2020 10:42:00 GMT
Content-Type: application/zip
Content-Length: 8
Connection: keep-alive
X-Powered-By: PHP/5.4.45
Content-Disposition: attachment; filename="test.zip"
Content-Range: bytes 3-10/777