实现一个视频播放的功能,以及对大文件的下载操作等等都避不开一个点:获取文件任意位置的数据,如果说我们单纯的通过 echo file-content 的方式只能用于文件下载,如果视频文件用于播放中,则难以处理,具体表现则为视频播放的时候无法调整进度条,而且如果是视频网站,对于视频只采用放在某个可以直接访问的目录上,那么这个视频也就相当于公开了,对于什么 VIP 什么的也就无从说起,本篇文章将 Range,来提供视频播放、断点续传、多线程下载的技术依赖实现

Range

HTTP协议中,支持以 Range 的形式指定获取资源的特定偏移的数据,语法格式如下,具体参考 Range: MDN

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>
  • <unit> 只能是 bytes (目前来说),指定单位
  • <range-start> 一个整数,表示在特定单位下,范围的起始值。
  • <range-end> 一个整数,表示在特定单位下,范围的结束值。这个值是可选的,如果不存在,表示此范围一直延伸到文档结束。

如: 获取 0-100 字节的数据和120到结尾的数据

1
Range: bytes=0-100,120-

Content-Range

该头部指定了响应的数据的内容范围,语法格式如下:

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

说明:

  • <unit> 数据区间所采用的单位。通常是字节(bytes)。
  • <range-start> 一个整数,表示在给定单位下,区间的起始值。
  • <range-end> 一个整数,表示在给定单位下,区间的结束值。
  • <size> 整个文件的大小(如果大小未知则用 "*" 表示)

例如:

1
Content-Range: bytes 200-1000/67589

多Range响应

目测在网络上面的都没有说到,但是HTTP协议支持多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
28
29
30
31
32
GET http://suda.dev.dx/file HTTP/1.1
Host: suda.dev.dx
Connection: keep-alive
Accept-Encoding: identity;q=1, *;q=0
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3679.0 Safari/537.36
Accept: */*
Referer: http://test.dev.dx/video.html
Accept-Language: zh-CN,zh;q=0.9
Cookie: php_session=8eec314af63d994c2eeb1baca7487332
Range: bytes=0-1,2-3


HTTP/1.1 206 Partial Content
Date: Sun, 10 Mar 2019 09:36:59 GMT
Server: Apache/2.4.23 (Win32) OpenSSL/1.0.2j mod_fcgid/2.3.9
X-Powered-By: PHP/7.2.1
Accept-Ranges: bytes
Content-Length: 220
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: multipart/byteranges; boundary=multiple_range_ss6bBSB6IlLi0YPpP8rK3g==

--multiple_range_ss6bBSB6IlLi0YPpP8rK3g==
Content-Type: video/mp4
Content-Range: bytes 0-1/132006090

<...somedata...>
--multiple_range_ss6bBSB6IlLi0YPpP8rK3g==
Content-Type: video/mp4
Content-Range: bytes 2-3/132006090

<...somedata...>

Accept-Rangs

服务器响应,告诉浏览器是否支持 Range,

语法:

1
2
Accept-Ranges: bytes
Accept-Ranges: none
  • none
    不支持任何范围请求单位,由于其等同于没有返回此头部,因此很少使用。不过一些浏览器,比如IE9,会依据该头部去禁用或者移除下载管理器的暂停按钮。
  • bytes
    范围请求的单位是 bytes (字节)

实现代码

本实现代码可以简单理解为伪代码,部分依赖没有给出,Swoole 环境下修改一下即可使用。

使用代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

namespace suda\welcome\response;

use suda\framework\Request;
use suda\framework\Response;
use suda\application\processor\RequestProcessor;
use suda\application\processor\FileRangeProccessor;

class FileResponse implements RequestProcessor
{
public function onRequest(Request $request, Response $response)
{
$filename = 'G:\视频\刺客伍六七.2018\EP01.mp4';
$processor = new FileRangeProccessor($filename);
$processor->onRequest($request, $response);
}
}

依赖代码:

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
<?php
namespace suda\application\processor;

use SplFileObject;
use suda\framework\Request;
use suda\framework\Response;
use suda\framework\response\MimeType;
use suda\framework\http\stream\DataStream;
use suda\application\processor\RequestProcessor;

/**
* 响应
*/
class FileRangeProccessor implements RequestProcessor
{
/**
* 文件路径
*
* @var SplFileObject
*/
protected $file;

/**
* MIME
*
* @var string
*/
protected $mime;

public function __construct($file)
{
$this->file = $file instanceof SplFileObject? $file : new SplFileObject($file);
$this->mime = MimeType::getMimeType($this->file->getExtension());
}

/**
* 处理文件请求
*
* @param \suda\framework\Request $request
* @param \suda\framework\Response $response
* @return void
*/
public function onRequest(Request $request, Response $response)
{
$ranges = $this->getRanges($request);
$response->setHeader('accept-ranges', 'bytes');
if ($ranges === false || $request->getMethod() !== 'GET') {
$response->status(400);
} elseif ($ranges === null) {
$response->sendFile($this->file->getRealPath());
} elseif (count($ranges) === 1) {
$response->status(206);
$range = $ranges[0];
$response->setHeader('content-type', $this->mime);
$response->setHeader('content-range', $this->getRangeHeader($range));
$this->sendFileByRange($response, $range);
} else {
$response->status(206);
$this->sendMultipleFileByRange($response, $ranges);
}
}

/**
* 发送多Range
*
* @param \suda\framework\Response $response
* @param array $ranges
* @return void
*/
protected function sendMultipleFileByRange(Response $response, array $ranges)
{
$separates = 'multiple_range_'.base64_encode(\md5(\uniqid(), true));
$response->setHeader('content-type', 'multipart/byteranges; boundary='.$separates);
foreach ($ranges as $range) {
$response->write('--'.$separates."\r\n");
$this->sendMultipleRangePart($response, $range);
$this->sendFileByRange($response, $range);
$response->write("\r\n");
}
}


/**
* 发送范围数据
*
* @param \suda\framework\Response $response
* @param array $range
* @return void
*/
protected function sendFileByRange(Response $response, array $range)
{
$response->write(new DataStream($this->file->getRealPath(), $range['start'], $range['end'] - $range['start'] + 1));
}

/**
* 获取Range描述
*
* @param \suda\framework\Request $request
* @return array|bool|null
*/
protected function getRanges(Request $request)
{
$ranges = $this->parseRangeHeader($request);
if (\is_array($ranges)) {
return $this->parseRanges($ranges);
} elseif ($ranges === false) {
return false;
}
return null;
}

/**
* 写Range头
*
* @param \suda\framework\Response $response
* @param array $range
* @return void
*/
protected function sendMultipleRangePart(Response $response, array $range)
{
$response->write('Content-Type: '.$this->mime."\r\n");
$response->write('Content-Range: '.$this->getRangeHeader($range) ."\r\n\r\n");
}

/**
* 生成Range头
*
* @param array $range
* @return string
*/
protected function getRangeHeader(array $range):string
{
return sprintf('bytes %d-%d/%d', $range['start'], $range['end'], $this->file->getSize());
}

/**
* 获取Range描述
*
* @param \suda\framework\Request $request
* @return array|bool|null
*/
protected function parseRangeHeader(Request $request)
{
$range = $request->getHeader('range', null);
if (is_string($range)) {
$range = trim($range);
if (\strpos($range, 'bytes=') !== 0) {
return false;
}
$rangesFrom = \substr($range, strlen('bytes='));
return \explode(',', $rangesFrom);
}
return null;
}

/**
* 处理范围
*
* @param array $ranges
* @return array|bool
*/
protected function parseRanges(array $ranges)
{
$range = [];
foreach ($ranges as $value) {
if (($r = $this->parseRange($value)) !== null) {
$range[] = $r;
} else {
return false;
}
}
return $range;
}

/**
* 处理Range
*
* @param string $range
* @return array
*/
protected function parseRange(string $range):?array
{
$range = trim($range);
if (strrpos($range, '-') === strlen($range) - 1) {
return [
'start' => intval(\rtrim($range, '-')),
'end' => $this->file->getSize() - 1,
];
} elseif (\strpos($range, '-') !== false) {
list($start, $end) = \explode('-', $range, 2);
return ['start' => intval($start) , 'end' => intval($end) ];
}
return null;
}
}

参考文献

  1. https://tools.ietf.org/html/rfc7233#section-4
  2. https://tools.ietf.org/html/rfc7233#section-3
  3. https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Accept-Ranges
  4. https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Range
  5. 完整代码