让我们来看看这几个步骤中的每一个关键消息头.
这是会有服务器发送的字节头,展示可以被分部分发送给浏览器的内容. 这个值声明了可被接受的每一个范围请求, 大多数情况下是字节数 bytes.
Range: 字节数(bytes)=(开始)-(结束)
这是浏览器告知服务器所需分部分内容范围的消息头. 注意开始和结束位置是都包括在内的,而且是从0开始的. 这个消息头也可以不发送两个位置,其含义如下:
Content-Range:字节数(bytes)=(开始)-(结束)/(总数)
这个消息头将会跟随 HTTP 状态码 206 一起出现. 开始和结束的值展示了当前内容的范围. 跟 Range 消息头一样, 两个值都是包含在内的,并且也是从零开始的. 总数这个值声明了可用字节的总数.
Content-Range: */(总数)
这个头信息和上面一个是一样的,不过是用另一种格式,并且仅在返回HTTP状态码416时被发送。其中总数代表了正文总共可用的字节数。
这里有一对有2048个字节文件的例子。注意省略起点和重点的区别。
请求开始的1024个字节
浏览器发送:
1 2 3 | GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=0-1023
|
服务器返回:
1 2 3 4 5 6 7 | HTTP/1.1 216 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 0-1023/2048
Content-Length: 1024
(Content...)
|
没有终点位置的请求
浏览器发送:
1 2 3 | GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-
|
服务器返回:
1 2 3 4 5 6 7 | HTTP/1.1 216 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 1024-2047/2048
Content-Length: 1024
(Content...)
|
注意:服务器并不需要在单个响应中返回所有剩下的字节,特别是当正文太长或者有其他性能的考虑。所以下面的两个例子在这种情况下也是可接受的:
1 2 | Content-Range: bytes 1024-1535/2048
Content-Length: 512
|
服务器仅返回剩余正文的一半。下一次请求的范围将从第1536个字节开始。
1 2 | Content-Range: bytes 1024-1279/2048
Content-Length: 256
|
服务器仅返回剩余正文的256个字节。下一次请求的范围将从第1280个字节开始。
请求最后512个字节
浏览器发送:
1 2 3 | GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=-512
|
服务器返回:
1 2 3 4 5 6 7 | HTTP/1.1 216 Partial Content
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Type: video/mp4
Content-Range: bytes 1536-2047/2048
Content-Length: 512
(Content...)
|
请求不可用的范围:
浏览器发送:
1 2 3 | GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-4096
|
服务器返回:
1 2 3 | HTTP/1.1 416 Requested Range Not Satisfiable
Date: Mon, 15 Sep 2014 22:19:34 GMT
Content-Range: bytes */2048
|
理解了工作流和头部信息后,现在我们可以用Node.js去实现这个机制。
开始用Node.js实现
第一步:创建一个简单的HTTP服务器
我们将像下面的例子那样,从一个基本的HTTP服务器开始。这已经可以基本足够处理大多数的浏览器请求了。首先,我们初始化我们需要用到的对象,并且用initFolder来代表文件的位置。为了生成Content-Type头部,我们列出文件扩展名和它们相对应的MIME名称来构成一个字典。在回调函数httpListener()中,我们将仅允许GET可用。如果出现其他方法,服务器将返回405 Method Not Allowed,在文件不存在于initFolder,服务器将返回404 Not Found。
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 | var http = require( "http" );
var fs = require( "fs" );
var path = require( "path" );
var url = require( "url" );
var initFolder = "C:\\Users\\User\\Videos" ;
var mimeNames = {
".css" : "text/css" ,
".html" : "text/html" ,
".js" : "application/javascript" ,
".mp3" : "audio/mpeg" ,
".mp4" : "video/mp4" ,
".ogg" : "application/ogg" ,
".ogv" : "video/ogg" ,
".oga" : "audio/ogg" ,
".txt" : "text/plain" ,
".wav" : "audio/x-wav" ,
".webm" : "video/webm" ;
};
http.createServer(httpListener).listen(8000);
function httpListener (request, response) {
if (request.method != "GET" ) {
sendResponse(response, 405, { "Allow" : "GET" }, null );
return null ;
}
var filename =
initFolder + url.parse(request.url, true , true ).pathname.split( '/' ).join(path.sep);
var responseHeaders = {};
var stat = fs.statSync(filename);
if (!fs.existsSync(filename)) {
sendResponse(response, 404, null , null );
return null ;
}
responseHeaders[ "Content-Type" ] = getMimeNameFromExt(path.extname(filename));
responseHeaders[ "Content-Length" ] = stat.size;
sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
}
function sendResponse(response, responseStatus, responseHeaders, readable) {
response.writeHead(responseStatus, responseHeaders);
if (readable == null )
response.end();
else
readable.on( "open" , function () {
readable.pipe(response);
});
return null ;
}
function getMimeNameFromExt(ext) {
var result = mimeNames[ext.toLowerCase()];
if (result == null )
result = "application/octet-stream" ;
return result;
}
|
步骤 2 - 使用正则表达式捕获Range消息头
有了这个HTTP服务器做基础,我们现在就可以用如下代码处理Range消息头了. 我们使用正则表达式将消息头分割,以获取开始和结束字符串。然后使用 parseInt() 方法将它们转换成整形数. 如果返回值是 NaN (非数字not a number), 那么这个字符串就是没有在这个消息头中的. 参数totalLength展示了当前文件的总字节数. 我们将使用它计算开始和结束位置.
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 | function readRangeHeader(range, totalLength) {
if (range == null || range.length == 0 )
return null ;
var array = range.split(/bytes=([ 0 - 9 ]*)-([ 0 - 9 ]*)/);
var start = parseInt(array[ 1 ]);
var end = parseInt(array[ 2 ]);
var result = {
Start: isNaN(start) ? 0 : start,
End: isNaN(end) ? (totalLength - 1 ) : end
};
if (!isNaN(start) && isNaN(end)) {
result.Start = start;
result.End = totalLength - 1 ;
}
if (isNaN(start) && !isNaN(end)) {
result.Start = totalLength - end;
result.End = totalLength - 1 ;
}
return result;
}
|
步骤 3 - 检查数据范围是否合理
回到函数 httpListener(), 在HTTP方法通过之后,现在我们来检查请求的数据范围是否可用. 如果浏览器没有发送 Range 消息头过来, 请求就会直接被当做一般的请求对待. 服务器会返回整个文件,HTTP状态将会是 200 OK. 另外我们还会看看开始和结束位置是否比文件长度更大或者相等. 只要有一个是这种情况,请求的数据范围就是不能被满足的. 返回的状态就将会是 416 Requested Range Not Satisfiable 而 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 | var responseHeaders = {};
var stat = fs.statSync(filename);
var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
if (rangeRequest == null ) {
responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
responseHeaders['Content-Length'] = stat.size;
responseHeaders['Accept-Ranges'] = 'bytes';
sendResponse(response, 200 , responseHeaders, fs.createReadStream(filename));
return null ;
}
var start = rangeRequest.Start;
var end = rangeRequest.End;
if (start >= stat.size || end >= stat.size) {
responseHeaders['Content-Range'] = 'bytes */' + stat.size;
sendResponse(response, 416 , responseHeaders, null );
return null ;
}
|
步骤 4 - 满足请求
最后使人迷惑的一块来了。对于状态 216 Partial Content, 我们有另外一种格式的 Content-Range 消息头,包括开始,结束位置以及当前文件的总字节数. 我们也还有 Content-Length 消息头,其值就等于开始和结束位置之间的差。在最后一句代码中,我们调用了 createReadStream() 并将开始和结束位置的值给了第二个参数选项的对象, 这意味着返回的流将只包含从开始到结束位置的只读数据.
1 2 3 4 5 6 7 8 9 10 | responseHeaders[ 'Content-Range' ] = 'bytes ' + start + '-' + end + '/' + stat.size;
responseHeaders[ 'Content-Length' ] = start == end ? 0 : (end - start + 1 );
responseHeaders[ 'Content-Type' ] = getMimeNameFromExt(path.extname(filename));
responseHeaders[ 'Accept-Ranges' ] = 'bytes' ;
responseHeaders[ 'Cache-Control' ] = 'no-cache' ;
sendResponse(response, 206 ,
responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
|
下面是完整的 httpListener() 回调函数.
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 | function httpListener(request, response) {
if (request.method != 'GET' ) {
sendResponse(response, 405 , { 'Allow' : 'GET' }, null );
return null ;
}
var filename =
initFolder + url.parse(request.url, true , true ).pathname.split( '/' ).join(path.sep);
if (!fs.existsSync(filename)) {
sendResponse(response, 404 , null , null );
return null ;
}
var responseHeaders = {};
var stat = fs.statSync(filename);
var rangeRequest = readRangeHeader(request.headers[ 'range' ], stat.size);
if (rangeRequest == null ) {
responseHeaders[ 'Content-Type' ] = getMimeNameFromExt(path.extname(filename));
responseHeaders[ 'Content-Length' ] = stat.size;
responseHeaders[ 'Accept-Ranges' ] = 'bytes' ;
sendResponse(response, 200 , responseHeaders, fs.createReadStream(filename));
return null ;
}
var start = rangeRequest.Start;
var end = rangeRequest.End;
if (start >= stat.size || end >= stat.size) {
responseHeaders[ 'Content-Range' ] = 'bytes */' + stat.size;
sendResponse(response, 416 , responseHeaders, null );
return null ;
}
responseHeaders[ 'Content-Range' ] = 'bytes ' + start + '-' + end + '/' + stat.size;
responseHeaders[ 'Content-Length' ] = start == end ? 0 : (end - start + 1 );
responseHeaders[ 'Content-Type' ] = getMimeNameFromExt(path.extname(filename));
responseHeaders[ 'Accept-Ranges' ] = 'bytes' ;
responseHeaders[ 'Cache-Control' ] = 'no-cache' ;
sendResponse(response, 206 ,
responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
}
|
测试实现
我们怎么来测试我们的代码呢?就像在介绍中提到的,部分正文最常用的场景是流和播放视频。所以我们创建了一个ID为mainPlayer并包含一个<source/>标签的<video/>。函数onLoad()将在mainPlayer预读取当前视频的元数据时被触发,这用于检查在URL中是否有数字参数,如果有,mainPlayer将跳到指定的时间点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <! DOCTYPE html>
< html >
< head >
< script type = "text/javascript" >
function onLoad() {
var sec = parseInt(document.location.search.substr(1));
if (!isNaN(sec))
mainPlayer.currentTime = sec;
}
</ script >
< title >Partial Content Demonstration</ title >
</ head >
< body >
< h3 >Partial Content Demonstration</ h3 >
< hr />
< video id = "mainPlayer" width = "640" height = "360"
autoplay = "autoplay" controls = "controls" onloadedmetadata = "onLoad()" >
< source src = "dota2/techies.mp4" />
</ video >
</ body >
</ html >
|
现在我们把页面保存为"player.html"并和"dota2/techies.mp4"一起放在initFolder目录下。然后在浏览器中打开URL:http://localhost:8000/player.html
在Chrome中看起来像这样:

因为在URL中没有任何参数,文件将从最开始出播放。
接下来就是有趣的部分了。让我们试着打开这个然后看看发生了什么:http://localhost:8000/player.html?60

如果你按F12来打开Chrome的开发者工具,切换到网络标签页,然后点击查看最近一次日志的详细信息。你会发现范围的头信息(Range)被你的浏览器发送了:
很有趣,对吧?当函数onLoad()改变currentTime属性的时候,浏览器计算这部视频60秒处的字节位置。因为mainPlayer已经预加载了元数据,包括格式、比特率和其他基本信息,这个起始位置立刻就被得到了。之后,浏览器就可以下载并播放视频而不需要请求开头的60秒了。成功了!
结论
我们已经用Node.js来实现支持部分正文的HTTP服务器端了。我们也用HTML5页面测试了。但这只是一个开始。如果你对头部信息和工作流这些都已经理解透彻了,你可以试着用其他像ASP.NET MVC或者WCF服务这类框架来实现它。但是不要忘记启动任务管理器来查看CPU和内存的使用。像我们在之前讨论到的,服务器没有在单个响应中返回所用剩余的字节。要找到性能的平衡点将是一项重要的任务。