PHP写一个兼容性好的获取远程文件大小函数

之前写的没发,抽空整理一下,非网上复制类文章,可以看看。

https://cdn.jsdelivr.net/gh/cooldev-cn/cdn@latest/img/20200421214143.png

导图使用markmap生成

原理

主要是从http的响应header头中获取Content-Length字段即是文件大小,可在不下载文件情况下获取远程文件大小

应用

  1. 文件下载
  2. 文件抓取
  3. 文件大小比较

获取远程文件大小

可使用的方法:

  1. get_headers

  2. curl

  3. file_get_contents

可选择的请求方式:

  1. GET

  2. HEAD

HEAD方法介绍:

请求资源的头部信息, 并且这些头部与 HTTP GET 方法请求时返回的一致. 该请求方法的一个使用场景是在下载一个大文件前先获取其大小再决定是否要下载, 以此可以节约带宽资源.

所以使用HEAD方式是更加好的方式,考虑到远程文件可能较大,不推荐使用file_get_contents来获取。

1
2
3
4
5
// !!!不要使用!!! file_get_contents会把文件内容写到内存,遇到大文件直接内存超出
function getRemoteFileSizeByFileGetContents($url)
{
    return strlen(file_get_contents($url));
}

get_headers

使用 get_headers 取得服务器响应一个 HTTP 请求所发送的所有标头

get_headers默认使用GET方式去请求远程文件

1
2
3
4
5
// !!!不要使用!!! 不完善,请继续往下看
function getRemoteFileSize($url)
{
  return @get_headers($url, 1)['Content-Length']??null;
}

**这么简单? 并不是,这个方法有问题。**如果远程链接是包含重定向的,get_headers会跟随重定向,默认次数是20次,会把每一次响应头都输出,我们给了get_headers第二个参数为1,会让header头以关联数组格式返回,导致部分键变成了数组,如下面的Content-Length就是一个数组,此时获取的结果肯定是错误的。

包含重定向链接举例: https://work.weixin.qq.com/wework_admin/commdownload?platform=win&from=wwindex (企业微信win客户端下载链接)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Array
(
    // 说明有重定向
    [0] => HTTP/1.1 302 Found
    [Server] => Array
        (
            [0] => nginx
            [1] => nws_yybmid_hy
        )
    [Date] => Array
        (
            [0] => Mon, 20 Apr 2020 12:06:40 GMT
            [1] => Fri, 10 Apr 2020 00:31:43 GMT
        )
    [Content-Type] => Array
        (
            [0] => text/plain; charset=utf-8
            [1] => application/octet-stream
        )
    [Content-Length] => Array	//返回了2个长度
        (
            [0] => 85
            [1] => 295714632
        )
    [Connection] => Array
        (
            [0] => close
            [1] => close
        )
    [Location] => https://dldir1.qq.com/wework/work_weixin/WXWork_3.0.16.1614.exe
    [Vary] => Accept
    // 重定向后状态码为200
    [1] => HTTP/1.0 200 OK
    [Last-Modified] => Thu, 09 Apr 2020 16:30:25 GMT
    [Cache-Control] => max-age=600
    [Expires] => Fri, 10 Apr 2020 00:41:43 GMT
    [Accept-Ranges] => bytes
    [X-NWS-LOG-UUID] => 8797539992453101646
    [X-NWS-UUID-VERIFY] => 66d30ade3f6f1bae7a4bf892d9be38f3
    [X-Cache-Lookup] => Cache Hit
)

改造一下(判断Content-Length是否是数组,如果是返回数组最后一个元素,不是就正常返回):

1
2
3
4
5
// !!!不要使用!!! 还是不完善
function getRemoteFileSizeByGetHeaders($url)
{
    return ($cl = @get_headers($url, 1)['Content-Length']) ? (is_array($cl) ? array_pop($cl) : $cl) : null;
}

**哪里不完善?**这里是直接获取字段Content-Length的,问题来了:

一定会返回Content-Length吗?

并不是。举一个远程文件链接 https://www.php.net/images/logos/php-logo.svg

返回响应头并没有Content-Length,使用上面的方法肯定获取不到值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
cache-control: max-age=86400
content-encoding: gzip
content-type: image/svg+xml
date: Mon, 20 Apr 2020 14:43:20 GMT
etag: "58f78023-5e9"
expires: Tue, 21 Apr 2020 07:01:02 GMT
last-modified: Wed, 19 Apr 2017 15:20:03 GMT
server: myracloud
status: 200
vary: accept-encoding
x-cdn: 1

Content-Length有大小写区分吗?

区分。这个问题等同于Header头字段有大小写区分吗?

举例:https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso(阿里云CentOS镜像下载链接)

这个链接使用上面的函数是可以正常获取到值的(可自测),说明通过get_headers函数获取的会自动转换为首字母大写,curl请求的话不能保证。所以优先使用get_header请求,避免手动转换,更快一点。

1
2
3
4
5
6
accept-ranges: bytes
age: 2640
cache-control: max-age=7200
content-length: 11026825216
content-type: application/octet-stream
...省略部分...

为什么前面说get_headers默认使用GET请求,以及会跟随重定向和跟随重定向次数为20次?

因为get_headers使用默认的流的上下文配置,在HTTP context 选项中有详细说明其默认配置项

method string

远程服务器支持的 GETPOST 或其它 HTTP 方法。

默认值是 GET

follow_location integer

跟随 Location header 的重定向。设置为 0 以禁用。

默认值是 1

max_redirects integer

跟随重定向的最大次数。值为 1 或更少则意味不跟随重定向。

默认值是 20

timeout float

读取超时时间,单位为秒(s),用 float 指定(e.g. 10.5)。

默认使用 php.ini 中设置的 default_socket_timeout。(笔者注:默认超时时间是60秒)

此配置可以修改,get_headers英文文档( 中文文档中没有,应该是还没翻译完成? )中此函数还有第三个参数$content,接收stream_context_create()创建的流上下文.

PHP 7.1.0 新增了 $content 参数

HEAD方式请求

尝试使用HEAD的方式去请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// !!!不要使用!!! 改为了HEAD方式请求,但依然无法保证一定有Content-Length
function getRemoteFileSizeByHEAD($url)
{
    $opts = array('http' =>
        array(
            'method' => 'HEAD',
        )
    );
    $context = stream_context_create($opts);
    return ($cl = get_headers($url, 1, $context)['Content-Length']) ? (is_array($cl) ? array_pop($cl) : $cl) : null;
}

先不考虑是否一定有Content-Length字段,一个新的问题:

一定会支持HEAD方式请求吗?

不一定。这个没找到例子的链接,测了很多都是支持的,在mozilla的文档HTTP状态码405文档中也提到:

状态码 405 Method Not Allowed 表明服务器禁止了使用当前 HTTP 方法的请求。需要注意的是,GETHEAD 两个方法不得被禁止,当然也不得返回状态码 405。

理论上应该是都支持的,但查阅资料看可能还是会有不支持的,状态码返回就是405,可能不是标准实现。

所以结论是:优先使用HEAD,不支持则使用GET

在跳回到Content-Length字段问题,如果没有这个字段怎么办?还可以通过另外一个Header

Range、Accept-Ranges 和 Content-Range

在前面我提到的两个链接:

  1. (企业微信win客户端下载链接) https://work.weixin.qq.com/wework_admin/commdownload?platform=win&from=wwindex
  2. (阿里云CentOS镜像下载链接) https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso

我们可以使用Postman的HEAD去请求,会看到响应头中有一个Accept-Ranges

https://assets.cooldev.cn/20200421190909.png@!p

RangeAccept-RangesContent-Range介绍

Range是请求头,Accept-RangesContent-Range是响应头

Range

The Range 是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206 Partial Content 状态码。假如所请求的范围不合法,那么服务器会返回 416 Range Not Satisfiable 状态码,表示客户端错误。服务器允许忽略 Range 首部,从而返回整个文件,状态码用 200

语法

1
2
3
4
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>

通过在请求中携带Range,服务器会响应Content-Range

Accept-Ranges

服务器使用 HTTP 响应头 Accept-Ranges 标识自身支持范围请求(partial requests)。字段的具体值用于定义范围请求的单位。

当浏览器发现Accept-Ranges头时,可以尝试继续中断了的下载,而不是重新开始。

语法

1
2
Accept-Ranges: bytes
Accept-Ranges: none

为none或者没有就表示不支持范围请求

Content-Range

在HTTP协议中,响应首部 Content-Range 显示的是一个数据片段在整个文件中的位置。

语法

1
2
3
Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>

指令

  • 数据区间所采用的单位。通常是字节(byte)。

  • 一个整数,表示在给定单位下,区间的起始值。

  • 一个整数,表示在给定单位下,区间的结束值。

  • 整个文件的大小(如果大小未知则用"*“表示)。

示例

1
Content-Range: bytes 200-1000/67589 

其中在一个 Range 首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回,这不就是多线程下载吗?

其中当浏览器发现Accept-Ranges头时,可以尝试继续中断了的下载,而不是重新开始。,这不就是断点续传吗?

所以多线程下载的和断点续传的核心就在这里。那这个对获取文件大小有什么用呢?可以看到Content-Range的语法格式中最后的size就是文件大小。

先测试一下,以https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso(阿里云CentOS镜像下载链接)为例,在postman中使用HEAD发起请求并携带Range请求头:

Range头格式:

Range: bytes=0-1

https://assets.cooldev.cn/20200421193329.png@!p

可以看到: Content-Range: bytes 0-1/11026825216,通过提取/后半部分,就可以获得文件大小。

综合上面的得出:

  1. 优先使用HEAD请求,不支持则使用GET请求
  2. 先查找Content-Lengeth,找不到再找Accept-Ranges ,如果有尝试提交Range
  3. 查找Content-Range
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 比较完善
function getRemoteFileSize($url)
{
    $size = null;
    $timeout = 5;
    // 尝试使用HEAD方式请求
    $opts = ['http' => ['method' => 'HEAD', 'header' => ['Connection: close'], 'timeout' => $timeout]];
    $headers = @get_headers($url, 1, stream_context_create($opts));
    // 访问不通直接返回;
    if ($headers === false) return $size;
    // 寻找 Content-Length (绝大多数到这里都会出结果)
    if (isset($headers['Content-Length'])) {
        $cl = $headers['Content-Length'];
        return (int)(is_array($cl) ? array_pop($cl) : $cl);
    }
    // 没有Content-Length,看是否支持提交 Range请求头,寻找 Content-Range
    if (isset($headers['Accept-Ranges']) && $headers['Accept-Ranges'] == 'bytes') {
        $opts = ['http' => ['method' => 'GET', 'header' => ['Range: bytes=0-1', 'Connection: close'], 'timeout' => $timeout]];
        $headers = @get_headers($url, 1, stream_context_create($opts));
        if (isset($headers['Content-Range'])) {
            list(, $size) = explode('/', $headers['Content-Range'], 2);
            return (int)$size;
        }
    }
    
    return $size;
}

Range也不是一定都支持的,所以还需要兜底,现在唯一的就剩下file_get_contents来获取了,但是又容易发生内存溢出,索性我用curl写了一个,类似于下载,但又不会写入文件,不会发生内存超出。用到了CURLOPT_PROGRESSFUNCTIONCURLOPT_WRITEFUNCTION,用法可自己查阅文档。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function getRemoteFileSizeFallBack($url)
{
    $size = null;
    // fallback
    // 上面都行不通使用curl,相当于下载但是不写入文件,不会出现file_get_contents的内存超出
    // 但如果远程文件很大,会执行很长时间,通过浏览器请求会触发超时
    $ch = curl_init();
    curl_setopt_array($ch, array(
        CURLOPT_URL => $url,
        CURLOPT_HEADER => false,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_NOPROGRESS => false
    ));
    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($curl, $dltotal, $dlnow, $ultotal, $ulnow) use (&$size) {
        $size = $dlnow;
    });
    curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) {
        return strlen($data);
    });
    curl_exec($ch);
    curl_close($ch);

    return (int)$size;
}

在命令行下我测试了https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso(阿里云CentOS镜像下载链接)这个链接,这个文件有10GB,可以正确获得值,耗时如下:

1
2
int(11026825216)
Time Use 935.21796917915 s

终极

综上,可以将两个函数组合起来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
 * 获取远程文件大小
 * @param $url
 * @return int|null
 */
function getRemoteFileSize($url)
{
    $size = null;
    $timeout = 5;
    // 尝试使用HEAD方式请求
    $opts = ['http' => ['method' => 'HEAD', 'header' => ['Connection: close'], 'timeout' => $timeout]];
    $headers = @get_headers($url, 1, stream_context_create($opts));
    // 访问不通直接返回;
    if ($headers === false) return $size;
    // 寻找 Content-Length (绝大多数到这里都会出结果)
    if (isset($headers['Content-Length'])) {
        $cl = $headers['Content-Length'];
        return (int)(is_array($cl) ? array_pop($cl) : $cl);
    }
    // 没有Content-Length,看是否支持提交 Range请求头,寻找 Content-Range
    if (isset($headers['Accept-Ranges']) && $headers['Accept-Ranges'] == 'bytes') {
        $opts = ['http' => ['method' => 'GET', 'header' => ['Range: bytes=0-1', 'Connection: close'], 'timeout' => $timeout]];
        $headers = @get_headers($url, 1, stream_context_create($opts));
        if (isset($headers['Content-Range'])) {
            list(, $size) = explode('/', $headers['Content-Range'], 2);
            return (int)$size;
        }
    }
    // fallback
    // 上面都行不通使用curl,相当于下载但是不写入文件,不会出现file_get_contents的内存超出
    // 但如果远程文件很大,会执行很长时间,通过浏览器请求会触发超时
    $ch = curl_init();
    curl_setopt_array($ch, array(
        CURLOPT_URL => $url,
        CURLOPT_HEADER => false,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_NOPROGRESS => false
    ));
    curl_setopt($ch, CURLOPT_PROGRESSFUNCTION, function ($curl, $dltotal, $dlnow, $ultotal, $ulnow) use (&$size) {
        $size = $dlnow;
    });
    curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) {
        return strlen($data);
    });
    curl_exec($ch);
    curl_close($ch);

    return (int)$size;
}

测试,准备了4个链接:

// 包含重定向

https://work.weixin.qq.com/wework_admin/commdownload?platform=win&from=wwindex

// 没有Content-Length,小文件,国外链接访问速度慢

https://www.php.net/images/logos/php-logo.svg

// 超大文件,10GB

https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso

// 一个不存在的链接

https://xxx.com/a.jpg

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$st = microtime(true);
$wx = getRemoteFileSize('https://work.weixin.qq.com/wework_admin/commdownload?platform=win&from=wwindex');
echo "Time Use " . (microtime(true) - $st) . ' s' . PHP_EOL;
var_dump($wx) . PHP_EOL;

$st = microtime(true);
$wx = getRemoteFileSize('https://www.php.net/images/logos/php-logo.svg');
echo "Time Use " . (microtime(true) - $st) . ' s' . PHP_EOL;
var_dump($wx) . PHP_EOL;

$st = microtime(true);
$wx = getRemoteFileSize('https://mirrors.aliyun.com/centos/7.7.1908/isos/x86_64/CentOS-7-x86_64-Everything-1908.iso');
echo "Time Use " . (microtime(true) - $st) . ' s' . PHP_EOL;
var_dump($wx) . PHP_EOL;

$st = microtime(true);
$wx = getRemoteFileSize('https://xxx.com/a.jpg');
echo "Time Use " . (microtime(true) - $st) . ' s' . PHP_EOL;
var_dump($wx) . PHP_EOL;

测试结果:

1
2
3
4
5
6
7
8
Time Use 0.24951410293579 s
int(295714632)
Time Use 4.2920858860016 s
int(1513)
Time Use 0.492595911026 s
int(11026825216)
Time Use 3.0125138759613 s
NULL

参考:

remote-file-size-without-downloading-file(stackoverflow)

聊一聊HTTP的Range, Content-Range