使用Node.js实现HTTP 206内容分片的教程


Posted in Javascript onJune 23, 2015

 介绍

在本文中,我会阐述HTTP状态206 分部分内容 的基础概念,并使用Node.js一步步地实现它. 我们还将用一个基于它用法最常见场景的示例来测试代码:一个能够在任何时间点开始播放视频文件的HTML5页面.
Partial Content 的简要介绍

HTTP 的 206 Partial Content 状态码和其相关的消息头提供了让浏览器以及其他用户代理从服务器接收部分内容而不是全部内容,这样一种机制. 这一机制被广泛使用在一个被大多数浏览器和诸如Windows Media Player和VLC Player这样的播放器所支持视频文件的传输上.
 

基础的流程可以用下面这几步描述:

  •     浏览器请求内容.
  •     服务器告诉浏览器,该内容可以使用 Accept-Ranges 消息头进行分部分请求.
  •     浏览器重新发送请求,用 Range 消息头告诉服务器需要的内容范围.

    服务器会分如下两种情况响应浏览器的请求:

  •         如果范围是合理的,服务器会返回所请求的部分内容,并带上 206 Partial Content 状态码. 当前内容的范围会在 Content-Range 消息头中申明.
  •         如果范围是不可用的(例如,比内容的总字节数大), 服务器会返回 416 请求范围不合理 Requested Range Not Satisfiable 状态码. 可用的范围也会在 Content-Range 消息头中声明.

让我们来看看这几个步骤中的每一个关键消息头.

Accept-Ranges: 字节(bytes)

这是会有服务器发送的字节头,展示可以被分部分发送给浏览器的内容. 这个值声明了可被接受的每一个范围请求, 大多数情况下是字节数 bytes.

Range: 字节数(bytes)=(开始)-(结束)

这是浏览器告知服务器所需分部分内容范围的消息头. 注意开始和结束位置是都包括在内的,而且是从0开始的. 这个消息头也可以不发送两个位置,其含义如下:

  •     如果结束位置被去掉了,服务器会返回从声明的开始位置到整个内容的结束位置内容的最后一个可用字节.
  •     如果开始位置被去掉了,结束位置参数可以被描述成从最后一个可用的字节算起可以被服务器返回的字节数.

Content-Range:字节数(bytes)=(开始)-(结束)/(总数)

这个消息头将会跟随 HTTP 状态码 206 一起出现. 开始和结束的值展示了当前内容的范围. 跟 Range 消息头一样, 两个值都是包含在内的,并且也是从零开始的. 总数这个值声明了可用字节的总数.
 
Content-Range: */(总数)

这个头信息和上面一个是一样的,不过是用另一种格式,并且仅在返回HTTP状态码416时被发送。其中总数代表了正文总共可用的字节数。

这里有一对有2048个字节文件的例子。注意省略起点和重点的区别。

请求开始的1024个字节

浏览器发送:
 

GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=0-1023

服务器返回:
 

HTTP/1.1 206 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...)

没有终点位置的请求

浏览器发送:
 

GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-

服务器返回:
 

HTTP/1.1 206 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...)

注意:服务器并不需要在单个响应中返回所有剩下的字节,特别是当正文太长或者有其他性能的考虑。所以下面的两个例子在这种情况下也是可接受的:
 

Content-Range: bytes 1024-1535/2048
Content-Length: 512

服务器仅返回剩余正文的一半。下一次请求的范围将从第1536个字节开始。

 

Content-Range: bytes 1024-1279/2048
Content-Length: 256

服务器仅返回剩余正文的256个字节。下一次请求的范围将从第1280个字节开始。

请求最后512个字节

浏览器发送:
 

GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=-512

服务器返回:
 

HTTP/1.1 206 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...)

请求不可用的范围:

浏览器发送:
 

GET /dota2/techies.mp4 HTTP/1.1
Host: localhost:8000
Range: bytes=1024-4096

服务器返回:
 

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。
 

// 初始化需要的对象
var http = require("http");
var fs = require("fs");
var path = require("path");
var url = require("url");
 
// 初始的目录,随时可以改成你希望的目录
var initFolder = "C:\\Users\\User\\Videos";
 
// 将我们需要的文件扩展名和MIME名称列出一个字典
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) {
  // 我们将只接受GET请求,否则返回405 'Method Not Allowed'
  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);
  // 检查文件是否存在,不存在就返回404 Not Found
  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展示了当前文件的总字节数. 我们将使用它计算开始和结束位置.

 

function readRangeHeader(range, totalLength) {
    /*
     * Example of the method 'split' with regular expression.
     * 
     * Input: bytes=100-200
     * Output: [null, 100, 200, null]
     * 
     * Input: bytes=-200
     * Output: [null, null, 200, null]
     */
 
  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 也会被发送. 
 

var responseHeaders = {};
  var stat = fs.statSync(filename);
  var rangeRequest = readRangeHeader(request.headers['range'], stat.size);
  
  // If 'Range' header exists, we will parse it with Regular Expression.
  if (rangeRequest == null) {
    responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
    responseHeaders['Content-Length'] = stat.size; // File size.
    responseHeaders['Accept-Ranges'] = 'bytes';
     
    // If not, will return file directly.
    sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
    return null;
  }
 
  var start = rangeRequest.Start;
  var end = rangeRequest.End;
 
  // If the range can't be fulfilled. 
  if (start >= stat.size || end >= stat.size) {
    // Indicate the acceptable range.
    responseHeaders['Content-Range'] = 'bytes */' + stat.size; // File size.
 
    // Return the 416 'Requested Range Not Satisfiable'.
    sendResponse(response, 416, responseHeaders, null);
    return null;
  }

步骤 4 - 满足请求

最后使人迷惑的一块来了。对于状态 216 Partial Content, 我们有另外一种格式的 Content-Range 消息头,包括开始,结束位置以及当前文件的总字节数. 我们也还有 Content-Length 消息头,其值就等于开始和结束位置之间的差。在最后一句代码中,我们调用了 createReadStream() 并将开始和结束位置的值给了第二个参数选项的对象, 这意味着返回的流将只包含从开始到结束位置的只读数据.
 

// Indicate the current range. 
  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';
 
  // Return the 206 'Partial Content'.
  sendResponse(response, 206, 
    responseHeaders, fs.createReadStream(filename, { start: start, end: end }));

下面是完整的 httpListener() 回调函数.

 

function httpListener(request, response) {
  // We will only accept 'GET' method. Otherwise will return 405 'Method Not Allowed'.
  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);
 
  // Check if file exists. If not, will return the 404 'Not Found'. 
  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 'Range' header exists, we will parse it with Regular Expression.
  if (rangeRequest == null) {
    responseHeaders['Content-Type'] = getMimeNameFromExt(path.extname(filename));
    responseHeaders['Content-Length'] = stat.size; // File size.
    responseHeaders['Accept-Ranges'] = 'bytes';
 
    // If not, will return file directly.
    sendResponse(response, 200, responseHeaders, fs.createReadStream(filename));
    return null;
  }
 
  var start = rangeRequest.Start;
  var end = rangeRequest.End;
 
  // If the range can't be fulfilled. 
  if (start >= stat.size || end >= stat.size) {
    // Indicate the acceptable range.
    responseHeaders['Content-Range'] = 'bytes */' + stat.size; // File size.
 
    // Return the 416 'Requested Range Not Satisfiable'.
    sendResponse(response, 416, responseHeaders, null);
    return null;
  }
 
  // Indicate the current range. 
  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';
 
  // Return the 206 'Partial Content'.
  sendResponse(response, 206, 
    responseHeaders, fs.createReadStream(filename, { start: start, end: end }));
}

测试实现

我们怎么来测试我们的代码呢?就像在介绍中提到的,部分正文最常用的场景是流和播放视频。所以我们创建了一个ID为mainPlayer并包含一个<source/>标签的<video/>。函数onLoad()将在mainPlayer预读取当前视频的元数据时被触发,这用于检查在URL中是否有数字参数,如果有,mainPlayer将跳到指定的时间点。

 

<!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中看起来像这样:

使用Node.js实现HTTP 206内容分片的教程

因为在URL中没有任何参数,文件将从最开始出播放。

接下来就是有趣的部分了。让我们试着打开这个然后看看发生了什么:http://localhost:8000/player.html?60

使用Node.js实现HTTP 206内容分片的教程

如果你按F12来打开Chrome的开发者工具,切换到网络标签页,然后点击查看最近一次日志的详细信息。你会发现范围的头信息(Range)被你的浏览器发送了:
 

Range:bytes=225084502-

很有趣,对吧?当函数onLoad()改变currentTime属性的时候,浏览器计算这部视频60秒处的字节位置。因为mainPlayer已经预加载了元数据,包括格式、比特率和其他基本信息,这个起始位置立刻就被得到了。之后,浏览器就可以下载并播放视频而不需要请求开头的60秒了。成功了!
 

结论

我们已经用Node.js来实现支持部分正文的HTTP服务器端了。我们也用HTML5页面测试了。但这只是一个开始。如果你对头部信息和工作流这些都已经理解透彻了,你可以试着用其他像ASP.NET MVC或者WCF服务这类框架来实现它。但是不要忘记启动任务管理器来查看CPU和内存的使用。像我们在之前讨论到的,服务器没有在单个响应中返回所用剩余的字节。要找到性能的平衡点将是一项重要的任务。

Javascript 相关文章推荐
[IE&amp;FireFox兼容]JS对select操作
Jan 07 Javascript
jQuery Ajax文件上传(php)
Jun 16 Javascript
js图片延迟加载的实现方法及思路
Jul 22 Javascript
返回页面顶部top按钮通过锚点实现(自写)
Aug 30 Javascript
iframe的父子窗口之间的对象相互调用基本用法
Sep 03 Javascript
Javascript前端UI框架Kit使用指南之kitjs事件管理
Nov 28 Javascript
Jquery-1.9.1源码分析系列(十一)之DOM操作
Nov 25 Javascript
js判断浏览器是否支持严格模式的方法
Oct 04 Javascript
JS+WCF实现进度条实时监测数据加载量的方法详解
Dec 19 Javascript
vue.js根据代码运行环境选择baseurl的方法
Feb 28 Javascript
JS函数动态传递参数的方法分析【基于arguments对象】
Jun 05 Javascript
基于postman获取动态数据过程详解
Sep 08 Javascript
jquery.gridrotator实现响应式图片展示画廊效果
Jun 23 #Javascript
使用JavaScript实现旋转的彩圈特效
Jun 23 #Javascript
在Node.js中使用HTTP上传文件的方法
Jun 23 #Javascript
Js+php实现异步拖拽上传文件
Jun 23 #Javascript
javascript框架设计之类工厂
Jun 23 #Javascript
jQuery判断多个input file 都不能为空的例子
Jun 23 #Javascript
javascript框架设计之浏览器的嗅探和特征侦测
Jun 23 #Javascript
You might like
php+resumablejs实现的分块上传 断点续传功能示例
2017/04/18 PHP
用PHP去掉文件头的Unicode签名(BOM)方法
2017/06/22 PHP
PHP切割汉字的常用方法实例总结
2019/04/27 PHP
图片按比例缩放函数
2006/06/26 Javascript
js二维数组排序的简单示例代码
2014/01/24 Javascript
jquery获取html元素的绝对位置和相对位置的方法
2014/06/20 Javascript
jQuery checkbox选中问题之prop与attr注意点分析
2016/11/15 Javascript
jQuery自定义插件详解及实例代码
2016/12/29 Javascript
js实现随机抽选效果、随机抽选红色球效果
2017/01/13 Javascript
React进阶学习之组件的解耦之道
2017/08/07 Javascript
详解CommonJS和ES6模块循环加载处理的区别
2018/12/26 Javascript
解决layui弹框失效的问题
2019/09/09 Javascript
Vue 路由间跳转和新开窗口的方式(query、params)
2019/12/25 Javascript
pygame学习笔记(4):声音控制
2015/04/15 Python
在Python中使用PIL模块对图片进行高斯模糊处理的教程
2015/05/05 Python
详解python脚本自动生成需要文件实例代码
2017/02/04 Python
Python编程实现正则删除命令功能
2017/08/30 Python
Python基于最小二乘法实现曲线拟合示例
2018/06/14 Python
Python实现的删除重复文件或图片功能示例【去重】
2019/04/23 Python
Pytorch 抽取vgg各层并进行定制化处理的方法
2019/08/20 Python
python并发爬虫实用工具tomorrow实用解析
2019/09/25 Python
使用Python爬虫库BeautifulSoup遍历文档树并对标签进行操作详解
2020/01/25 Python
python图形开发GUI库pyqt5的基本使用方法详解
2020/02/14 Python
python要安装在哪个盘
2020/06/15 Python
美国专营婴幼儿用品的购物网站:buybuy BABY
2017/01/01 全球购物
三星美国官网:Samsung美国
2017/02/06 全球购物
H&M旗下高端女装品牌:& Other Stories
2018/05/07 全球购物
Hotter Shoes美国官网:英国最受欢迎的舒适鞋
2018/08/02 全球购物
不开辟用于交换数据的临时空间,如何完成字符串的逆序
2012/12/02 面试题
估算杭州有多少软件工程师
2015/08/11 面试题
linux面试题参考答案(6)
2016/06/23 面试题
航空大学应届生求职信
2013/11/10 职场文书
货车司机岗位职责
2014/03/18 职场文书
一份关于丢失公司财物的检讨书
2014/09/19 职场文书
客户经理岗位职责大全
2015/04/09 职场文书
银行保安拾金不昧表扬稿
2015/05/05 职场文书