WebSocket - ( 一.概述 )
说到 WebSocket,不得不提 HTML5,作为近年来Web技术领域最大的改进与变化,包含CSS3、离线与存储、多媒体、连接性( Connectivity )等一系列领域,而即将介绍的 WebSocket 则是 HTML5 连接性领域( Connectivity )最值得称道的改进。
1 HTTP通信的几种方式
HTTP是用于文档传输简单同步请求的响应式协议,本质上是无状态的应用层协议,半双工的连接特性。传输层依然是传输控制协议( TCP )。
在介绍WebSocket之前,首先有必要介绍下在 WebSocket 未出现之前我们是怎样实现 HTTP 服务器与客户端交互通信。
1.1 轮询( polling )
一种定时的同步调用。当频率过低,信息不及时,当频率过高,对服务器负担大,会产生大量的不必要的连接开销。
// client
var time = 1000 * x; // 轮询频率 / ms
setInterval(function(){
//ajax
}, time);
1.2. 长轮询( long polling )
客户端向服务端请求,这个请求在有数据时返回,如果没有数据,这个请求会一直被挂起,直到有数据返回或超时。结束完成再次向服务器请求。在HTML/1.1之后,浏览器默认连接改为长连接( keep-alive ),正是基于此,服务器才能长时间和客户端保持连接直到返回或超时。
这种无需第三方插件仅依靠长连接维持客户端与服务器交互的技术普遍称为 comet 或 反向Ajax。
对于部分浏览器来说,同时建立过多的长连接会造成阻塞,例如IE,在打开两个长连接之后,第三个HTTP请求会被阻塞。因为HTTP 1.1规范对长连接数有相应规定,不建议建立两个以上的长连接,因为某些浏览器严格执行了这些规范。因此我们无论用哪种方式建立长连接的时候,如果要考虑应用程序性能体验,都需要注意避免这种情况——避免IE给我们造成的麻烦。
1.3. 流化( streaming )
客户端发送一个请求,服务器发送并维护一个持续更新和保持打开的开放响应,这个请求除非超时或主动关闭,否则会一直源源不断向客户端返回数据。
流化通常有两种技术方案,第一种通过在HTML页面添加一个隐藏的Iframe标签,然后再这个Iframe的src属性设置一个长连接请求,服务器据此不断向客户端发送数据。这个数据形式就是Javascript的函数调用。这种方式早期部分浏览器会一直处于loading状态。第二种利用部分浏览器的multi-part标志,但这种的局限性也很明显,受限于支持multi-part标志的浏览器,因为XMLHttpRequest在不同浏览器上有着不同的实现。
即便流的处理方式与长轮询一样具有较低延迟的特点,但仍要注意的是,很多流化的处理方式对于存在防火墙和代理网络并不太友好,由于连接一直打开,代理或防火墙可能会缓存响应,增加信息交付延迟。
/** 使用 Forever IFrame */
// client
<iframe id='hidden_iframe_polling' name='hidden_iframe_polling' style='display:none;' />
<script>
var url=''; // 请求地址
var time = 1000 * x; // 轮询频率 / ms
setInterval(function(){
document.getElementById('hidden_iframe_polling').src = url + "?t=" + new Date(); //时间戳保证每次都是最新请求
window.frames["hidden_iframe_polling"].location.reload();
}, time);
</script>
/** 使用 multi-part */
// client
var url = ''; // 请求地址
var type = ''; // 请求方式 post / get
var xhr = new XMLHttpRequest();
xhr.multipart = true; // multi-part 标志设置为 true
xhr.timeout = 1000 * x; // 超时 / ms
xhr.onreadystatechange = state_change_call;
xhr.open(type, url, true);
xhr.send(null);
function state_change_call(){
//回调
}
// server
// 建立长连接,设置content-type的值为multipart/mixed或multipart/x-mixed-replace
// 例:multipart/x-mixed-replace;boundary="string类型数据"
因为服务器端语言不同,无法一致描述,但本质上解决方式还是一样的。
1.4.其它
其它方式这边不再讨论,诸如捎带轮询( piggyback polling )、第三方插件( FlashSockets )之类。
直至今日,即便WebSocket有很多成熟的案例,但考虑到老版本浏览器兼容问题,以上部分技术( comet )依然在广泛使用,即便是一款成熟的WebSocket组件,大部分都会提供降级服务。因此我们在学习开发一套基于WebSocket的组件时,如果想用于生产环境,为了应用程序的健壮、稳定、兼容性,最好也为老版本浏览器提供降级服务,在不支持WebSocket的浏览器上使用 comet 技术。当然,完成之后也别忘了必要的性能和可伸缩性测试。
2. WebSocket
WebSocket是全双工、双向、单套接字连接。WebSocket是一个低层网络协议,可以在它的基础上构建其它标准协议。
HTML5在Web端为我们带来了革命性的变化,这些变化仿佛注入了一股强心剂,无论视觉还是用户体验,或甚是开发上简洁简约的体验,相较之前,都有了质的飞跃,在连接领域 WebSocket 也印证了这一点。
说的直观一点,WebSocket就是Http连接的升级版,当你需要进行WebSocket连接时,你就需要把即将要连接的Http请求的升级为WebSocket。
2.1. WebSocket Protocol
一旦建立起WebSocket请求,不需要客户端发起,客户端也能及时接收到来自服务端的数据。WebSocket使用起来相比传统HTTP通信更加的简洁、高效、直观,它解决了HTTP通信的诸多不足之处,而且它真的足够简单,这往往是说服我们使用它的理由。
当连接前判断浏览器是否支持原生WebSocket,只需要如下一行代码即可:
if(window.WebSocket){
// 支持WebSocket
}
WebSocket的连接都基于HTTP请求,那怎么区分这次请求是HTTP还是WebSocket呢?
只需要在请求头当中包含一个Upgrade的请求头,这是向服务器指定某种传输协议。例如指定WebSocket协议就是:
//
// -client
// 浏览器发送一个请求到服务器,表示它想把HTTP协议转为WebSocket。客户端通过更新头字段(Upgrade header)实现了这个目的
GET /echo HTTP/1.1
Sec-WebSocket-Key: xx
Sec-WebSocket-Verson: xx
Connection: Upgrade
Upgrade: websocket
//
// -server
// 如果服务器识别WebSocket协议,它通过Upgrade header接受协议转换
Connection:Upgrade
Sec-WebSocket-Accept: xx
Upgrade: WebSocket
此时HTTP连接会被基于TCP/IP连接的WebSocket连接所取代。WebSocket连接默认使用和HTTP(80)或者HTTPS(443)一样的端口,同样,你可以像部署Web服务一样使用其它端口。
另外,WebSocket为了完成握手,服务器必须响应一个计算出来的键值。这个响应说明服务器理解WebSocket协议。就像暗号一样,只有对上暗号才是自己人。
那么话说回来,究竟是如何计算响应键值的呢?很简单,响应函数从客户端的Sec-WebSocket-Key请求头中取得键值,并在Sec-WebSocket-Accept请求头中返回根据客户端通过SHA1计算出键值,通过BASE64返回字符串。
// -- node.js
// 计算响应键值函数
var KEY_SUFFIX = ""; //协议规范中包含的一个固定键值后缀,服务器必须得知道这个值
var hashWebSocketKey = function(key){
var sha1_enc= crypto.createHash("sha1").update(key + KEY_SUFFIX, "utf8");
return sha1_enc.digest("base64");
}
在建立WebSocket连接握手成功之后,服务器会一直以帧( frame )的形式往客户端发送数据。
WebSocket传输内容支持文本或二进制数据,这些数据的边界靠帧( frame )来维护。我们不妨看一下WebSocket的帧特性。
这是官方标准协议提供的结构图,接下来要做的就是解析这张图。第一眼看到这张图的时候,或许有点懵圈,仔细看就会明白。从第二行开始,居然是十进制的数值依次排开,其实就是第几位( bit )。然后再往下的内容都是追述及说明。
WebSocket帧头第一个字节第一位( bit )表示FIN码,因为WebSocket可以多帧,当你需要多帧的时候,前面的所有帧FIN位设值为0,最后一帧设置为1,用来标识结束帧。第一个字节第二位( bit )到第四位都是RSV码,分别占1bit,如果通信两端没有设置自定义协议,那么设置为0即可。第一个字节的后四位是操作码( opcode ),这是一个十六位无符号整数。
操作码 | 消息类型 | |
---|---|---|
%x0 | 0 | 附加数据帧 |
%x1 | 1 | 文本 |
%x2 | 2 | 二进制数据 |
%x8 | 8 | 连接关闭 |
%x9 | 9 | ping |
%xA | 10 | pong |
其它 | 一共16位,其余全部保留用作将来扩展 |
这里要提一句,WebSocket以文本传输的时候,都为UTF-8编码,是WebSocket协议允许的唯一编码。
第二个字节高1位( bit )为MASK掩码,俗称“屏蔽”,就是用来标识客户端到服务端的数据是否加密混淆内容(payload)。
第二个字节低7位( bit )用来标识消息内容的长度( payload len )。上图的Extended payload length是用来标识扩展的长度。这样做的好处是使用可变位数来标示编码长度能使消息更加紧凑。数据长度一共有三种情况,全都由低7位的值认定,如果取值在126以内,不包括126,则数据真实长度就是低7位的值。如果取值为126,则需要额外的两个字节来表示数据的真实长度,16位的无符号整数。如果取值127,那么需要额外的8个字节表示数据的真实长度,64位的无符号整数。
之后就是Masking-key,一共4个字节,当然这是上一段说到的MASK掩码设置为1的时候,且在客户端到服务端发送消息时才会存在。那么当存在Masking-key时,服务器接收的每个数据包在处理之前都需要解除掩码。
var data; //数据
var mask; //掩码
// 解除掩码
var content= new Buffer(data.length);
for(var i= 0; i < data.length; i++){
content[i] = buffer[i] ^ mask[i%4];
}
// content 就是解除掩码之后的数据
再之后就是Payload数据了。由此也可看出,WebSocket最小的传输大小仅为2KB,譬如关闭( %x8 )帧。
这个图表达更为形象,帧的所有内容几乎都被囊括其中。到这里关于帧也不再赘述。
以上简单叙述了握手、连接并以帧形式的传输数据包,那么接下来我们要看一下WebSocket的关闭握手。
WebSocket关闭时,不论客户端还是服务端都会发送一个终止的数字代码,以及一个表示关闭原因的字符串。当然,这些就像上面关于帧( frame )内容提到的一样,关闭操作的数据边界依然以帧的形式传输,操作符( opcode )为8,其中包含终止代码和内容文本。
到这里其实要简单叙述下WebSocket数字代码的含义。
代码 | 描述 | 场景 |
---|---|---|
0~999 | 禁止 | 1000以下代码皆无效,不用于任何目的 |
1000~2999 | 保留 | 这些代码都用于WebSocket的扩展和修订版本 |
3000~3999 | 需要注册 | 这些代码用于"应用程序、程序库、框架",可在IANA( 互联网分配机构 )注册 |
4000~4999 | 私有 | 可以用作自定义 |
而我们所说的关闭代码正是在1000~2999之间,例如1000表示正常关闭,1001表示离开,1002表示协议错误,1007表示无效数据,1011表示意外情况等。
关于WebSocket协议,与TCP一样可以异步发送消息,都是可以用作高级协议的传输层。这么说不是把WebSocket协议等同于TCP,尽管把WebSocket当作传输层使用,它的层次仍然在TCP之上。根据OSI的协议层次,IP在网络层,TCP/UDP在传输层,HTTP位于应用层,与WebSocket协议一样,同样有着帧实现的SPDY( 由Google提出,增量性的提高HTTP的性能 ),位于会话层。
下面是各个协议之间对比:
TCP | HTTP | WebSocket | |
---|---|---|---|
寻址 | IP地址+端口 | URL | URL |
并发传输 | 全双工 | 半双工 | 全双工 |
内容 | 字节 | MIMI消息 | 文本或二进制 |
消息定界 | 否 | 是 | 是 |
消息定向 | 是 | 否 | 是 |
另外,如果我们想对WebSocket协议扩展,可以使用Sec-WebSocket-Extensions请求头,这个请求头包含扩展的名称。
2.2. WebScoket API
上面内容主要讨论了WebSocket Protocol相关内容,对其作了一个简单介绍。那么下面将介绍如何去使用WebSocket,如果要使用WebSocket,那么将用到WebSocket API。
WebSocket API用来控制WebSocket协议,响应服务器触发的事件。利用这个API,可以用来打开、关闭、发送、接受和监听服务器触发的事件。
1.WebSocket构造函数
var url =""; //URL地址
var protocols = []; //协议数组
var ws = new WebSocket(url, protocols); //构造函数
WebSocket(url, protocols)构造函数接受一个或两个参数。
第一个参数url指定要连接的url。这个url可能是ws:或wss:,类似于HTTP请求的http:或者https:。WebSocket也提供了传输层安全性的连接( TLS/SSL )。
第二个是一个协议数组,非必填项。如果它是一个字符串,它相当于一个数组组成的字符串,如果省略,它相当于空数组。其实在protocols参数指定的协议基本有三种类型:一、注册协议,向注册管理实体IANA正式注册的标准协议;二、开放协议,广泛使用的标准化协议,如XMPP或STOMP;三、自定义协议,自己编写并和WebSocket一起使用的协议。例如,protocols有可能是简单对象访问协议( SOAP )或其它自定义协议。
当创建的WebSocket构造函数被调用时,会先解析URL参数,获取主机、端口、资源名称、安全。如果操作失败,抛出SyntaxError异常并终止操作。如果存在一个安全组件,例如套接字安全协议https,但分析出这个安全是false,例如无效的安全证书,那么抛出一个SecurityError异常。如果protocols协议数组或字符串中Sec-WebSocket-Protocol头字段定义的值超过一次不匹配,则抛出一个SyntaxError异常并中止。此时返回这个WebSocket的对象,但是后台依然会继续这些操作。
2.事件
由于WebSocket应用程序监听WebSocket对象上的事件,用于处理数据和连接状态,WebSocket对象存在4个事件,包括open、message、error、close。
var socket = new WebSocket('ws://game.example.com:12010/updates');
// 打开事件
socket.onopen = function (e) {
console.log("websocket 打开");
};
// 消息事件
socket.binaryType = "";
socket.onmessage = function (e) {
if (typeof e.data === "string"){
console.log("处理文本格式数据.") ;
if (event.data == 'on') {
console.log("处理当数据等于on时.") ;
}
}
};
//error 事件
socket.onerror = function(e){
console.log("正在处理错误");
}
//关闭事件
socket.onclose = function(e){
console.log("连接关闭");
}
这里额外提一下,close事件有三个属性,可用于处理和恢复:wasClean、code、reason。wasClean属性为布尔类型,表示是否顺利连接,如果接收到一个正常的close帧,则该属性为true,如果因为其它原因关闭,该属性为false。code和reason分别代表错误代码和关闭原因,这个在下文介绍close()方法会具体阐述如何使用。
3.方法
WebSocket有两个方法:send()和close()。
// 发送消息
var msg =""; //定义消息
socket.onopen = function(e){
socket.send(msg); //发送
}
// OR
function sendHandler(e){
if (ws.readState === WebSocket.Open){
socket.send(msg);
} else { }
}
// 关闭方法
var code = ""; //定义代码
var reason = ""; //关闭原因
socket.close(code,reason);
send()方法在WebSocket连接打开之后使用。
4.对象特性
readState,用于报告连接状态。从下图可以看到WebSocket的只读状态从连接到关闭的取值的整个对象生命周期。
常量 | 值 | 状态 |
---|---|---|
WebSocket.CONNECTION | 0 | 正在握手请求中,还未完成连接 |
WebSocket.OPEN | 1 | 连接已打开 |
WebSocket.CLOSING | 2 | 连接正在关闭 |
WebSocket.CLOSED | 3 | 连接已关闭 |
bufferAmount,用于检查发往服务器的缓冲数据量。调用send()方法能使我们立即往服务器发送数据,但是数据量较大、网络带宽有限的时候,我们就想知道网络传输速率。这时候我们就可以用bufferedAmount检查发送队列中未发送到服务器的字节数。
// -client
var info_size= 1024 * 100; //传输数据长度
var _url =""; //WebSocket服务器地址
var ws = new WebSocket(_url);
ws.onopen = function(){
setInterval(function(){
if(ws.bufferedAmount < info_size){
//do something
}
},1000);
}
protocol,用于服务器理解客户端在WebSocket上使用的协议。只有在握手完成之后、关闭之前,且服务器选择了客户端提供的协议,这个特性才会存值。否则为空。
附:SSE ( Server-Send Event )
尽管这个并不属于WebSocket,但是属于HTML5规范一部分,放在这提一下是因为HTML5除了提供WebSocket这种强大特性之外,还提供这种加强了comet的技术。这样的话,你依然可以不通过WebSocket实现某些业务需求。顾名思义,SSE主要功能是向客户端广播或推送消息。如果仅需使用到服务器单方面推送或广播,并不需要双向全双工通信,那么SSE是一个很不错的选择。在应用发面,比如可以推送新闻、天气等。