本周在应用宝前端分享会上分享了Web实时通信技术,分享内容整理如下。

一、传统Web数据更新

传统的Web数据更新,必须要刷新网页才能显示更新的内容。这是浏览器采用的是B/S架构,而B/S架构是基于HTTP协议的。HTTP协议的工作模式就是客户端向服务器发送一个请求,服务器收到请求后返回响应。所以这种工作模式是基于请求显示数据的。

这样的工作方式有其自身的好处,但是也会导致很多问题。在Web应用越来越火的今天,经常会遇到需要服务器主动发送数据到客户端的需求,比如事件推送、Web聊天等。这些需求使用传统的Web数据更新工作模式是无法实现的,因此就需要一项新的技术:Web实时通信技术。

二、短轮询

第一种解决方法思路很简单,既然需要客户端发送请求服务器才能发送数据,那么就可以让客户端不断的向服务器发送数据,这样就能实时的获取服务器端的数据更新了。具体的实现方法很简单,客户端每隔一定时间就发送一个请求到服务器端。下面的图可以清晰的反映出短轮询过程中客户端和服务器的工作流程:

Web实时通信技术_网页

下面看一下实现方法。

在服务器端我们模拟数据的发送,生成1-1000的随机数,当数值小于800的时候模拟没有数据的情况,大于800的时候模拟有数据的情况,并返回数据:

<?php$arr = array('title'=>'推送!','text'=>'推送消息内容');
$rand = rand(1,999);if(rand < 800){echo “”
}else{  echo json_encode($arr);
}?>

客户端部分,定义了一个函数用来发送ajax请求到客户端,然后每隔2s就发送以此请求:

<!doctype html><html>
<head>
<meta charset="utf-8">
<title>短轮询ajax实现</title>
<script type="text/javascript" src="../jquery.min.js"></script>
</head>
<body>
<form id="form1" runat="server">
     <div id="news"></div>
    </form>
</body>
<script type="text/javascript">  function showUnreadNews()
    {
        $(document).ready(function() {
            $.ajax({
                type: "GET",
                url: "setInterval.php",
                dataType: "json",
                success: function(msg) {
                    $.each(msg, function(id, title) {
                        $("#news").append("<a>" + title + "</a><br>");
                    });
                }
            });
        });
    }
    setInterval('showUnreadNews()',2000);
</script>
</html>

运行程序我们可以在Chrome的network工具看到,每隔两秒都会有一个请求从客户端发往服务器,不管当时的服务器有没有数据,都会立即返回请求。、

短轮询虽然简单,但是它的缺点也是显而易见的。首先短轮询建立了很多HTTP请求,而且其中绝大部分的请求是没有用处的。而HTTP连接数过多过多会严重影响Web性能。其次,客户端设置的请求发送时间间隔也不好掌控,时间间隔太短会造成大量HTTP的浪费,而时间间隔过长会使得客户端不能即时收到服务器端的数据更新,失去了即时通信的意义。

三、长轮询

针对上面短轮询的种种问题,我们自然而然想到要减少HTTP请求的数量,才能让实时通信性能更高。而长轮询就能有效的减少HTTP请求的数量。

长轮询的逻辑是,首先客户端向服务器端发送一个请求,服务器端在收到请求后不马上返回该请求,而是将请求挂起。一段时间后服务器端有数据更新时,再将这个请求返回客户端,客户端收到服务器端的响应数据后渲染界面,同时马上再发送一个请求到服务器,如此循环,下面的图描述了这个过程:

Web实时通信技术_客户端_02

长轮询有效的减少了HTTP连接。服务器端在有数据更新时才返回数据,客户端收到数据再请求这一机制,较少了之间许多的无用HTTP请求。下面通过一个Demo来演示长轮询的工作模式。

服务器端模拟数据更新,在客户端发来请求后先挂起6s,模拟6s后才有数据的情况:

<?php$arr = array('title'=>'推送','text'=>'推送消息内容');
$flag = 0;for($i=1;$i<=6;$i++){  if($i>5){   //i = 6时表示有数据了
    echo json_encode($arr);
  }else{
    sleep(1);
  }
}?>

这里为了演示的更清楚,添加了一个for循环,其实就是先将请求挂起6s。

客户端发送一个ajax请求,并当收到服务器端数据后自动再发送一个请求到服务器:

<!doctype html><html>
<head>
<meta charset="utf-8">
<title>长轮询ajax实现</title>
<script type="text/javascript" src="../jquery.min.js"></script>
</head>
<body>
<input type="button" id="btn" value="click">
<div id="msg"></div>
</body>
<script type="text/javascript">
$(function(){
        $("#btn").bind('click',{btn:$('#btn')},function(e){
            $.ajax({
                type: 'POST',
                dataType: 'json',               
                url: 'do.php',
                timeout: '20000',
                success: function(data,status){
                    $("#msg").append(data.title + ':' + data.text + "</br>");
                    e.data.btn.click(); 
                }
            });
        });
    });
</script>
</html>

长轮询虽然有效的减少了HTTP请求,但是HTTP请求相比之下还是很多的,因为每次数据的更新都需要建立一个HTTP请求。下面的技术就可以实现建立以此HTTP连接,服务器可以源源不断的向客户端发送数据。

四、SSE

MessageEvent是HTML5中新定义的一种事件基类,和原先的MouseEvent、UIEvent一样。MessageEvent是专门为数据传输定义的事件,Html5中的SSE和WebSocket都利用了这个事件。MessageEvent在HTML5协议中的接口如下:

Web实时通信技术_浏览器_03

除了继承了Event事件具有的属性外,MessageEvent还定义了其他属性。其中data属性所包含的内容就是传输的数据内容。而lastEventId可以存放一个事件标识符,当客户端和服务器传输数据过程中断开连接需要重连时,客户端会将上一次传输数据的lastEventId作为请求头中的一个特殊字段发送到服务器,从而让服务器可以继续上次断开连接的部分发送消息。

SSE是HTML5规范中定义的,它可以实现维持一个HTTP连接,服务器端单向向客户端不断发送数据。这个技术真正意义上实现了服务器主动推送数据到客户端。除此之外,SSE的客户端和服务器端代码都特别简洁,使用起来十分方便。

SSE的逻辑就是首先客户端发送请求,建立一个HTTP连接,之后服务器端和客户端一直保持这个连接,服务器端可以单向向客户端发送数据,见下图:

Web实时通信技术_浏览器_04

SSE的实现方法很简单,SSE是以事件流的形式发送数据的。在服务器端要先进行如下配置:

Content-Type:text/event-streamCache-Control:no-cacheConnections:keep-alive

如果还需要进行跨域,配置里再添加:

Access-Control-Allow-Origin: *

其中text/event-stream是HTML5规范中为SSE的事件流传输定义的一种流格式。

做好配置后,服务器第二个要做的事就是维护一个清单,清单内容就是要向客户端发送的数据,下面是一段例子:

data: first event  
 event: push
data: second event

每一组事件流传输对应的数据,每个事件流之间使用换行符进行分割。这种格式发送过去之后会被进行解析,最后将各个部分进行组装,客户端按需进行读取。每一个事件流可以为其指定四个字段。

(1)retry字段

  SSE有一个自动重连机制,即客户端和服务器之间的连接断开后,隔一段时间客户端就会自动重连。retry指定的就是这个重连时间,单位为ms。

(2)event字段

SSE中有三个默认的事件,它们都继承自MessageEvent事件类。其中open事件在连接建立时触发,message事件在从客户端接收到数据时触发,close事件在连接关闭时触发。如果传输事件时一个事件流没有设定event字段的值,那么客户端就会监听message默认事件;如果指定了event事件,那么就会生成自定义的event事件,客户端可以监听自定义的event进行数据读取。

(3)data字段

data字段包含的内容就是服务器要传送给客户端的数据,SSE只能传送文本数据,且必须是UTF-8编码的。由于这些事件都继承自MessageEvent基类,因此可以通过event.data获取服务器传输的数据。

(4)id字段

id字段是事件的唯一标识符,解析后会被传入MessageEvent对应的lastEventId属性字段中,从而记录上次数据传输的位置。如果不指定id字段lastEventId字段就是一个空字符串。

至此服务器端任务完成,下面介绍客户端的实现方法。

客户端首先需要实例化一个EventSource对象。EventSource对象在HTML5中的接口定义如下:

首先需要为EventSource对象传入一个url,表明要请求的服务器地址。该对象有三个readyState状态值,其中CONNECTING表示正在建立连接,OPEN表示连接处于打开状态可以传输数据,CLOSED状态表示连接中断,并且客户端并没有尝试重连。EventSource定义的默认事件句柄为onopen、onmessage、onerror。其中的方法只有close(),用来关闭连接。

实例化好EventSource对象后,我们需要对事件进行监听,从而获取数据,最后可以通过close()方法关闭连接,整体逻辑的代码如下:

var es = new EventSource(url);  
es.addEventListener("message", function(e){    console.log(e.data);
})
es.close();

下面是一个实现SSE的例子。

服务器使用node,代码及注释如下:

var http = require("http");var fs = require("fs");//创建服务器http.createServer(function (req, res) {  var index = "./index.html";  var fileName;  var interval;  var i = 1;  //设置路由
  if (req.url === "/"){
    fileName = index;
  }else{
    fileName = "." + req.url;
  }  if (fileName === "./stream") {    //配置头部信息:注意类型为专门为sse定义的event-stream,并且不使用缓存
    res.writeHead(200, {"Content-Type":"text/event-stream", "Cache-Control":"no-cache", "Connection":"keep-alive"});    /*
      下面的代码的输出结果等价于:
      retry: 10000
      event: title
      data: News Begin
 
      data: ...
 
      ...
    */
    //上面可以看出,只有第一段是触发事件connecttime,其他都是触发默认事件message
    res.write("retry: 10000\n");    //定义连接断开后客户端重新发起连接的时间,ms制
    res.write("event: title\n");   //自定义的事件title
    res.write("data: News Begin! \n\n");    //每隔1s就在协议中新写入一段数据来模拟服务器向客户端发送数据
    interval = setInterval(function() {
      res.write("data: News" + i +"\n\n");
      i++;
    }, 1000);    //监听close事件,当服务器关闭时停止向客户端传送数据
    req.connection.addListener("close", function () {
      clearInterval(interval);
    }, false);
  } else if (fileName === index) {
    fs.exists(fileName, function(exists) {      if (exists) {
        fs.readFile(fileName, function(error, content) {          if (error) {
            res.writeHead(500);
            res.end();
          } else {
            res.writeHead(200, {"Content-Type":"text/html"});
            res.end(content, "utf-8");
          }
        });
      } else {
        res.writeHead(404);
        res.end();
      }
    });
  } else {
    res.writeHead(404);
    res.end();
  }
}).listen(8888);console.log("Server running at http://127.0.0.1:8888/");

服务器端自定义了title事件,用来发送标题数据,其他的数据使用默认事件发送。

客户端部分代码及注释如下:

<!DOCTYPE html><html lang="en">
<head>
  <title>Server-Sent Events Demo</title>
  <meta charset="UTF-8" />
  <script>    window.onload = function() {      var button = document.getElementById("connect");      var status = document.getElementById("status");      var output = document.getElementById("output");      var connectTime = document.getElementById("connecttime");      var source;      function connect() {
        source = new EventSource("stream");        //messsage事件:当收到服务器传来的数据时触发
        source.addEventListener("message", function(event) {
          output.textContent = event.data;  //每次收到数据后都更新时间
        }, false);        //自定义的事件title
        source.addEventListener("title", function(event) {
          connectTime.textContent = event.data;
        }, false);        //open事件:当客户端和服务器完成连接时触发
        source.addEventListener("open", function(event) {          //每次连接成功后更新按钮功能和文本提示,再次点击按钮应为关闭连接
          button.value = "Disconnect";
          button.onclick = function(event) {            //调用eventsource对象的close()方法关闭连接,并且为其绑定新的事件connect建立连接
            source.close();
            button.value = "Connect";
            button.onclick = connect;
          };
        }, false);        //异常处理
      }      //调用,如果支持EVentSource则执行connect()方法仅从sse连接
      connect();
    }
  </script>
</head>
<body>
  <input type="button" id="connect" value="Connect" /><br />
  <span id="status"></span><br />
  <span id="connecttime"></span><br />
  <span id="output"></span>
</body>
</html>

客户端监听事件,不同的事件收到的数据进行不同的渲染。同时,都过为按钮绑定事件,调用close()等方法,实现SSE连接的打开与断开。

SSE技术简单方便,且是HTML5中定义内容,实现了服务器推送数据的技术,下图是SSE的浏览器兼容性列表:

Web实时通信技术_客户端_05

但是SSE只能实现服务器到客户端单向的数据传输。有时我们的需求需要使用双向数据传输,这时就需要使用WebSocket。

五、WebSocket

WebSocket也是HTML5中定义的。它是一个新的协议,实现了全双工的通信模式,即客户端和服务器端可以互相发送消息。WebSocket的实现首先需要客户端和服务器端进行一次握手,此后就会建立起一个数据传输通道,通道存在期间客户端和服务器端可以平等的互相发送数据。具体的逻辑图如下:

Web实时通信技术_通信技术_06

WebSocket的服务器端实现比较复杂,但是各个后台语言都已经有实现好的WebSocket库。比如Node.js中的nodejs-websocket模块和socket.io模块。使用WebSocket技术可以实现很多功能,附件中就是借助nodejs-websocket模块编写的弹幕效果。

WebSocket的客户端实现比较便捷,首先需要实例化一个WebSocket对象,传入要请求的服务器的url。这里需要注意,协议名要指定为ws或wss,如:

ws = new WebSocket("ws://localhost:8080");

客户端可以通过调用send()方法进行数据的发送,通过调用close()方法关闭WebSocket连接。WebSocket也使用了MessageEvent接口,因此可以对消息事件进行监听,默认的可以通过监听message事件获取数据。下面是WebSocket在HTML5规范中定义的接口:

Web实时通信技术_浏览器_07

WebSocket的服务器端实现可以分为两个部分,第一个部分是握手部分,主要负责HTTP协议的升级,第二个部分是数据传输部分。

WebSocket协议可以说是一个HTTP协议的升级版,这个升级过程需要通过客户端和服务器的一次握手来实现。下面是建立握手时客户端向服务器发送的请求报文头实例:

Web实时通信技术_网页_08

字段Upgrade:websocket和Connection:Upgrade部分完成了协议的升级,服务器可以触发响应事件,获取这两个字段的内容,匹配符合要求后服务器端进行握手处理。客户端需要向服务器端发送一个Sec-WebSocket-Key字段,这个字段的内容是客户端产生的,相当于一个私钥。服务器端收到客户端的请求头后,如果确定是要使用WebSocket协议,就开始进行握手。服务器端使用客户端传来的Sec-WebSocket-Key的值,与服务器端存储的一个全局唯一标识符进行拼接,之后做SHA1处理和BASE64加密,并作为响应头返回给客户端。服务器端的GUID相当于公钥。

下面是服务器端返回的响应报文头,处理后的字符串在Sec-WebSocket-Accept字段中给出。客户端必须受到101状态码,这个状态码表示切换协议,从而完成对协议的升级。

Web实时通信技术_服务器_09

总的来看,WebSocket只有在建立握手连接的时候借用了HTTP协议的头,连接成功后的通信部分都是基于TCP的连接,可以说WebSocket协议是HTTP协议的升级版。

WebSocket的数据帧格式如下:

Web实时通信技术_服务器_10

opcode存储的是传输数据的类型,诸如文本、二进制数据等。数据传输时首先会对该部分的值进行判断,然后进行对应的数据操作。数据存储在Payload Data字段中。最后将帧结构解析为一个键值对的对象。

下面是浏览器对WebSocket的支持情况:

Web实时通信技术_浏览器_11