[关闭]
@cherishpeace 2014-12-13T20:35:16.000000Z 字数 8247 阅读 1564

从零单排之socket.io实战

最近需要做这样一个需求,就是一个接口请求,服务器端执行时间比较长,过了好久才会返回内容,这个体验是很不好的。在浏览器端就会感觉浏览器死掉了。

优化方案就是给前端浏览器一些提示,所以需要一种实时的进度条一样的东西。告诉用户,当前到底执行到什么程度了。

问题实例化

首先以一个简单的例子来大概说明下问题,你去餐厅一屁股坐下来点完菜,菜要7秒种才能上来。(这边假设7秒已经很长时间了):

为了更容易理解,我们尽量使用原生的node代码实现。

服务端代码:

  1. var http = require('http');
  2. var fs = require('fs');
  3. var url = require('url');
  4. http.createServer(function (req, res) {
  5. var path = url.parse(req.url).pathname;
  6. if(path === '/api'){
  7. //调用的接口点菜
  8. //这是个需要7秒才能完成的任务
  9. setTimeout(function() {
  10. res.end('心好累,7秒后菜才好了。。。');
  11. }, 7000);
  12. }
  13. if(path === '/'){
  14. //不是ajax接口,直接返回前端的html内容
  15. var indexStr = fs.readFileSync('index.html');
  16. res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  17. res.end(indexStr);
  18. }
  19. }).listen(3000);
  20. console.log('Server listening on port 3000');

前端 index.html代码:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <title>长连接测试</title>
  5. <script type="text/javascript" src='http://lib.sinaapp.com/js/jquery/1.7.2/jquery.min.js'></script>
  6. <script type="text/javascript">
  7. function _start(node){
  8. $(node).attr('disabled','disabled');
  9. /*后面前端代码基本只修改这边的其他的不变*/
  10. /*修改区域开始*/
  11. $.ajax({
  12. url: "/api",
  13. async: false,
  14. success:function(data){
  15. $('body').append('<div>'+data+'</div>');
  16. }
  17. })
  18. /*修改区域结束*/
  19. }
  20. </script>
  21. </head>
  22. <body>
  23. 我就是个打酱油的。。
  24. <button onclick="_start(this)">点菜</button>
  25. </body>
  26. </html>

我们以一个setTimeout来模拟一个7秒才能完成的任务.

运行后,访问:localhost:3000我们会看到index.html的内容,点击点菜按钮,会ajax请求/api的内容。7秒后我们才能看到内容。体验非常不好。我们需要改进下,在任务执行的过程中提前返回数据通知浏览器给些进度提示。

要实现这个需求,就我知道的有下面这些技术:

ajax 轮询(polling)

这是一种最古老,最简单粗暴的方式。轮询说白了就是不停的用ajax发请求问服务器,当前执行到什么程度了。
就好像你去餐厅一屁股坐下来点完菜,菜一直没上来,然后你每5秒种就叫服务员跑到厨房问下厨师菜几分熟了。

所以一般我们的做法是前端:

  1. var interId = null;
  2. //先调用耗时接口,就是你开始点菜
  3. $.ajax({
  4. url: "/api",
  5. success:function(data){
  6. //成功后,就可以取消轮询了。
  7. clearInterval(interId);
  8. $('body').append('<div>'+data+'</div>');
  9. }
  10. })
  11. //使用轮询去查状态,开始叫服务员去问菜烧到几分熟了
  12. function queryPercent(){
  13. $.ajax({
  14. url: "/pencent",
  15. success:function(pencent){
  16. $('body').append('<div>当前进度'+pencent+'</div>');
  17. }
  18. })
  19. }
  20. interId = setInterval(queryPercent,500)

后端一般是这样:

  1. var http = require('http');
  2. var fs = require('fs');
  3. var url = require('url');
  4. //定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。
  5. //这边为了简单直接放在全局
  6. var percent = '0%';
  7. http.createServer(function (req, res) {
  8. var path = url.parse(req.url).pathname;
  9. //查看菜几分熟了
  10. if(path === '/pencent'){
  11. res.end(percent);
  12. }
  13. //调用的接口点菜
  14. if(path === '/api'){
  15. percent = '0%';
  16. //5分熟的时候更新下状态
  17. setTimeout(function() {
  18. percent = '50%';
  19. }, 3500);
  20. //这是个需要7秒才能完成的任务
  21. setTimeout(function() {
  22. res.end('心好累,7秒后菜才好了。。。');
  23. }, 7000);
  24. }
  25. if(path === '/'){
  26. //不是ajax接口,直接返回前端的html内容
  27. var indexStr = fs.readFileSync('index.html');
  28. res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  29. res.end(indexStr);
  30. }
  31. }).listen(3000);
  32. console.log('Server listening on port 3000');

主要就是/api这个接口会更新一个全局的进度变量,这样我们可以再开一个接口,给前端不停的轮询请求查看进度。就是每500毫秒就让服务员去问一次。

结果是:

  1. 当前进度0%
  2. 当前进度0%
  3. 当前进度0%
  4. 当前进度0%
  5. 当前进度0%
  6. 当前进度0%
  7. 当前进度50%
  8. 当前进度50%
  9. 当前进度50%
  10. 当前进度50%
  11. 当前进度50%
  12. 当前进度50%
  13. 当前进度50%
  14. 心好累,7秒后菜才好了。。。

这样的缺点是很明显的,浪费很多请求。造成很多不必要的开销。

长连接(Comet),分段传输

上面是额外开了个接口获取进度,而如果我们使用了长连接技术。可以不需要/pencent这个接口。

长连接说白了,就是浏览器跟服务器发一个请求,这个请求一直不断开,而服务器程序每过一段时间就返回一段数据。达到一种分块读取的效果。有数据就提前返回,而不用等所有数据都准备好了再返回。

这项技术的实现,归功于http1.1实现的 Transfer-Encoding: chunked

当你设置了这个 http头。服务器的数据就不会整体的返回,而是一段一段的返回。可以参考这段wiki

nodejs原生支持分块读取,默认就打开了Transfer-Encoding: chunked。我们调用res.write(data)就会提前将数据分块返回给浏览器端。而在php里面 不仅需要改写header还要调用flush来提前响应。

我们修改下服务端代码:

  1. var http = require('http');
  2. var fs = require('fs');
  3. var url = require('url');
  4. http.createServer(function (req, res) {
  5. var path = url.parse(req.url).pathname;
  6. //调用的接口点菜
  7. if(path === '/api'){
  8. //5分熟的时候更新下状态
  9. setTimeout(function() {
  10. //提前响应数据
  11. res.write('当前进度50%');
  12. }, 3500);
  13. //这是个需要7秒才能完成的任务
  14. setTimeout(function() {
  15. res.end('心好累,7秒后菜才好了。。。');
  16. }, 7000);
  17. }
  18. if(path === '/'){
  19. //不是ajax接口,直接返回前端的html内容
  20. var indexStr = fs.readFileSync('index.html');
  21. res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  22. res.end(indexStr);
  23. }
  24. }).listen(3000);
  25. console.log('Server listening on port 3000');

如果这时候你直接使用浏览器访问http://localhost:3000/api就会发现数据已经是一点一点的出来的了。

当然我们需要程序化的调用,前端使用分下面几种方式:

1.ajax读取分段数据

XMLHttpRequest其实有一个状态readyState = 3标识数据正在传输中。因此我们可以这样:

  1. var lastIndex = 0;
  2. var query = new XMLHttpRequest();
  3. query.onreadystatechange = function () {
  4. if (query.readyState === 3) {
  5. //每次返回的数据responseText会包含上次的数据,所以需要手动substring一下
  6. var info = query.responseText.substring(lastIndex);
  7. $('body').append('<div>'+info+'</div>');
  8. lastIndex = query.responseText.length;
  9. }
  10. }
  11. query.open("GET", "/api", true);
  12. query.send(null);

上面的代码我在chrome下面测试通过,显然这东西兼容性很差,ie什么的就不要指望了。

2.使用iframe来调用

这也是一种曾经流行的方式,特点就是兼容性比较好。我们知道我们之前直接访问http://localhost:3000/api,页面上已经会一点点的出来数据了。我们可以在服务器端在数据外面包一层script标记,这样就可以调用前端页面上的函数,达到一种分段处理数据的目的。

首先改造下核心的服务端代码:

  1. //调用的接口点菜
  2. if(path === '/api'){
  3. //这边一定要设置为text/html; charset=UTF-8,不然就不会有分段效果
  4. res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  5. //5分熟的时候更新下状态
  6. setTimeout(function() {
  7. res.write('<script> top.read("当前进度50%") </script>');
  8. }, 3500);
  9. //这是个需要7秒才能完成的任务
  10. setTimeout(function() {
  11. res.end('<script> top.read("心好累,7秒后菜才好了。。。") </script>');
  12. }, 7000);
  13. }

可以看到我们在数据外面包了一层script标签还有方法。

然后前端代码,使用一个隐藏的iframe来加载接口:

  1. window.read = function(info){
  2. $('body').append('<div>'+info+'</div>');
  3. }
  4. $('body').append('<iframe style="display:none" src="/api"></iframe>');

当iframe加载时,一块块加载,加载一块就会调用父iframe的read方法。这样就达到了一点点提示的目的。

实际上这也是bigpie这种技术的主要实现方式,只不过不需要iframe,直接在当前页面更新视图就好了。这边就不扯了。

另外按照这个原理,这边我还尝试了下 动态插入script的方式,但是发现不管怎样都不会有分段调用的过程,应该是浏览器会等js全部加载完之后才会执行里面的代码。

总之这种方式实现了一个接口分段返回信息的功能,但是只是单向的服务端传输,不存在可操作性。

长轮询(long polling)

这是后来比较流行的一种方式,Facebook,Plurk都曾经使用过。这个技术被称为服务器推送技术。其实原理也很简单,就是一个请求过去了,不要马上返回,等数据有更新了,再返回。这样可以减少很多无意义的请求。

跟上面的polling的对比就是,轮询是每5秒就去问一次,不管状态有没有更新。而长轮询是服务员跑过去问了,但是状态没更新就先不回去,因为回去了再跑过来是没意义的。所以就等状态更新后再返回告诉客人,熟到几分了。

比如上面的例子,只有5分熟的时候才会更新状态,所以如果用轮询的方式,可能来来回回好几趟,但是返回的结果一直都是0%.完全没有意义。

我们把上面的改造成长轮询:
前端js:

  1. //先调用耗时接口,就是你开始点菜
  2. $.ajax({
  3. url: "/api",
  4. success:function(data){
  5. $('body').append('<div>'+data+'</div>');
  6. }
  7. })
  8. //叫服务员去问菜烧到几分熟了,状态更新了再回来告诉我,没到100%就立即再去问。
  9. function queryPercent(){
  10. $.ajax({
  11. url: "/pencent",
  12. success:function(pencent){
  13. $('body').append('<div>当前进度'+pencent+'</div>');
  14. if (pencent != '100%') {
  15. queryPercent();
  16. }
  17. }
  18. })
  19. }
  20. queryPercent();

服务端改造为:

  1. var http = require('http');
  2. var fs = require('fs');
  3. var url = require('url');
  4. //定义一个菜几分熟的变量,在实际运用中,可能是针对不同请求存储在数据库中的。
  5. //这边为了简单直接放在全局
  6. var percent = '0%';
  7. var isPencentUpate = false;
  8. http.createServer(function (req, res) {
  9. var path = url.parse(req.url).pathname;
  10. //查看菜几分熟了
  11. if(path === '/pencent'){
  12. //实际应用中这边最好使用事件机制。否则只是把轮询放到了后端而已。
  13. var tId = setInterval(function(){
  14. if (isPencentUpate){
  15. isPencentUpate = false;
  16. clearInterval(tId);
  17. res.end(percent);
  18. }
  19. },100);
  20. }
  21. //调用的接口点菜
  22. if(path === '/api'){
  23. percent = '0%';
  24. //5分熟的时候更新下状态
  25. setTimeout(function() {
  26. isPencentUpate = true;
  27. percent = '50%';
  28. }, 3500);
  29. //这是个需要7秒才能完成的任务
  30. setTimeout(function() {
  31. isPencentUpate = true;
  32. percent = '100%';
  33. res.end('心好累,7秒后菜才好了。。。');
  34. }, 7000);
  35. }
  36. if(path === '/'){
  37. //不是ajax接口,直接返回前端的html内容
  38. var indexStr = fs.readFileSync('index.html');
  39. res.setHeader('Content-Type', 'text/html; charset=UTF-8');
  40. res.end(indexStr);
  41. }
  42. }).listen(3000);
  43. console.log('Server listening on port 3000');

结果为:

  1. 当前进度50%
  2. 心好累,7秒后菜才好了。。。
  3. 当前进度100%

/pencent的请求只会发两次,只在服务端程序发现状态变更的时候请求才会返回数据。也就是一种主动推送的概念。

这种技术,不仅减少了请求,而且弥补了上面长连接的不可交互的弊端。但是因为一直维持着一个连接会比较占用资源。特别是对php,ruby这种一个请求一个进程的模型来说是硬伤,不过node没有这个问题。基于事件的请求模型使他天生就适合这种方式。

使用flash插件(Flash XMLSocket)

虽然苹果放弃了flash,虽然越来越多的前端放弃flash转投h5的怀抱,但是不得不承认,有的时候flash还是可以实现很多功能。

主要是,使用javascript跟flash通信,用flash提供的XMLSocket来实现。但是这种毕竟已经越来越被淘汰了,这边就不展开细讲了。

另外据说还有种使用更小众的Java Applet的socket接口来实现的。这个也不考虑了。早就淘汰了n年的东西了。

WebSocket

上面提到的插件方式,说白了都是使用javascript借助别人的socket实现。万幸的是html5已经提出了websocket的概念,javascript也可以在浏览器端实现socket了。虽然ie系列肯定不支持,但是我们还是有必要了解下。

说了这么多,我们先要科普下socket。socket也叫做套接字,提供了一种面向tcp、udp的编程方式。我们知道http协议是无状态的一次请求型的。只有浏览器端发起请求才能建立一次会话。而socket可以建立双向的通信。

首先我们撇开浏览器,看下nodejs里面的socket用法:

我们先建立一个socket服务端(server.js):

  1. var net = require('net');
  2. var server = net.createServer(function(c) { //'connection' listener
  3. console.log('server connected');
  4. //这边的c是一个net.Socket实例,本质上是一个可读可写流。
  5. c.on('end', function() {
  6. console.log('server disconnected');
  7. });
  8. //这边调用write客户端那边可以使用data监听到数据
  9. c.write('客户端你好!\r\n');
  10. c.write('客户端你幸苦了!\r\n');
  11. //调用end同时会触发客户端那边的实例的end事件
  12. c.end();
  13. //客户端那边写过来的数据可以使用data事件获取到。
  14. c.on('data', function(data) {
  15. console.log(data.toString());
  16. });
  17. });
  18. server.listen(8124, function() { //'listening' listener
  19. console.log('server bound');
  20. });

运行它

我们建立个socket客户端去连接这个服务端(client.js):

  1. var net = require('net');
  2. var client = net.connect({port: 8124},
  3. function() { //'connect' listener
  4. console.log('client connected');
  5. client.write('服务端你好!\r\n');
  6. });
  7. client.on('data', function(data) {
  8. console.log(data.toString());
  9. //client.end();
  10. });
  11. client.on('end', function() {
  12. console.log('client disconnected');
  13. });

运行client.js

服务端会打出

  1. server bound
  2. server connected
  3. 服务端你好!
  4. server disconnected

客户端会打出:

  1. client connected
  2. 客户端你好!
  3. 客户端你幸苦了!
  4. client disconnected

socket.io

参考资料

上面的总结探索都是网上各种查资料,再自己写例子实验出来的,感谢下面这些文章:

[1] 使用Node.JS构建Long Polling应用程序
[2] 基于 HTTP 长连接的“服务器推”技术
[3] Socket 通讯
[4] Browser 與 Server 持續同步的作法介紹

结语

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注