@cherishpeace
2014-12-14T04:35:16.000000Z
字数 8247
阅读 1710
最近需要做这样一个需求,就是一个接口请求,服务器端执行时间比较长,过了好久才会返回内容,这个体验是很不好的。在浏览器端就会感觉浏览器死掉了。
优化方案就是给前端浏览器一些提示,所以需要一种实时的进度条一样的东西。告诉用户,当前到底执行到什么程度了。
首先以一个简单的例子来大概说明下问题,你去餐厅一屁股坐下来点完菜,菜要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' listener
console.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' listener
console.log('server bound');
});
运行它
我们建立个socket客户端去连接这个服务端(client.js):
var net = require('net');
var client = net.connect({port: 8124},
function() { //'connect' listener
console.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 bound
server connected
服务端你好!
server disconnected
客户端会打出:
client connected
客户端你好!
客户端你幸苦了!
client disconnected
上面的总结探索都是网上各种查资料,再自己写例子实验出来的,感谢下面这些文章:
[1] 使用Node.JS构建Long Polling应用程序
[2] 基于 HTTP 长连接的“服务器推”技术
[3] Socket 通讯
[4] Browser 與 Server 持續同步的作法介紹