1. 知识模块一
1.1. websocket与http对比
1.1.1. http协议
主要关注:客户端->服务器(获取资源)
特点:
- 无状态协议,每个请求都是独立的,请求应答模式,服务端无法主动给客户端推送消息,半双工(同一刻数据传输只能是单项的,还有单工和全双工)。
- http受浏览器同源策略影响,需要保证协议、主机名、端口号一致,否则会出现跨域问题(为了安全)。
- 适合获取资源、下载文件,但不适合实时性要求高的需求。
1.1.2.websocket协议
双向通信(全双工协议),每次不需要重新建立连接,可以一致相互通信,适合长通信。
1.1.3.关系
都是通信协议,websocket是建立在http基础之上的,第一次websocket握手是基于http的,底层传输都依靠TCP。
1.2.不用websocket以前是如何实现双向通信的
Comet,这个技术主要是为了实现服务端可以向客户端推送数据,为了解决实时性比较高的情况。
import express from "express";
import cors from "cors";
const app = express();
// 解决跨域问题
app.use(cors());
// 轮询,短轮询()
// 接口
app.get('/clock',function(req,res){
res.send(new Date().toLocaleDateString());
})
// 通过node命令启动时,修改后并不会重新执行
// 通过nodeman启动可以在改变后自动执行
app.listen(3000,function(){
console.log('server start 3000');
})
-
轮询
clock-1.html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="clock"></div> <script> setInterval(() => { // 创建请求 const xhr = new XMLHttpRequest(); // 访问请求,异步 xhr.open('GET','http://localhost:3000/clock',true); xhr.onload = function () { console.log(xhr.responseText); clock.innerHTML = xhr.responseText; } // 发送请求 xhr.send(); }, 1000)//每隔一秒 </script> </body> </html>
存在问题:
- 竞速问题:无法保证请求的先后顺序,可能会出现多个请求返回的时候同时修改资源,会导致一些不可预测的问题。
- 频繁的网络请求,请求数目过多,会导致网络带宽的消耗,增加服务端和客户端的消耗。
- http在发送请求的时候,会增加http报文(鉴权、内容类型),增加额外的数据消耗
- 实时性比较低,如果服务端1s内变了三次,而客户端每隔1s发送一次请求。
优点:
- 容易实现,适合轻量级、低并发。
-
长轮询
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="clock"></div> <script> // 客户端发送请求后,服务端相应后,我就发下一个请求 function longPolling() { const xhr = new XMLHttpRequest(); xhr.open('GET', 'http://localhost:3000/clock', true); xhr.onload = function () { console.log(xhr.responseText); clock.innerHTML = xhr.responseText; longPolling(); } xhr.send(); } longPolling() </script> </body> </html>
- 想解决短轮询的问题,希望实时性更强,但是实时性强了的同时,也会造成频繁的网络请求(实时性强了,但是要求服务端的并发能力必须强)。
- 连接堆叠问题,这些链接都在服务端中保持打开,会占用服务端资源。
-
iframe流(以前用的挺多的)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> <div id="clock"></div>\ <!-- 目前谷歌的document.domain跨域方法已经噶了 --> <script> document.domain = 'localhost' </script> <iframe src="http://localhost:3000/clock" frameborder="0"></iframe> </body> </html>
import express from "express"; import cors from "cors"; const app = express(); // 解决跨域问题 app.use(cors()); // 接口 app.get('/clock', function (req, res) { // res.end或者res.send请求结束后会断开 // res.write方法不会结束本次的响应 setInterval(() => { res.write(` <script> document.domain = 'localhost' parent.document.getElementById('clock').innerHTML = "${new Date().toDateString()}" </script> `); }) }) // 通过node命令启动时,修改后并不会重新执行 // 通过nodeman启动可以在改变后自动执行 app.listen(3000, function () { console.log('server start 3000'); })
创建之后一直保持链接,会出现跨域问题
可以保证实时性,而且不用客户频繁发送请求 。
缺点:单向通信。
-
sse EventSource(写法已经比较接近websocket了)
html提供的,单向通信,客户端可以监控服务端推送的事件,只能推送文本类型的数据,适合小数据,需要做额外的处理。
缺点:单向,客户端无法给服务端传递数据。
-
websocket
优势:
- 双向绑定
- 持久链接,可以一直握手
- 发送的消息增加帧非常小
- 支持多种数据格式
- 天生支持跨域
2. 知识模块二
2.1.基础内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 客户端 -->
<script>
// 与服务端提供的一个websocket服务相关联
const ws = new WebSocket('ws://localhost:3000');
// 给服务端发送消息
ws.onopen = function(){
console.log('Connection opend');
ws.send('hello server');// 给服务端发送消息
}
// 监控服务端的数据
ws.onmessage = function(e){
console.log('服务端相应的数据:' + e.data);
}
// http各种header的使用
// websocket怎么实现握手、数据长什么样的、怎么通信的
// 协议的表示方式
// 请求行:GET ws://localhost:3000 HTTP/1.1
// Connection:Upgrade
// Sec-Websocket-Key:用于保证是安全的websocket链接,防止恶意连接,用于握手
// Sec-Websoeckt-Version:版本
// 握手成功后服务端会返回一个Sec-Websocket-Accept,是根据key算出来的
// Upgrade:websocket,表示升级成什么协议
</script>
</body>
</html>
import express, { response } from 'express';
import http from 'http';
import { WebSocketServer } from 'ws';
const app = express();
const server = http.createServer(app); // http服务
const wss = new WebSocketServer({server});
// 监控连接成功
wss.on('connection',(ws)=>{
console.log('Connection opend');
// 给客户端发送消息
ws.send('hello client');
// 第一个参数可以为
// close、error、message、open、ping、pong、upgrade、unexpected-response
ws.on('message', function(message){
console.log("客户端数据:"+message);
})
})
// 监控端口
server.listen(3000)
2.2. key和accept的换算
// 可以使用wireshark抓包软件,分析协议信息
// key-> P2P2F9kEf/wg18RKzXM8eA== ,握手的时候创建一个随机的key
// 服务端通过key加上
// const number = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
// 然后经历sha1算法计算生成accept,
// accept-> adAEOXRx506qcgqahbjvIHPI1Sk= ,服务端要相应一个值
import crypto from 'crypto'
const number = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const WebsocketKey = 'P2P2F9kEf/wg18RKzXM8eA=='; // key是随机值
const WebsocketAccept = crtpto
.createHash('sha1')
.update(websocketKey + number)
.digest('base64');
2.3.具体握手过程
2.3.1.三次握手:
- 第一次握手:建立连接,客户端A发送SYN=1、随机产生Seq=client_isn的数据包到服务器B,等待服务器确认。
- 第二次握手:服务器B收到请求后确认联机(可以接受数据),发起第二次握手请求,ACK=(A的Seq+1)、SYN=1,随机产生Seq=client_isn的数据包到A。
- 第三次握手:A收到后检查ACK是否正确,若正确,A会在发送确认包ACK=服务器B的Seq+1、ACK=1,服务器B收到后确认Seq值与ACK值,若正确,则建立连接。
通俗点,客户端跟服务端说我们结婚吧,服务端给客户端说好的我们结婚吧,然后服务端和客户端结婚了。
2.3.2.websocket数据帧格式:
-
FIN:1个比特,如果是1,表示这是消息(message)的最后一个分片(fragment),如果是0,表示不是消息(message)的最后一个分片(fragment)。
-
RSV1、RSV2、RSV1:各占1个比特,一般情况全为0.当客户端、服务端协商采用websocket扩展时,这三个标志位可以非0,且值的含义由拓展进行定义。如果出现非零的值,且并没有采用websocket拓展,连接出错。
-
Opcode:4个比特。操作代码,决定了应该如何解析后续的数据载荷(data payload)。如果操作代码是不认识的,那么接收端应该断开连接
- %x1:表示这是一个文本帧。
- %x2:表示这是一个二进制帧。
- %x2:表示这是一个二进制帧。
- %x3-7:保留的操作代码,用于后续定义的非控制帧。
- %x8:表示连接断开
- %x9:表示这是一个ping操作
- %xA:表示这是一个pong操作
- %xB-F:保留的操作代码,用于后续定义的控制帧
-
Mask:1个比特。表示是否要对数据载荷进行掩码操作
-
从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作,如果服务端接收到的数据没有进行掩码操作,服务端需要断开连接。
-
如果Mask是1,那么在 Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。
-
-
Payload length:表示数据载荷的长度,单位是字节,由7位/7+16位/7+64位
- Payload length=x为0~125:数据的长度为x字节。
- Payload length=x为126:后续2个字节代表一个16位的无符号整数,该无符号整数的值为数据的长度。
- Payload length=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字节
2.3.3.具体代码模拟
// 引入node内的tcp模块,可以接收原始的tcp消息
import net from 'net';
import crypto from 'crypto';
const server = net.createServer(function (socket) { //每个人都会产生一个socket
// 接收二进制信息
socket.once('data', function (data) {
// 将二进制信息转化为字符串
data = data.toString();
// 如果升级为websocket协议
// console.log(data);
// GET / HTTP/1.1
// Host: localhost:3000
// Connection: Upgrade
// Pragma: no-cache
// Cache-Control: no-cache
// User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) //AppleWebKit/537.36 (KHTML, like Gecko)
//Chrome/117.0.0.0 Safari/537.36
// Upgrade: websocket
// Origin: http://127.0.0.1:5500
// Sec-WebSocket-Version: 13
// Accept-Encoding: gzip, deflate, br
// Accept-Language: zh-CN,zh;q=0.9
// Sec-WebSocket-Key: 1tIB0I01z9xlRZt89EDUxw==
// Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
if (data.match(/Upgrade: websocket/)){
// 报文是以换行来分割的
let rows = data.split('\r\n');
// 解析出请求头
const headers = rows.slice(1,-2).reduce((memo,row)=>{
let [key,value] = row.split(': ')
// 改成小写
memo[key.toLowerCase()] = value;
return memo;
},{});
const number = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
let websocketKey = headers['sec-websocket-key'];
let websocketAccept = crypto.createHash('sha1').update(websocketKey + number).digest('base64');
// 相应报文
let response = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
`Sec-Websocket-Accept: ${websocketAccept}`,
'Connection: Upgrade',
'\r\n'
].join('\r\n');
// 表示websocket建立连接成功
socket.write(response);
// 继续解析 后续发来的websocket数据
socket.on('data', function(buffers) {
// 解析websocket的格式
// 一、客户端发消息过来,先判断消息是否结束了
// 第一个字节(1个字节是8个位,如何获取第一位是不是1)
// 位运算:
// 1、按位或,有一个为1即为1
// 0000 1111
// 1111 0000
//--------------
// 1111 1111
// 2、按位与,都是1才是1
// 0000 1111
// 1111 1111
// -------------
// 0000 1111
// 3、异或,相同为0不同为1
// 0000 0111
// 1000 0110
//--------------
// 1000 0001
const FIN = ((buffers[0] & 0b10000000) === 0b10000000); //表示完成了
console.log(FIN); //true
// 二、判断发送数据的格式
// 1表示的是文本,由于前四位不需要所以为0000 1111
const OPCOED = (buffers[0] & 0b00001111);
console.log(OPCOED); // 1
// 三、计算masked,由于第一位数已经使用完,这里开始使用第二位
const MASKED = ((buffers[1] & 0b10000000) === 0b10000000);
console.log(MASKED); //true
// 四、计算payload_len
const PAYLOAD_LEN = ((buffers[1] & 0b01111111));
console.log(PAYLOAD_LEN); // 12
// 五、获取掩码,掩码的长度是4个字节
const MASK_KEY = buffers.slice(2,6);
// 六、获取真正的数据内容,这个内容是被掩码过的,需要用掩码做异或操作(相同为0不同为1)
const PAYLOAD = buffers.slice(6);
for (let i = 0 ; i<PAYLOAD.length; i++){
// 如果数据有多个字节但是掩码是4个字节时
PAYLOAD[i] = PAYLOAD[i]^MASK_KEY[i%4];
}
console.log(PAYLOAD.toString()); // hello server
// 以上内容为客户端给服务端发送消息流程。
// 服务端如果想给客户端发送消息,按照一样的格式发送即可(服务端给客户端发送消息是不用加掩码的)
})
}
})
})
server.listen(3000, function() {
console.log('server start 3000');
})