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');})
  1. 轮询

    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发送一次请求。

    优点:

    • 容易实现,适合轻量级、低并发。
  2. 长轮询

    <!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>
    1. 想解决短轮询的问题,希望实时性更强,但是实时性强了的同时,也会造成频繁的网络请求(实时性强了,但是要求服务端的并发能力必须强)。
    2. 连接堆叠问题,这些链接都在服务端中保持打开,会占用服务端资源。
  3. 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>\<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(`document.domain = 'localhost'parent.document.getElementById('clock').innerHTML = "${new Date().toDateString()}"`);})})// 通过node命令启动时,修改后并不会重新执行// 通过nodeman启动可以在改变后自动执行app.listen(3000, function () {console.log('server start 3000');})

    创建之后一直保持链接,会出现跨域问题

    可以保证实时性,而且不用客户频繁发送请求 。

    缺点:单向通信。

  4. sse EventSource(写法已经比较接近websocket了)

    html提供的,单向通信,客户端可以监控服务端推送的事件,只能推送文本类型的数据,适合小数据,需要做额外的处理。

    缺点:单向,客户端无法给服务端传递数据。

  5. websocket

    优势:

    1. 双向绑定
    2. 持久链接,可以一直握手
    3. 发送的消息增加帧非常小
    4. 支持多种数据格式
    5. 天生支持跨域

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-responsews.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.三次握手:

  1. 第一次握手:建立连接,客户端A发送SYN=1、随机产生Seq=client_isn的数据包到服务器B,等待服务器确认。
  2. 第二次握手:服务器B收到请求后确认联机(可以接受数据),发起第二次握手请求,ACK=(A的Seq+1)、SYN=1,随机产生Seq=client_isn的数据包到A。
  3. 第三次握手: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_bitsif (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 0001const FIN = ((buffers[0] & 0b10000000) === 0b10000000); //表示完成了console.log(FIN); //true// 二、判断发送数据的格式// 1表示的是文本,由于前四位不需要所以为0000 1111const OPCOED = (buffers[0] & 0b00001111);console.log(OPCOED); // 1// 三、计算masked,由于第一位数已经使用完,这里开始使用第二位const MASKED = ((buffers[1] & 0b10000000) === 0b10000000);console.log(MASKED); //true// 四、计算payload_lenconst 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');})