@cherishpeace
2014-12-13T20:35:16.000000Z
字数 8247
阅读 1917
最近需要做这样一个需求,就是一个接口请求,服务器端执行时间比较长,过了好久才会返回内容,这个体验是很不好的。在浏览器端就会感觉浏览器死掉了。
优化方案就是给前端浏览器一些提示,所以需要一种实时的进度条一样的东西。告诉用户,当前到底执行到什么程度了。
首先以一个简单的例子来大概说明下问题,你去餐厅一屁股坐下来点完菜,菜要7秒种才能上来。(这边假设7秒已经很长时间了):
为了更容易理解,我们尽量使用原生的node代码实现。
服务端代码:
var http = require('http');var fs = require('fs');var url = require('url');http.createServer(function (req, res) {var path = url.parse(req.url).pathname;if(path === '/api'){//调用的接口点菜//这是个需要7秒才能完成的任务setTimeout(function() {res.end('心好累,7秒后菜才好了。。。');}, 7000);}if(path === '/'){//不是ajax接口,直接返回前端的html内容var indexStr = fs.readFileSync('index.html');res.setHeader('Content-Type', 'text/html; charset=UTF-8');res.end(indexStr);}}).listen(3000);console.log('Server listening on port 3000');
前端 index.html代码:
<!DOCTYPE html><html><head><title>长连接测试</title><script type="text/javascript" src='http://lib.sinaapp.com/js/jquery/1.7.2/jquery.min.js'></script><script type="text/javascript">function _start(node){$(node).attr('disabled','disabled');/*后面前端代码基本只修改这边的其他的不变*//*修改区域开始*/$.ajax({url: "/api",async: false,success:function(data){$('body').append('<div>'+data+'</div>');}})/*修改区域结束*/}</script></head><body>我就是个打酱油的。。<button onclick="_start(this)">点菜</button></body></html>
我们以一个setTimeout来模拟一个7秒才能完成的任务.
运行后,访问:localhost:3000我们会看到index.html的内容,点击点菜按钮,会ajax请求/api的内容。7秒后我们才能看到内容。体验非常不好。我们需要改进下,在任务执行的过程中提前返回数据通知浏览器给些进度提示。
要实现这个需求,就我知道的有下面这些技术:
这是一种最古老,最简单粗暴的方式。轮询说白了就是不停的用ajax发请求问服务器,当前执行到什么程度了。
就好像你去餐厅一屁股坐下来点完菜,菜一直没上来,然后你每5秒种就叫服务员跑到厨房问下厨师菜几分熟了。
所以一般我们的做法是前端:
var interId = null;//先调用耗时接口,就是你开始点菜$.ajax({url: "/api",success:function(data){//成功后,就可以取消轮询了。clearInterval(interId);$('body').append('<div>'+data+'</div>');}})//使用轮询去查状态,开始叫服务员去问菜烧到几分熟了function queryPercent(){$.ajax({url: "/pencent",success:function(pencent){$('body').append('<div>当前进度'+pencent+'</div>');}})}interId = setInterval(queryPercent,500)
后端一般是这样:
var http = require('http');var fs = require('fs');var url = require('url');//定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。//这边为了简单直接放在全局var percent = '0%';http.createServer(function (req, res) {var path = url.parse(req.url).pathname;//查看菜几分熟了if(path === '/pencent'){res.end(percent);}//调用的接口点菜if(path === '/api'){percent = '0%';//5分熟的时候更新下状态setTimeout(function() {percent = '50%';}, 3500);//这是个需要7秒才能完成的任务setTimeout(function() {res.end('心好累,7秒后菜才好了。。。');}, 7000);}if(path === '/'){//不是ajax接口,直接返回前端的html内容var indexStr = fs.readFileSync('index.html');res.setHeader('Content-Type', 'text/html; charset=UTF-8');res.end(indexStr);}}).listen(3000);console.log('Server listening on port 3000');
主要就是/api这个接口会更新一个全局的进度变量,这样我们可以再开一个接口,给前端不停的轮询请求查看进度。就是每500毫秒就让服务员去问一次。
结果是:
当前进度0%当前进度0%当前进度0%当前进度0%当前进度0%当前进度0%当前进度50%当前进度50%当前进度50%当前进度50%当前进度50%当前进度50%当前进度50%心好累,7秒后菜才好了。。。
这样的缺点是很明显的,浪费很多请求。造成很多不必要的开销。
上面是额外开了个接口获取进度,而如果我们使用了长连接技术。可以不需要/pencent这个接口。
长连接说白了,就是浏览器跟服务器发一个请求,这个请求一直不断开,而服务器程序每过一段时间就返回一段数据。达到一种分块读取的效果。有数据就提前返回,而不用等所有数据都准备好了再返回。
这项技术的实现,归功于http1.1实现的 Transfer-Encoding: chunked。
当你设置了这个 http头。服务器的数据就不会整体的返回,而是一段一段的返回。可以参考这段wiki
nodejs原生支持分块读取,默认就打开了Transfer-Encoding: chunked。我们调用res.write(data)就会提前将数据分块返回给浏览器端。而在php里面 不仅需要改写header还要调用flush来提前响应。
我们修改下服务端代码:
var http = require('http');var fs = require('fs');var url = require('url');http.createServer(function (req, res) {var path = url.parse(req.url).pathname;//调用的接口点菜if(path === '/api'){//5分熟的时候更新下状态setTimeout(function() {//提前响应数据res.write('当前进度50%');}, 3500);//这是个需要7秒才能完成的任务setTimeout(function() {res.end('心好累,7秒后菜才好了。。。');}, 7000);}if(path === '/'){//不是ajax接口,直接返回前端的html内容var indexStr = fs.readFileSync('index.html');res.setHeader('Content-Type', 'text/html; charset=UTF-8');res.end(indexStr);}}).listen(3000);console.log('Server listening on port 3000');
如果这时候你直接使用浏览器访问http://localhost:3000/api就会发现数据已经是一点一点的出来的了。
当然我们需要程序化的调用,前端使用分下面几种方式:
XMLHttpRequest其实有一个状态readyState = 3标识数据正在传输中。因此我们可以这样:
var lastIndex = 0;var query = new XMLHttpRequest();query.onreadystatechange = function () {if (query.readyState === 3) {//每次返回的数据responseText会包含上次的数据,所以需要手动substring一下var info = query.responseText.substring(lastIndex);$('body').append('<div>'+info+'</div>');lastIndex = query.responseText.length;}}query.open("GET", "/api", true);query.send(null);
上面的代码我在chrome下面测试通过,显然这东西兼容性很差,ie什么的就不要指望了。
这也是一种曾经流行的方式,特点就是兼容性比较好。我们知道我们之前直接访问http://localhost:3000/api,页面上已经会一点点的出来数据了。我们可以在服务器端在数据外面包一层script标记,这样就可以调用前端页面上的函数,达到一种分段处理数据的目的。
首先改造下核心的服务端代码:
//调用的接口点菜if(path === '/api'){//这边一定要设置为text/html; charset=UTF-8,不然就不会有分段效果res.setHeader('Content-Type', 'text/html; charset=UTF-8');//5分熟的时候更新下状态setTimeout(function() {res.write('<script> top.read("当前进度50%") </script>');}, 3500);//这是个需要7秒才能完成的任务setTimeout(function() {res.end('<script> top.read("心好累,7秒后菜才好了。。。") </script>');}, 7000);}
可以看到我们在数据外面包了一层script标签还有方法。
然后前端代码,使用一个隐藏的iframe来加载接口:
window.read = function(info){$('body').append('<div>'+info+'</div>');}$('body').append('<iframe style="display:none" src="/api"></iframe>');
当iframe加载时,一块块加载,加载一块就会调用父iframe的read方法。这样就达到了一点点提示的目的。
实际上这也是bigpie这种技术的主要实现方式,只不过不需要iframe,直接在当前页面更新视图就好了。这边就不扯了。
另外按照这个原理,这边我还尝试了下 动态插入script的方式,但是发现不管怎样都不会有分段调用的过程,应该是浏览器会等js全部加载完之后才会执行里面的代码。
总之这种方式实现了一个接口分段返回信息的功能,但是只是单向的服务端传输,不存在可操作性。
这是后来比较流行的一种方式,Facebook,Plurk都曾经使用过。这个技术被称为服务器推送技术。其实原理也很简单,就是一个请求过去了,不要马上返回,等数据有更新了,再返回。这样可以减少很多无意义的请求。
跟上面的polling的对比就是,轮询是每5秒就去问一次,不管状态有没有更新。而长轮询是服务员跑过去问了,但是状态没更新就先不回去,因为回去了再跑过来是没意义的。所以就等状态更新后再返回告诉客人,熟到几分了。
比如上面的例子,只有5分熟的时候才会更新状态,所以如果用轮询的方式,可能来来回回好几趟,但是返回的结果一直都是0%.完全没有意义。
我们把上面的改造成长轮询:
前端js:
//先调用耗时接口,就是你开始点菜$.ajax({url: "/api",success:function(data){$('body').append('<div>'+data+'</div>');}})//叫服务员去问菜烧到几分熟了,状态更新了再回来告诉我,没到100%就立即再去问。function queryPercent(){$.ajax({url: "/pencent",success:function(pencent){$('body').append('<div>当前进度'+pencent+'</div>');if (pencent != '100%') {queryPercent();}}})}queryPercent();
服务端改造为:
var http = require('http');var fs = require('fs');var url = require('url');//定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。//这边为了简单直接放在全局var percent = '0%';var isPencentUpate = false;http.createServer(function (req, res) {var path = url.parse(req.url).pathname;//查看菜几分熟了if(path === '/pencent'){//实际应用中这边最好使用事件机制。否则只是把轮询放到了后端而已。var tId = setInterval(function(){if (isPencentUpate){isPencentUpate = false;clearInterval(tId);res.end(percent);}},100);}//调用的接口点菜if(path === '/api'){percent = '0%';//5分熟的时候更新下状态setTimeout(function() {isPencentUpate = true;percent = '50%';}, 3500);//这是个需要7秒才能完成的任务setTimeout(function() {isPencentUpate = true;percent = '100%';res.end('心好累,7秒后菜才好了。。。');}, 7000);}if(path === '/'){//不是ajax接口,直接返回前端的html内容var indexStr = fs.readFileSync('index.html');res.setHeader('Content-Type', 'text/html; charset=UTF-8');res.end(indexStr);}}).listen(3000);console.log('Server listening on port 3000');
结果为:
当前进度50%心好累,7秒后菜才好了。。。当前进度100%
/pencent的请求只会发两次,只在服务端程序发现状态变更的时候请求才会返回数据。也就是一种主动推送的概念。
这种技术,不仅减少了请求,而且弥补了上面长连接的不可交互的弊端。但是因为一直维持着一个连接会比较占用资源。特别是对php,ruby这种一个请求一个进程的模型来说是硬伤,不过node没有这个问题。基于事件的请求模型使他天生就适合这种方式。
虽然苹果放弃了flash,虽然越来越多的前端放弃flash转投h5的怀抱,但是不得不承认,有的时候flash还是可以实现很多功能。
主要是,使用javascript跟flash通信,用flash提供的XMLSocket来实现。但是这种毕竟已经越来越被淘汰了,这边就不展开细讲了。
另外据说还有种使用更小众的Java Applet的socket接口来实现的。这个也不考虑了。早就淘汰了n年的东西了。
上面提到的插件方式,说白了都是使用javascript借助别人的socket实现。万幸的是html5已经提出了websocket的概念,javascript也可以在浏览器端实现socket了。虽然ie系列肯定不支持,但是我们还是有必要了解下。
说了这么多,我们先要科普下socket。socket也叫做套接字,提供了一种面向tcp、udp的编程方式。我们知道http协议是无状态的一次请求型的。只有浏览器端发起请求才能建立一次会话。而socket可以建立双向的通信。
首先我们撇开浏览器,看下nodejs里面的socket用法:
我们先建立一个socket服务端(server.js):
var net = require('net');var server = net.createServer(function(c) { //'connection' listenerconsole.log('server connected');//这边的c是一个net.Socket实例,本质上是一个可读可写流。c.on('end', function() {console.log('server disconnected');});//这边调用write客户端那边可以使用data监听到数据c.write('客户端你好!\r\n');c.write('客户端你幸苦了!\r\n');//调用end同时会触发客户端那边的实例的end事件c.end();//客户端那边写过来的数据可以使用data事件获取到。c.on('data', function(data) {console.log(data.toString());});});server.listen(8124, function() { //'listening' listenerconsole.log('server bound');});
运行它
我们建立个socket客户端去连接这个服务端(client.js):
var net = require('net');var client = net.connect({port: 8124},function() { //'connect' listenerconsole.log('client connected');client.write('服务端你好!\r\n');});client.on('data', function(data) {console.log(data.toString());//client.end();});client.on('end', function() {console.log('client disconnected');});
运行client.js
服务端会打出
server boundserver connected服务端你好!server disconnected
客户端会打出:
client connected客户端你好!客户端你幸苦了!client disconnected
上面的总结探索都是网上各种查资料,再自己写例子实验出来的,感谢下面这些文章:
[1] 使用Node.JS构建Long Polling应用程序
[2] 基于 HTTP 长连接的“服务器推”技术
[3] Socket 通讯
[4] Browser 與 Server 持續同步的作法介紹