WebScoket
1、HTTP
HTTP 是客户端/服务器模式中请求-响应所用的协议,在这种模式中,客户端(一般是 web 浏览器)向服务器提交 HTTP 请求,服务器响应返回请求的资源。 HTTP特点:
- HTTP 是半双工协议,也就是说,在同一时刻数据只能单向流动,客户端向服务器发送请求(单向的),然后服务器响应请求(单向的)。
- 服务器不能主动推送数据给浏览器,HTTP2.0 后支持服务端推送。服务端推送能把客户端所需要的资源伴随着 index.html 一起发送到客户端,省去了客户端重复请求的步骤。因为没有再次发起请求,建立连接等操作,所以静态资源通过服务端推送的方式可以极大地提升速度。
2、双向通信
HTTP 只是一个半双工协议。如果我们想实现服务器能实时地将更新的信息传送到客户端有什么方法呢?
2.1 Comet
基于 HTTP 长链接的“服务器推”技术,目前有三种实现方式:轮询(polling) 长轮询(long-polling)和iframe流(streaming)。
2.1.1 轮询
轮询是客户端和服务器之间会一直进行连接,每隔一段时间就询问一次。
// 服务端
app.get('/polling', function (req, res) {
res.send(new Date().toLocaleTimeString());
});
// 客户端
setInterval(() => {
axios.get('http://localhost:8888/polling').then(res => {
this.pollingRes = res.data
})
}, 1000);
缺点: 这种方式连接数会很多,一个接受,一个发送。而且每次发送请求都会有 HTTP 的 Header,会很耗流量,也会消耗 CPU 的利用率。
2.1.2 长轮询
长轮询是对轮询的改进版,客户端发送HTTP给服务器之后,看有没有新消息,如果没有新消息,就一直等待。当有新消息的时候,才会返回给客户端。在某种程度上减小了网络带宽和CPU利用率等问题。
// 服务端
app.get('/longPolling', function (req, res) {
setTimeout(() => {
res.send(new Date().toLocaleTimeString());
}, Math.random() * 10000);
});
// 客户端
startLongPolling(){
axios.get('http://localhost:8888/longPolling').then(res => {
this.longPollingRes = res.data
this.startLongPolling()
})
},
缺点:由于http数据包的头部数据量往往很大(通常有 400 多个字节),但是真正被服务器需要的数据却很少(有时只有 10 个字节左右),这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费。
2.1.3 iframe流
通过在HTML页面里嵌入一个隐藏的iframe,然后将这个iframe的src属性设为对一个长连接的请求,服务器端就能源源不断地往客户推送数据。
// 服务端
app.get('/iframe', function (req, res) {
setInterval(function () {
res.write(`
<script type="text/javascript">
parent.document.getElementById('iframeRes').innerHTML = "${new Date().toLocaleTimeString()}";
</script>
`);
}, 1000);
});
// 客户端
<iframe src="http://localhost:8888/iframe" frameborder="0" v-if="showIframe" v-show="false"></iframe>
<p>IframeRes:<span id="iframeRes"></span></p>
2.2 EventSource流
HTML5规范中提供了服务端事件EventSource,浏览器在实现了该规范的前提下创建一个EventSource连接后,便可收到服务端的发送的消息,这些消息需要遵循一定的格式,对于前端开发人员而言,只需在浏览器中侦听对应的事件即可。 SSE简单模型是:一个客户端去从服务器端订阅一条流,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以eventsource也叫作"server-sent-event"。
优点:
- EventSource流的实现方式对客户端开发人员而言非常简单,兼容性良好
- 对于服务端,它可以兼容老的浏览器,无需upgrade为其他协议,在简单的服务端推送的场景下可以满足需求
客户端
- 需要创建一个EventSource对象,并且传入一个服务端的接口URI作为参数
- 默认EventSource对象通过侦听message事件获取服务端传来的消息
- open事件则在http连接建立后触发
- error事件会在通信错误(连接中断、服务端返回数据失败)的情况下触发
- 同时EventSource规范允许服务端指定自定义事件,客户端侦听该事件即可
let _this = this
const eventSource = new EventSource('http://localhost:8888/eventSource');
eventSource.onopen = function(){
console.log('open');
}
eventSource.onmessage = function(e){
_this.EventSourceRes = e.data
}
eventSource.onerror = function(err){
console.log(err);
}
服务端
事件流的对应Content-Type为text/event-stream,而且其基于HTTP长连接。针对HTTP1.1规范默认采用长连接,针对HTTP1.0的服务器需要特殊设置。 event-source必须编码成utf-8的格式,消息的每个字段使用"\n"来做分割,并且需要下面4个规范定义好的字段:
- Event: 事件类型
- Data: 发送的数据
- ID: 每一条事件流的ID
- Retry: 告知浏览器在所有的连接丢失之后重新开启新的连接等待的时间,在自动重新连接的过程中,之前收到的最后一个事件流ID会被发送到服务端
普通模型
let sendCount = 1;
app.get('/eventSource',function(req,res){
res.header('Content-Type','text/event-stream',);
setInterval(() => {
res.write(`event:message\nid:${sendCount++}\ndata:${new Date().toTimeString()}\nretry:2000\n`);
}, 1000)
});
sse模型
const SseStream = require('ssestream');
let sendCount2 = 1;
app.get('/eventSourceV2',function(req,res){
const sseStream = new SseStream(req);
sseStream.pipe(res);
const pusher = setInterval(() => {
sseStream.write({
id: sendCount2++,
event: 'message',
retry: 20000, // 告诉客户端,如果断开连接后,20秒后再重试连接
data: new Date().toTimeString()
})
}, 1000)
res.on('close', () => {
console.log('sseStream close')
clearInterval(pusher);
sseStream.unpipe(res);
})
});
2.3 WebSocket
WebSockets_API 规范定义了一个 API 用以在网页浏览器和服务器建立一个 socket 连接。通俗地讲:在客户端和服务器保有一个持久的连接,两边可以在任意时间开始发送数据。HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术。属于应用层协议,它基于TCP传输协议,并复用HTTP的握手通道。
优点:
- 支持双向通信,实时性更强。
- 更好的二进制支持。
- 较少的控制开销。连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。
- 没有同源限制,客户端可以与任意服务器通信。
3、WebSocket
如何使用
// 客户端
const ws = new WebSocket('ws://localhost:8888');
ws.onopen = function () {
console.log('客户端连接成功');
ws.send('hello');
}
ws.onmessage = function (event) {
console.log('收到服务器的响应 ' + event.data);
this.websocketRes = event.data
}
// 服务端
const WebSocketServer = require('ws').Server;
const wsServer = new WebSocketServer({ port: 8888 });
wsServer.on('connection', function (socket) {
console.log('连接成功');
socket.on('message', function (message) {
console.log('接收到客户端消息:' + message);
socket.send('服务器回应:' + message);
});
});
协议升级
WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。
首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。 Request header中
| header | 解释 |
|---|---|
| Connection: Upgrade | 表示要升级协议 |
| Upgrade: websocket | 表示要升级到websocket协议 |
| Sec-WebSocket-Version | 表示websocket的版本 |
| Sec-WebSocket-Key | 与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。 |
服务端返回状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。
Response header中
- Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。 计算公式为: 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。通过SHA1计算出摘要,并转成base64字符串。
const crypto = require('crypto');
const number = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const webSocketKey = 'ISMipMOlqXyNY38+Rnw8Mg==';
let websocketAccept = require('crypto').createHash('sha1').update(webSocketKey + number).digest('base64');
console.log(websocketAccept); // gGa8SQbjS8E0gzBmXY49IYFfafA=
Sec-WebSocket-Key/Accept的作用
- 避免服务端收到非法的websocket连接
- 确保服务端理解websocket连接
- 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的
- Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)
消息格式
WebSocket客户端、服务端通信的最小单位是帧,由1个或多个帧组成一条完整的消息(message)。 发送端:将消息切割成多个帧,并发送给服务端。 接收端:接收消息帧,并将关联的帧重新组装成完整的消息。
数据帧格式
掩码算法
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。掩码、反掩码操作都采用如下算法:
- 对索引 i 模以4得到对应的掩码字节, 因为掩码一共就是四个字节
- 对原来的索引进行异或对应的掩码字节
- 异或就是两个数的二进制形式,按位对比,相同取0,不同取1
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i % 4];
}
}
| 数据 | 掩码 | 发送数据 | 解码数据 |
|---|---|---|---|
| 0011 | 1100 | 1111 | 0011 |
连接保持+心跳
WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。 但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。 这个时候,可以采用心跳来实现:
发送方->接收方:ping
接收方->发送方:pong
ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x9、0xA。
举例:WebSocket服务端向客户端发送ping,只需要如下代码(采用ws模块)
ws.ping('', false, true);
简单实现
const net = require('net');
const crypto = require('crypto');
const CODE = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
let server = net.createServer(function (socket) {
socket.once('data', function (data) {
data = data.toString();
if (data.match(/Upgrade: websocket/)) {
let rows = data.split('\r\n');//按分割符分开
rows = rows.slice(1, -2);//去掉请求行和尾部的二个分隔符
const headers = {};
rows.forEach(row => {
let [key, value] = row.split(': ');
headers[key] = value;
});
if (headers['Sec-WebSocket-Version'] == 13) {
let wsKey = headers['Sec-WebSocket-Key'];
let acceptKey = crypto.createHash('sha1').update(wsKey + CODE).digest('base64');
let response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
`Sec-WebSocket-Accept: ${acceptKey}`,
'Connection: Upgrade',
'\r\n'
].join('\r\n');
socket.write(response);
socket.on('data', function (buffers) {
let _fin = (buffers[0] & 0b10000000) === 0b10000000;//判断是否是结束位,第一个bit是不是1
let _opcode = buffers[0] & 0b00001111; //取一个字节的后四位,得到的一个是十进制数
let _masked = (buffers[1] & 0b10000000) === 0b10000000;//第一位是否是1
let _payloadLength = buffers[1] & 0b01111111;//取得负载数据的长度
let _mask = buffers.slice(2, 6);//掩码
let payload = buffers.slice(6);//负载数据
console.log('fin:' + _fin,'_opcode:'+ _opcode,'_masked:'+_masked, '_payloadLength:'+ _payloadLength)
console.log(_mask)
unmask(payload, _mask);//对数据进行解码处理
//向客户端响应数据
let response = Buffer.alloc(2 + payload.length);// 创建长度(2 + payload.length) 的 buffer
response[0] = _opcode | 0b10000000;//1表示发送结束
response[1] = payload.length;//负载的长度
payload.copy(response, 2);
socket.write(response);
});
}
}
});
function unmask(buffer, mask) {
const length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= mask[i % 4];
}
}
socket.on('end', function () {
console.log('end');
});
socket.on('close', function () {
console.log('close');
});
socket.on('error', function (error) {
console.log(error);
});
});
console.log('server at http://localhost:8888')
server.listen(8888);