使用 PHP 实现客户端提供断点下载的服务

Php 2020-08-02 阅读 41 评论 0

需求描述

在 B/S 架构中,我们常常需要提供文件下载的功能。对于小文件,由于下载的时间短,一般不会出现什么问题。但是大文件就不一样,比如下载视频或者一些数据集,偶尔的网络原因可能会导致下载中断,又得需要从头再来,因而允许恢复下载很有用,能够大大提高用户体验。结合 Http 头,Accept-RangesContent-RangeRange,可以实现断点下载。

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
最后更新 2020-08-04
MIP.watch('startSearch', function (newVal, oldVal) { if(newVal) { var keyword = MIP.getData('keyword'); console.log(keyword); // 替换当前历史记录,新增 MIP.viewer.open('/s/' + keyword, {replace: true}); setTimeout(function () { MIP.setData({startSearch: false}) }, 1000); } }); MIP.watch('goHome', function (newVal, oldVal) { MIP.viewer.open('/', {replace: false}); });