以前在项目中简单的使用过,可是追究使用它的原因、优势以及原理,在这以前笔者也是模糊不清,因此在这期间,作了比较系统的了解后,在此记录一番。html
话很少说,切入正题。可能在了解到这个协议的时候,大多数人都不知道它是作什么,或者说不知道为何须要这个协议,那么咱们就从基础开始,一点点的了解。node
仍是如今这里粘一个下面代码的github地址。 git
WebSocket是一种 网络传输协议(说到网络协议,你们可能会立马想到HTTP协议,下面会有二者的对比),可在单个TCP链接上进行全双工通讯,位于OSI模型的应用层。github
WebSocket使得客户端和服务器之间的数据交换变得更加简单,容许服务端向客户端推送数据。在WebSocket API中,浏览器和服务器只须要完成一次握手,二者之间就能够建立持久性的连接,并进行双向数据传输。web
WebSocket协议规范将 ws
(WebSocket)和 wss
(WebSocket Secure)定义为两个新的统一资源标识符,分别对应明文和加密链接。ajax
WebSocket最大特色就是,服务器能够主动向客户端推送信息,客户端也能够主动向服务器发送信息。算法
在这个协议以前,不少网站为了实现 推送技术,所用的技术都是轮询。轮询是在特定的时间间隔,由浏览器对服务器发出HTTP请求,而后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器须要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会消耗不少的带宽资源。npm
在这种状况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,而且可以更实时地进行通信。api
在上面简单的介绍WebSocket以后,想必你们也均可以总结出一些WebSocket的优势,下面相较于HTTP再作进一步的总结浏览器
较少的控制开销:在链接建立后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的状况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还须要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减小了。
更强的实时性:因为协议是全双工的,因此服务器能够随时主动给客户端下发数据。相对于HTTP请求须要等待客户端发起请求服务端才能响应,延迟明显更少
保持链接状态:Websocket须要先建立链接,这就使得其成为一种有状态的协议,以后通讯时能够省略部分状态信息。而HTTP请求可能须要在每一个请求都携带状态信息(如身份认证等)。
更好的二进制支持:Websocket定义了二进制帧,相对HTTP,能够更轻松地处理二进制内容。
简单来说,WebSocket协议由两部分组成:创建链接过程(握手) 和 数据传输。
在第一部分的介绍中,咱们提到,WebSocket在建立持久性链接以前,须要进行一次握手,并且为了兼容性考虑,WebSocket复用了HTTP的握手通道。具体指的是,客户端经过HTTP请求与WebSocket服务端协商升级协议。协议升级完成以后,后续的数据交换则遵守WebSocket的协议。
首先,客户端发起协议升级请求,从下图能够看到,采用的是标准的HTTP报文格式,且只支持 GET
方法。
重点说明一下上面四处的意义:
Connection:Upgrade
:表示要升级协议Upgrade:WebSocket
:表示要升级到WebSocket协议Sec-WebSocket-Key
与后面服务器端响应首部的Sec-WebSocket-Accept
是配套的,提供基本的防御,好比恶意的连接,或者无心的链接Sec-WebSocket-Version: 13
:表示WebSocket的版本。若是服务器不支持该版本,须要返回一个Sec-WebSocket-Version
的header
,里面包含服务端支持的版本号注意,上面请求省略了部分非重点请求首部。因为是标准的HTTP请求,相似Host、Origin、Cookie等请求首部会照常发送。在握手阶段,能够经过相关请求首部进行 安全限制、权限校验等。
服务端返回内容以下,状态码 101
表示协议切换。到此完成协议升级,后序的数据交互都按照新的协议进行。
Sec-WebSocket-Accept
根据客户端请求首部的Sec-WebSocket-Key
计算出来。
计算公式为:
将Sec-WebSocket-Key
跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11
拼接。 经过SHA1
计算出摘要,并转成base64
字符串。 伪代码以下:
>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
复制代码
一旦WebSocket客户端、服务端创建链接后,后续的操做都是基于数据帧的传递。
由于此处涉及到了数据帧的知识,因此能够先查看2.3 数据帧格式的部分。
一、数据分片
WebSocket的每条消息可能被切分红多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN
的值来判断,是否已经收到消息的最后一个数据帧。
FIN=1
表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,能够对消息进行处理。FIN=0
,则接收方还须要继续监听接收其他的数据帧。
此外,opcode
在数据交换的场景下,表示的是数据的类型。0x01
表示文本,0x02
表示二进制。而0x00
比较特殊,表示延续帧(continuation frame
),顾名思义,就是完整消息对应的数据帧还没接收完。
二、数据分片例子
直接看例子更形象些。下面例子来自MDN,能够很好地演示数据的分片。客户端向服务端两次发送消息,服务端收到消息后回应客户端,这里主要看客户端往服务端发送的消息。
第一条消息
FIN=1
, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,能够处理消息。opcode=0x1,表示客户端发送的是文本类型。
第二条消息
FIN=0,opcode=0x1
,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
FIN=0,opcode=0x0
,表示消息还没发送完成,还有后续的数据帧,当前的数据帧须要接在上一条数据帧以后。
FIN=1,opcode=0x0
,表示消息已经发送完成,没有后续的数据帧,当前的数据帧须要接在上一条数据帧以后。服务端能够将关联的数据帧组装成完整的消息。
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!
复制代码
客户端、服务端数据的交换,离不开数据帧格式的定义。因此咱们在这里看一看WebSocket的数据帧格式。
客户端、服务端数据的交换,离不开数据帧格式的定义。所以,在实际讲解数据交换以前,咱们先来看下WebSocket的数据帧格式。
WebSocket客户端、服务端通讯的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。
发送端:将消息切割成多个帧,并发送给服务端;
接收端:接收消息帧,并将关联的帧从新组装成完整的消息;
本节的重点,就是讲解数据帧的格式。
一、数据帧格式概览
下面给出了WebSocket数据帧的统一格式。
从左到右,单位是比特。好比FIN、RSV1
各占据1比特,opcode
占据4比特。 内容包括了标识、操做代码、掩码、数据、数据长度等。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
复制代码
二、数据帧格式详解
针对前面的格式概览图,这里逐个字段进行讲解,能够参考协议规范。
FIN:1个比特。
若是是1,表示这是消息(message)的最后一个分片(fragment),若是是0,表示不是是消息(message)的最后一个分片(fragment)。
RSV1, RSV2, RSV3:各占1个比特。
通常状况下全为0。当客户端、服务端协商采用WebSocket扩展时,这三个标志位能够非0,且值的含义由扩展进行定义。若是出现非零的值,且并无采用WebSocket扩展,链接出错。
Opcode: 4个比特。
操做代码,Opcode
的值决定了应该如何解析后续的数据载荷(data payload
)。若是操做代码是不认识的,那么接收端应该断开链接(fail the connection
)。可选的操做代码以下:
%x0
:表示一个延续帧。当Opcode
为0时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
%x1
:表示这是一个文本帧(frame)
%x2
:表示这是一个二进制帧(frame)
%x3-7
:保留的操做代码,用于后续定义的非控制帧。
%x8
:表示链接断开。
%x9
:表示这是一个ping
操做。
%xA
:表示这是一个pong
操做。
%xB-F
:保留的操做代码,用于后续定义的控制帧。
Mask: 1个比特
表示是否要对数据载荷进行掩码操做。从客户端向服务端发送数据时,须要对数据进行掩码操做;从服务端向客户端发送数据时,不须要对数据进行掩码操做。
若是服务端接收到的数据没有进行过掩码操做,服务端须要断开链接。
若是Mask
是1,那么在Masking-key
中会定义一个掩码键(masking key
),并用这个掩码键来对数据载荷进行反掩码。全部客户端发送到服务端的数据帧,Mask
都是1。
Payload length:数据载荷的长度,单位是字节。为7位,或7+16位,或1+64位
假设数Payload length === x
,若是
x为0~126:数据的长度为x字节。
x为126:后续2个字节表明一个16位的无符号整数,该无符号整数的值为数据的长度。
x为127:后续8个字节表明一个64位的无符号整数(最高位为0),该无符号整数的值为数据的长度。
此外,若是payload length
占用了多个字节的话,payload length
的二进制表达采用网络序(big endian,重要的位在前)。
Masking-key:0或4字节(32位)
全部从客户端传送到服务端的数据帧,数据载荷都进行了掩码操做,Mask为1,且携带了4字节的Masking-key
。若是Mask为0,则没有Masking-key
。
备注:载荷数据的长度,不包括mask key的长度。
Payload data:(x+y) 字节
载荷数据:包括了扩展数据、应用数据。其中,扩展数据x字节,应用数据y字节。
扩展数据:若是没有协商使用扩展的话,扩展数据数据为0字节。全部的扩展都必须声明扩展数据的长度,或者能够如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。若是扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
应用数据:任意的应用数据,在扩展数据以后(若是存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就获得应用数据的长度。
三、掩码算法 掩码键(Masking-key
)是由客户端挑选出来的32位的随机数。掩码操做不会影响数据载荷的长度。掩码、反掩码操做都采用以下算法:
首先,假设:
original-octet-i
:为原始数据的第i字节。
transformed-octet-i
:为转换后的数据的第i字节。
j
:为i mod 4
的结果。
masking-key-octet-j
:为mask key
第j字节。
算法描述为: original-octet-i
与 masking-key-octet-j
异或后,获得 transformed-octet-i
。
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
复制代码
WebSocket为了保持客户端、服务端的实时双向通讯,须要确保客户端、服务端之间的TCP通道保持链接没有断开。然而,对于长时间没有数据往来的链接,若是依旧长时间保持着,可能会浪费包括的链接资源。
但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍须要保持链接。这个时候,能够采用心跳来实现。
ping
接收方->发送方:`pong` `ping`、`pong的操做`,对应的是WebSocket的两个控制帧,`opcode
分别是0x9、
0xA`。在这一部分最后,在说明两个知识点(不作详细说明)
1.Sec-WebSocket-Key/Accept
的做用:主要做用在于提供基础的防御,减小恶意链接、意外链接。 做用大体概括以下:
避免服务端收到非法的websocket链接(好比http客户端不当心请求链接websocket服务,此时服务端能够直接拒绝链接)
确保服务端理解websocket链接。由于ws
握手阶段采用的是http协议,所以可能ws链接是被一个http服务器处理并返回的,此时客户端能够经过Sec-WebSocket-Key
来确保服务端认识ws
协议。(并不是百分百保险,好比老是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key
,但并无实现ws协议。。。)
用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key
以及其余相关的header是被禁止的。这样能够避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade
)
能够防止反向代理(不理解ws协议)返回错误的数据。好比反向代理先后收到两次ws
链接的升级请求,反向代理把第一次请求的返回给cache住,而后第二次请求到来时直接把cache住的请求给返回(无心义的返回)。
Sec-WebSocket-Key
主要目的并非确保数据的安全性,由于Sec-WebSocket-Key、Sec-WebSocket-Accept
的转换计算公式是公开的,并且很是简单,最主要的做用是预防一些常见的意外状况(非故意的)。
2. 数据掩码的做用:
WebSocket协议中,数据掩码的做用是加强协议的安全性(并非为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks
)等问题。)。但数据掩码并非为了保护数据自己,由于算法自己是公开的,运算也不复杂。除了加密通道自己,彷佛没有太多有效的保护通讯安全的办法。
<input type="text" id="sendTxt">
<button id="sendBtn">发送</button>
<div id="recv"></div>
<script>
/**
* WebSocket对象做为一个构造函数,用于新建WebSocket实例
* 执行下面的语句以后,客户端就会个服务器进行链接
*/
let webSocket = new WebSocket("wss://echo.websocket.org");
/**
* 下面结合实际讲一下WebSocket实例对象的属性和方法
* 1. 属性
* 1.1 webSocket.readyState(属性返回实例对象的当前状态)
* . CONNECTING:值为0,表示正在链接。
* . OPEN:值为1,表示链接成功,能够通讯了。
* . CLOSING:值为2,表示链接正在关闭。
* . CLOSED:值为3,表示链接已经关闭,或者打开链接失败。
*/
/**
* 1.2 webSocket.onopen(用于指定链接成功后的回调函数)
*/
webSocket.onopen = function () {
console.log("webSocket open");
document.getElementById('recv').innerHTML = "Connected";
};
/**
* 1.3 webSocket.onclose(用于指定链接关闭以后的回调函数)
*/
webSocket.onclose = function () {
console.log("webSocket close");
}
/**
* 1.4 webSocket.onmessage(用于指定收到服务器数据后的回调函数)
*/
webSocket.onmessage = function (e) {
console.log(e.data);
document.getElementById('recv').innerHTML = e.data;
}
//发送信息
document.getElementById('sendBtn').onclick = function () {
var text = document.getElementById('sendTxt').value;
/**
* 2. 方法
* 2.1 webSocket.send() (用于向服务器发送数据)
*/
webSocket.send(text);
}
</script>
复制代码
客户端的API上面的代码中有简单的介绍以及使用,若是想要查看更加具体的文档说明,能够在MDN进行查看。
对比于服务端的实现,客户端的使用略显简单,那么接下来咱们继续实现服务端的WebSocket。
由于笔者目前局限于JS,因此服务端的实现是使用的Node,经常使用的Node实现有如下三种:
µWebSockets
Socket.IO
WebSocket-Node
由于在项目中使用的是Socket.IO
,因此在这里笔者就结合本身的亲身经历去讲解如下,其余的实现方式应该也是差很少的,有兴趣的话是能够本身实现一些的。
Socket.IO
想实现双向通讯,固然WebSocket是必不可少的技术了,不过Socket.IO
不只仅是WebSocket的封装,在不支持WebSocket的环境中,Socket.IO
还有多种轮询解决方案,确保它可以正常运行。
既然用到了Socket.IO
,那咱们就要扒一扒有关于它的介绍,基本使用等等内容,先在这里贴一个官方文档。由于官方文档为全英,这里笔者找到了一个中文文档,建议二者对比着看,有能力的固然仍是看全英的比较好,内容更加准确。
Socket.Io
主要由两个部分组成:
socket.io
模块,集成到Node.js
的http
模块的服务器
socket.io-client
,在浏览器中运行的客户端 Socket.Io
支持多种传输机制,例如WebSocket、Adobe Flash Sockets、XHR轮询、JsonP轮询
,它们被隔离在统一的接口之下,这意味着任何浏览器均可以做为客户端。
标准的WebSocket服务器并不能和Socket.Io
客户端进行直接通讯,须要注意这一点。
Socket.io
是一个WebSocket库,包括了客户端的js
和服务器端的nodejs
,它的目标是构建能够在不一样浏览器和移动设备上使用的实时应用。它会自动根据浏览器从WebSocket
、AJAX
长轮询、Iframe
流等等各类方式中选择最佳的方式来实现网络实时应用,很是方便和人性化,并且支持的浏览器最低达IE5.5。
如今Node.js的框架很是的多,譬如:Express、ThinkJS、Koa、Egg.js等,每个框架有可能进一步对Socket.io
进行了封装,好比笔者使用过的Egg.js框架就提供了 egg-socket.io
插件,使用这些插件就要遵循框架的一些约束,因此对于框架中的使用,仍是须要读者根据文档要求使用,由于这个因素,因此读者只在这里介绍在不使用任何框架的状况下的使用。
安装
$ npm install socket.io
复制代码
使用 Node http 服务器
先直接上代码(最基础),以后会根据官方文档讲解其余内容。
// index.html
<script src="./node_modules/socket.io-client/dist/socket.io.js"></script>
<script>
let socket = io('http://localhost');
socket.on('news', (data) => { //监听'news'事件,有结果后输出
console.log(data);
socket.emit('my other event', { //触发'my other event'事件
my: 'data'
})
})
</script>
// app.js
let app = require('http').createServer(handler);//使用Node建立一个Http服务
let io = require('socket.io')(app); //此处为绑定上面建立的服务器
let fs = require('fs');
app.listen(80);
var handler = (req, res) => {
fs.readFile(__dirname + './index.html', (err, data) => {
if (err) {
res.writeHead(500);
return res.end('Error loading index.html');
}
res.writeHead(200);
res.end(data);
})
}
io.on('connection', (socket) => {
socket.emit('news', { //触发'news'事件
hello: 'world'
});
socket.on('my other event', (data) => { //监听'my other event'事件,有结果后输出
console.log(data);
})
})
复制代码
上面代码完成后,运行 node app.js
,以后打开 index.html
,以后再打开浏览器的控制台,会发现浏览器的控制台上先打印出{hello: "world"}
,以后编辑器的控制台上打印出{my: "data"}
,注意两个是有前后顺序的,这个看代码就明白了,很少说。
emit
和 on
是最重要的两个api,分别对应 发送 和 监听 事件。
socket.emit(eventName[, ...args])
:发射(触发)一个事件
socket.on(eventName, callback)
:监听一个emit
发射的事件
咱们能够很是自由的在服务端定义并发送一个事件emit
,而后在客户端监听 on
,反过来也同样。 发送的内容格式也很是自由,既能够是基本数据类型 Number,String,Boolean
等,也能够是Object,Array
类型,甚至还能够是函数。而用回调函数的方式则能够进行更便携的交互。
1.2 部分的示例代码就是这两个api的使用,这里就很少说了。
broadcast
默认是向全部的socket链接进行广播,可是不包括发送者自身。
注意:socket链接要确保是同一个命名空间下的
代码解释:
io.on('connection', (socket) => {
//发送给除本身之外的其余客户端
socket.broadcast.emit('news', {
hello: 'world'
})
})
复制代码
此时,要想查看效果,能够在建立一个HTML页面,代码同样便可,以后在浏览器上同时打开两个页面,刷新一个页面时(刷新一次页面就至关于触发一次事件),本页面控制台没有输出任何内容,另外一个页面的控制台则会输出内容(能够建立更多页面查看效果)。
若是想要自身也能够收到消息,此时能够
io.on('connection', (socket) => {
//发送给本身
socket.emit('news', {
hello: 'world'
})
})
复制代码
所谓的命名空间,就是指在不一样的域当中发消息只能给当前的域的socket收到。
做用:能够最大限度地减小资源(TCP链接)的数量,,并为应用提供频道划分功能。(这样多个应用模块能够共享单个TCP链接)
若是想隔离做用域,或者划分业务模块,这时候就可使用命名空间,命名空间至关于创建新的频道,使你能够在一个socket.io服务上隔离不一样的链接,时间和中间件。
默认的命名空间是/
,Socket.IO 客户端默认链接到这个命名空间,服务端默认监听的也是这个命名空间。
自定义命名空间
重要提示:命名空间是 Socket.IO 协议的一个实现细节, 与底层传输的实际 URL 无关。底层传输的实际 URL 默认是/socket.io/…
。
使用命名空间的方式一:直接在连接后面加子域名,这种方式其实仍是用同一个socket服务进程---软隔离
服务端代码:
io
.of('my-nsp')
.on('connection', (socket) => {
// 发送给除本身之外的其余客户端
socket.broadcast.emit('news', {
hello: 'world'
})
//发送给本身
socket.emit('news', {
hello: 'world'
})
})
复制代码
客户端须要修改的代码:
let socket = io('http://localhost:3000/my-nsp');
复制代码
使用命名空间的方式二:path
参数,这种方式就是真正的从新开启了一个socket服务。
这里说一下,namespace
、room
和socket
的关系
socket
会属于某一个 room
,若是没有指定,那么会有一个默认的room
。这个room
又会属于某个namespace
,若是没有指定,那么就是默认的/
。(一个命名空间下能够有多个room
)
客户端链接时指定本身属于哪个 namespace
,服务端看到namespace
就会把这个socket
加入到指定的namespace
中,若是客户端没有指定具体的room
,则服务端会放入默认的room
,或者服务端经过代码socket.join(bar)
放入bar
的room
中。
默认状况下,每个id
便自成一个房间,房间名是 socket.id
(指定命名空间以后,前面会带上命名空间,socket会自动加入到以此ID来标识的房间);自定义房间以后,原先的默认控件仍然存在;房间为一个对象,包含当前进入房间的sockets以及长度。
代码示例:
io
.on('connection', (socket) => {
//在服务端将一个socket加入到一个房间中
socket.join('manannan', () => {
console.log(socket.rooms);
});
//进入到该房间中,以后的事件发布仅仅在这个房间
io.to('manannan').emit('news', {
hello: 'world'
})
//离开房间
socket.leave('mananan')
})
复制代码
以上内容就是基本的使用,然而在实际项目中,确定会比这些更加复杂,这里就不一一赘述,当咱们用到咱们以前没有用过的东西时,必定要善于查看官方文档以及百度。
因此关于客户端API和服务端API等更多的内容,须要时查看官方文档就能够了。