2024年Web前端最新你不知道的 WebSocket_ws echo,前端开发培训学什么

算法刷题

大厂面试还是很注重算法题的,尤其是字节跳动,算法是问的比较多的,关于算法,推荐《LeetCode》和《算法的乐趣》,这两本我也有电子版,字节跳动、阿里、美团等大厂面试题(含答案+解析)、学习笔记、Xmind思维导图均可以分享给大家学习。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

写在最后

最后,对所以做Java的朋友提几点建议,也是我的个人心得:

  1. 疯狂编程

  2. 学习效果可视化

  3. 写博客

  4. 阅读优秀代码

  5. 心态调整


当然客户端接收到服务端返回的消息之后,会判断返回的数据类型,如果是 Blob 类型的话,会调用 Blob 对象的 text() 方法,获取 Blob 对象中保存的 UTF-8 格式的内容,然后把对应的文本内容保存到 **接收的数据** 对应的 textarea 文本框中。


**数据接收代码**



// const socket = new WebSocket(“ws://echo.websocket.org”);
// const receivedMsgContainer = document.querySelector(“#receivedMessage”);
socket.addEventListener(“message”, async function (event) {
  console.log("Message from server ", event.data);
  const receivedData = event.data;
  if (receivedData instanceof Blob) {
    receivedMsgContainer.value = await receivedData.text();
  } else {
    receivedMsgContainer.value = receivedData;
  }
 });


同样,我们使用 Chrome 浏览器的开发者工具来看一下相应的过程:


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIza3d5dzhqTmgyeEYyb1FrdDJzN1NxWHNmczdlZnRPbm53RmptbmlhNDhubDZ3TGJCNFBSQ0dOUS82NDA?x-oss-process=image/format,png)


通过上图我们可以很明显地看到,当使用发送 Blob 对象时,Data 栏位的信息显示的是 **Binary Message**,而对于发送普通文本来说,Data 栏位的信息是直接显示发送的文本消息。


以上示例对应的完整代码如下所示:



                  WebSocket 发送二进制数据示例                

阿宝哥:WebSocket 发送二进制数据示例

    
      
        

待发送的数据:发送

               
      
        

接收的数据:

               
    

// 监听连接成功事件
      socket.addEventListener(“open”, function (event) {
        console.log(“连接成功,可以开始通讯”);
      });

// 监听消息
      socket.addEventListener(“message”, async function (event) {
        console.log("Message from server ", event.data);
        const receivedData = event.data;
        if (receivedData instanceof Blob) {
          receivedMsgContainer.value = await receivedData.text();
        } else {
          receivedMsgContainer.value = receivedData;
        }
      });

function send() {
        const message = sendMsgContainer.value;
        if (socket.readyState !== WebSocket.OPEN) {
          console.log(“连接未建立,还不能发送消息”);
          return;
        }
        const blob = new Blob([message], { type: “text/plain” });
        if (message) socket.send(blob);
        console.log(未发送至服务器的字节数:${socket.bufferedAmount});
      }
    
  


可能有一些小伙伴了解完 WebSocket API 之后,觉得还不够过瘾。下面阿宝哥将带大家来实现一个支持发送普通文本的 WebSocket 服务器。


#### 三、手写 WebSocket 服务器


在介绍如何手写 WebSocket 服务器前,我们需要了解一下 WebSocket 连接的生命周期。


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIzOWtlOENPaHRROVRTcUhya202VVVtaWJyTTFLQXh3b043aWFEREZVVGRmSnlyanR6QWpQdUtFZVEvNjQw?x-oss-process=image/format,png)


从上图可知,在使用 WebSocket 实现全双工通信之前,客户端与服务器之间需要先进行握手(Handshake),在完成握手之后才能开始进行数据的双向通信。


握手是在通信电路创建之后,信息传输开始之前。**握手用于达成参数,如信息传输率,字母表,奇偶校验,中断过程,和其他协议特性。**  握手有助于不同结构的系统或设备在通信信道中连接,而不需要人为设置参数。


既然握手是 WebSocket 连接生命周期的第一个环节,接下来我们就先来分析 WebSocket 的握手协议。


3.1 握手协议


WebSocket 协议属于应用层协议,它依赖于传输层的 TCP 协议。WebSocket 通过 HTTP/1.1 协议的 **101** 状态码进行握手。为了创建 WebSocket 连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为 “握手”(Handshaking)。


利用 HTTP 完成握手有几个好处。首先,让 WebSocket 与现有 HTTP 基础设施兼容:使得 WebSocket 服务器可以运行在 80 和 443 端口上,这通常是对客户端唯一开放的端口。其次,让我们可以重用并扩展 HTTP 的 Upgrade 流,为其添加自定义的 WebSocket 首部,以完成协商。


下面我们以前面已经演示过的发送普通文本的例子为例,来具体分析一下握手过程。


3.1.1 客户端请求



GET ws://echo.websocket.org/ HTTP/1.1
Host: echo.websocket.org
Origin: file://
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: Zx8rNEkBE4xnwifpuh8DHQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits



> 
> 备注:已忽略部分 HTTP 请求头
> 
> 
> 


**字段说明**


* Connection 必须设置 Upgrade,表示客户端希望连接升级。
* Upgrade 字段必须设置 websocket,表示希望升级到 WebSocket 协议。
* Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均应当弃用。
* Sec-WebSocket-Key 是随机的字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一个特殊字符串 **“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”**,然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为 “Sec-WebSocket-Accept” 头的值,返回给客户端。如此操作,可以尽量避免普通 HTTP 请求被误认为 WebSocket 协议。
* Sec-WebSocket-Extensions 用于协商本次连接要使用的 WebSocket 扩展:客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一个或多个扩展。
* Origin 字段是可选的,通常用来表示在浏览器中发起此 WebSocket 连接所在的页面,类似于 Referer。但是,与 Referer 不同的是,Origin 只包含了协议和主机名称。


3.1.2 服务端响应



HTTP/1.1 101 Web Socket Protocol Handshake ①
Connection: Upgrade ②
Upgrade: websocket ③
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④



> 
> 备注:已忽略部分 HTTP 响应头
> 
> 
> 


* ① 101 响应码确认升级到 WebSocket 协议。
* ② 设置 Connection 头的值为 "Upgrade" 来指示这是一个升级请求。HTTP 协议提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议。
* ③ Upgrade 头指定一项或多项协议名,按优先级排序,以逗号分隔。这里表示升级为 WebSocket 协议。
* ④  签名的键值验证协议支持。


介绍完 WebSocket 的握手协议,接下来阿宝哥将使用 Node.js 来开发我们的 WebSocket 服务器。


3.2 实现握手功能


要开发一个 WebSocket 服务器,首先我们需要先实现握手功能,这里阿宝哥使用 Node.js 内置的 **http** 模块来创建一个 HTTP 服务器,具体代码如下所示:



const http = require(“http”);

const port = 8888;
const { generateAcceptValue } = require(“./util”);

const server = http.createServer((req, res) => {
  res.writeHead(200, { “Content-Type”: “text/plain; charset=utf-8” });
  res.end(“大家好,我是阿宝哥。感谢你阅读“你不知道的WebSocket””);
});

server.on(“upgrade”, function (req, socket) {
  if (req.headers[“upgrade”] !== “websocket”) {
    socket.end(“HTTP/1.1 400 Bad Request”);
    return;
  }
  // 读取客户端提供的Sec-WebSocket-Key
  const secWsKey = req.headers[“sec-websocket-key”];
  // 使用SHA-1算法生成Sec-WebSocket-Accept
  const hash = generateAcceptValue(secWsKey);
  // 设置HTTP响应头
  const responseHeaders = [
    “HTTP/1.1 101 Web Socket Protocol Handshake”,
    “Upgrade: WebSocket”,
    “Connection: Upgrade”,
    Sec-WebSocket-Accept: ${hash},
  ];
  // 返回握手请求的响应信息
  socket.write(responseHeaders.join(“\r\n”) + “\r\n\r\n”);
});

server.listen(port, () =>
  console.log(Server running at http://localhost:${port})
);


在以上代码中,我们首先引入了 **http** 模块,然后通过调用该模块的 `createServer()` 方法创建一个 HTTP 服务器,接着我们监听 `upgrade` 事件,每次服务器响应升级请求时就会触发该事件。由于我们的服务器只支持升级到 WebSocket 协议,所以如果客户端请求升级的协议非 WebSocket 协议,我们将会返回 “400 Bad Request”。


当服务器接收到升级为 WebSocket 的握手请求时,会先从请求头中获取 **“Sec-WebSocket-Key”** 的值,然后把该值加上一个特殊字符串 **“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”**,然后计算 SHA-1 摘要,之后进行 Base64 编码,将结果做为 **“Sec-WebSocket-Accept”** 头的值,返回给客户端。


上述的过程看起来好像有点繁琐,其实利用 Node.js 内置的 **crypto** 模块,几行代码就可以搞定了:



// util.js
const crypto = require(“crypto”);
const MAGIC_KEY = “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”;

function generateAcceptValue(secWsKey) {
  return crypto
    .createHash(“sha1”)
    .update(secWsKey + MAGIC_KEY, “utf8”)
    .digest(“base64”);
}


开发完握手功能之后,我们可以使用前面的示例来测试一下该功能。待服务器启动之后,我们只要对 “发送普通文本” 示例,做简单地调整,即把先前的 URL 地址替换成 `ws://localhost:8888`,就可以进行功能验证。


感兴趣的小伙们可以试试看,以下是阿宝哥本地运行后的结果:


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIzUzNFaWFZNFplWU9iNGFGUDVVTEVpY011dlQ4endQWmlhRnlHMFlKajI2aGRMUVJKY3VvS0U0QmNRLzY0MA?x-oss-process=image/format,png)


从上图可知,我们实现的握手功能已经可以正常工作了。那么握手有没有可能失败呢?答案是肯定的。比如网络问题、服务器异常或 **Sec-WebSocket-Accept** 的值不正确。


下面阿宝哥修改一下 **“Sec-WebSocket-Accept”** 生成规则,比如修改 **MAGIC\_KEY** 的值,然后重新验证一下握手功能。此时,浏览器的控制台会输出以下异常信息:



WebSocket connection to ‘ws://localhost:8888/’ failed: Error during WebSocket handshake: Incorrect ‘Sec-WebSocket-Accept’ header value


如果你的 WebSocket 服务器要支持子协议的话,你可以参考以下代码进行子协议的处理,阿宝哥就不继续展开介绍了。



// 从请求头中读取子协议
const protocol = req.headers[“sec-websocket-protocol”];
// 如果包含子协议,则解析子协议
const protocols = !protocol ? [] : protocol.split(“,”).map((s) => s.trim());

// 简单起见,我们仅判断是否含有JSON子协议
if (protocols.includes(“json”)) {
  responseHeaders.push(Sec-WebSocket-Protocol: json);
}


好的,WebSocket 握手协议相关的内容基本已经介绍完了。下一步我们来介绍开发消息通信功能需要了解的一些基础知识。


3.3 消息通信基础


在 WebSocket 协议中,数据是通过一系列数据帧来进行传输的。为了避免由于网络中介(例如一些拦截代理)或者一些安全问题,客户端必须在它发送到服务器的所有帧中添加掩码。服务端收到没有添加掩码的数据帧以后,必须立即关闭连接。


3.3.1 数据帧格式


要实现消息通信,我们就必须了解 WebSocket 数据帧的格式:



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 …                |
±--------------------------------------------------------------+


可能有一些小伙伴看到上面的内容之后,就开始有点 “懵逼” 了。下面我们来结合实际的数据帧来进一步分析一下:


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIzaWFCS0w3MHdpYUR0cnlWQnpHQW9pYmdJaG94cWpQNzdWaFpMM3ZFVGJNbUdWZ3JKVDJ5SHJrTHN3LzY0MA?x-oss-process=image/format,png)


在上图中,阿宝哥简单分析了 “发送普通文本” 示例对应的数据帧格式。这里我们来进一步介绍一下 Payload length,因为在后面开发数据解析功能的时候,需要用到该知识点。


Payload length 表示以字节为单位的 “有效负载数据” 长度。它有以下几种情形:


* 如果值为 0-125,那么就表示负载数据的长度。
* 如果是 126,那么接下来的 2 个字节解释为 16 位的无符号整形作为负载数据的长度。
* 如果是 127,那么接下来的 8 个字节解释为一个 64 位的无符号整形(最高位的 bit 必须为 0)作为负载数据的长度。


多字节长度量以网络字节顺序表示,有效负载长度是指 “扩展数据” + “应用数据” 的长度。“扩展数据” 的长度可能为 0,那么有效负载长度就是 “应用数据” 的长度。


另外,除非协商过扩展,否则 “扩展数据” 长度为 0 字节。在握手协议中,任何扩展都必须指定 “扩展数据” 的长度,这个长度如何进行计算,以及这个扩展如何使用。如果存在扩展,那么这个 “扩展数据” 包含在总的有效负载长度中。


3.3.2 掩码算法


掩码字段是一个由客户端随机选择的 32 位的值。掩码值必须是不可被预测的。因此,掩码必须来自强大的熵源(entropy),并且给定的掩码不能让服务器或者代理能够很容易的预测到后续帧。掩码的不可预测性对于预防恶意应用的作者在网上暴露相关的字节数据至关重要。


掩码不影响数据荷载的长度,对数据进行掩码操作和对数据进行反掩码操作所涉及的步骤是相同的。掩码、反掩码操作都采用如下算法:



j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j


* original-octet-i:为原始数据的第 i 字节。
* transformed-octet-i:为转换后的数据的第 i 字节。
* masking-key-octet-j:为 mask key 第 j 字节。


为了让小伙伴们能够更好的理解上面掩码的计算过程,我们来对示例中 **“我是阿宝哥”** 数据进行掩码操作。这里 **“我是阿宝哥”** 对应的 UTF-8 编码如下所示:



E6 88 91 E6 98 AF E9 98 BF E5 AE 9D E5 93 A5


而对应的 Masking-Key 为 `0x08f6efb1`,根据上面的算法,我们可以这样进行掩码运算:



let uint8 = new Uint8Array([0xE6, 0x88, 0x91, 0xE6, 0x98, 0xAF, 0xE9, 0x98, 
  0xBF, 0xE5, 0xAE, 0x9D, 0xE5, 0x93, 0xA5]);
let maskingKey = new Uint8Array([0x08, 0xf6, 0xef, 0xb1]);
let maskedUint8 = new Uint8Array(uint8.length);

for (let i = 0, j = 0; i < uint8.length; i++, j = i % 4) {
  maskedUint8[i] = uint8[i] ^ maskingKey[j];
}

console.log(Array.from(maskedUint8).map(num=>Number(num).toString(16)).join(’ '));


以上代码成功运行后,控制台会输出以下结果:



ee 7e 7e 57 90 59 6 29 b7 13 41 2c ed 65 4a


上述结果与 WireShark 中的 Masked payload 对应的值是一致的,具体如下图所示:


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIzNzB3VzVoVE5OSDlkRnQzd25kTDJtT1hSMmp0cXEwd3dpYlRWdE1mVFA3aG5BRWVaUzFaNE9pY2cvNjQw?x-oss-process=image/format,png)


在 WebSocket 协议中,数据掩码的作用是增强协议的安全性。但数据掩码并不是为了保护数据本身,因为算法本身是公开的,运算也不复杂。那么为什么还要引入数据掩码呢?引入数据掩码是为了防止早期版本的协议中存在的代理缓存污染攻击等问题。


了解完 WebSocket 掩码算法和数据掩码的作用之后,我们再来介绍一下数据分片的概念。


3.3.3 数据分片


WebSocket 的每条消息可能被切分成多个数据帧。当 WebSocket 的接收方收到一个数据帧时,会根据 FIN 的值来判断,是否已经收到消息的最后一个数据帧。


利用 FIN 和 Opcode,我们就可以跨帧发送消息。操作码告诉了帧应该做什么。如果是 0x1,有效载荷就是文本。如果是 0x2,有效载荷就是二进制数据。但是,如果是 0x0,则该帧是一个延续帧。这意味着服务器应该将帧的有效负载连接到从该客户机接收到的最后一个帧。


为了让大家能够更好地理解上述的内容,我们来看一个来自 MDN 上的示例:



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!


在以上示例中,客户端向服务器发送了两条消息。第一个消息在单个帧中发送,而第二个消息跨三个帧发送。


其中第一个消息是一个完整的消息(FIN=1 且 opcode != 0x0),因此服务器可以根据需要进行处理或响应。而第二个消息是文本消息(opcode=0x1)且 FIN=0,表示消息还没发送完成,还有后续的数据帧。该消息的所有剩余部分都用延续帧(opcode=0x0)发送,消息的最终帧用 FIN=1 标记。


好的,简单介绍了数据分片的相关内容。接下来,我们来开始实现消息通信功能。


3.4 实现消息通信功能


阿宝哥把实现消息通信功能,分解为消息解析与消息响应两个子功能,下面我们分别来介绍如何实现这两个子功能。


3.4.1 消息解析


利用消息通信基础环节中介绍的相关知识,阿宝哥实现了一个 parseMessage 函数,用来解析客户端传过来的 WebSocket 数据帧。出于简单考虑,这里只处理文本帧,具体代码如下所示:



function parseMessage(buffer) {
  // 第一个字节,包含了FIN位,opcode, 掩码位
  const firstByte = buffer.readUInt8(0);
  // [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
  // 右移7位取首位,1位,表示是否是最后一帧数据
  const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
  console.log("isFIN: ", isFinalFrame);
  // 取出操作码,低四位
  /**
   * %x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片;
   * %x1:表示这是一个文本帧(text frame);
   * %x2:表示这是一个二进制帧(binary frame);
   * %x3-7:保留的操作代码,用于后续定义的非控制帧;
   * %x8:表示连接断开;
   * %x9:表示这是一个心跳请求(ping);
   * %xA:表示这是一个心跳响应(pong);
   * %xB-F:保留的操作代码,用于后续定义的控制帧。
   */
  const opcode = firstByte & 0x0f;
  if (opcode === 0x08) {
    // 连接关闭
    return;
  }
  if (opcode === 0x02) {
    // 二进制帧
    return;
  }
  if (opcode === 0x01) {
    // 目前只处理文本帧
    let offset = 1;
    const secondByte = buffer.readUInt8(offset);
    // MASK: 1位,表示是否使用了掩码,在发送给服务端的数据帧里必须使用掩码,而服务端返回时不需要掩码
    const useMask = Boolean((secondByte >>> 7) & 0x01);
    console.log("use MASK: ", useMask);
    const payloadLen = secondByte & 0x7f; // 低7位表示载荷字节长度
    offset += 1;
    // 四个字节的掩码
    let MASK = [];
    // 如果这个值在0-125之间,则后面的4个字节(32位)就应该被直接识别成掩码;
    if (payloadLen <= 0x7d) {
      // 载荷长度小于125
      MASK = buffer.slice(offset, 4 + offset);
      offset += 4;
      console.log("payload length: ", payloadLen);
    } else if (payloadLen === 0x7e) {
      // 如果这个值是126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小;
      console.log("payload length: ", buffer.readInt16BE(offset));
      // 长度是126, 则后面两个字节作为payload length,32位的掩码
      MASK = buffer.slice(offset + 2, offset + 2 + 4);
      offset += 6;
    } else {
      // 如果这个值是127,则后面的8个字节(64位)内容应该被识别成一个64位的二进制数表示数据内容大小
      MASK = buffer.slice(offset + 8, offset + 8 + 4);
      offset += 12;
    }
    // 开始读取后面的payload,与掩码计算,得到原来的字节内容
    const newBuffer = [];
    const dataBuffer = buffer.slice(offset);
    for (let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
      const nextBuf = dataBuffer[i];
      newBuffer.push(nextBuf ^ MASK[j]);
    }
    return Buffer.from(newBuffer).toString();
  }
  return “”;
}


创建完 parseMessage 函数,我们来更新一下之前创建的 WebSocket 服务器:



server.on(“upgrade”, function (req, socket) {
  socket.on(“data”, (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log(“Message from client:” + message);
    } else if (message === null) {
      console.log(“WebSocket connection closed by the client.”);
    }
  });
  if (req.headers[“upgrade”] !== “websocket”) {
    socket.end(“HTTP/1.1 400 Bad Request”);
    return;
  }
  // 省略已有代码
});


更新完成之后,我们重新启动服务器,然后继续使用 “发送普通文本” 的示例来测试消息解析功能。以下发送 “我是阿宝哥” 文本消息后,WebSocket 服务器输出的信息。



Server running at http://localhost:8888
isFIN:  true
use MASK:  true
payload length:  15
Message from client:我是阿宝哥


通过观察以上的输出信息,我们的 WebSocket 服务器已经可以成功解析客户端发送包含普通文本的数据帧,下一步我们来实现消息响应的功能。


3.4.2 消息响应


要把数据返回给客户端,我们的 WebSocket 服务器也得按照 WebSocket 数据帧的格式来封装数据。与前面介绍的 parseMessage 函数一样,阿宝哥也封装了一个 constructReply 函数用来封装返回的数据,该函数的具体代码如下:



function constructReply(data) {
  const json = JSON.stringify(data);
  const jsonByteLength = Buffer.byteLength(json);
  // 目前只支持小于65535字节的负载
  const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
  const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
  const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
  // 设置数据帧首字节,设置opcode为1,表示文本帧
  buffer.writeUInt8(0b10000001, 0);
  buffer.writeUInt8(payloadLength, 1);
  // 如果payloadLength为126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小
  let payloadOffset = 2;
  if (lengthByteCount > 0) {
    buffer.writeUInt16BE(jsonByteLength, 2);
    payloadOffset += lengthByteCount;
  }
  // 把JSON数据写入到Buffer缓冲区中
  buffer.write(json, payloadOffset);
  return buffer;
}


创建完 constructReply 函数,我们再来更新一下之前创建的 WebSocket 服务器:



server.on(“upgrade”, function (req, socket) {
  socket.on(“data”, (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log(“Message from client:” + message);
      // 新增以下👇代码
      socket.write(constructReply({ message }));
    } else if (message === null) {
      console.log(“WebSocket connection closed by the client.”);
    }
  });
});


到这里,我们的 WebSocket 服务器已经开发完成了,接下来我们来完整验证一下它的功能。


![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9tbWJpei5xcGljLmNuL21tYml6X2pwZy9qUW13VElGbDFWMVpNZzRpYUw4aWFuTllnZUJ1aks4QTIzNzVhSEU0NU9EU3czU1M3bDVSbWNSbWxES0ZtTGN1cnV4NjBMZWI5UTRia3ZrdXhRSUptbVdRLzY0MA?x-oss-process=image/format,png)


从图中可知,我们的开发的简易版 WebSocket 服务器已经可以正常处理普通文本消息了。最后我们来看一下完整的代码:


**custom-websocket-server.js**



const http = require(“http”);

const port = 8888;
const { generateAcceptValue, parseMessage, constructReply } = require(“./util”);

const server = http.createServer((req, res) => {
  res.writeHead(200, { “Content-Type”: “text/plain; charset=utf-8” });
  res.end(“大家好,我是阿宝哥。感谢你阅读“你不知道的WebSocket””);
});

server.on(“upgrade”, function (req, socket) {
  socket.on(“data”, (buffer) => {
    const message = parseMessage(buffer);
    if (message) {
      console.log(“Message from client:” + message);
      socket.write(constructReply({ message }));
    } else if (message === null) {
      console.log(“WebSocket connection closed by the client.”);
    }
  });
  if (req.headers[“upgrade”] !== “websocket”) {
    socket.end(“HTTP/1.1 400 Bad Request”);
    return;
  }
  // 读取客户端提供的Sec-WebSocket-Key
  const secWsKey = req.headers[“sec-websocket-key”];
  // 使用SHA-1算法生成Sec-WebSocket-Accept
  const hash = generateAcceptValue(secWsKey);
  // 设置HTTP响应头
  const responseHeaders = [
    “HTTP/1.1 101 Web Socket Protocol Handshake”,
    “Upgrade: WebSocket”,
    “Connection: Upgrade”,
    Sec-WebSocket-Accept: ${hash},
  ];
  // 返回握手请求的响应信息
  socket.write(responseHeaders.join(“\r\n”) + “\r\n\r\n”);
});

server.listen(port, () =>
  console.log(Server running at http://localhost:${port})
);


**util.js**



const crypto = require(“crypto”);

const MAGIC_KEY = “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”;

function generateAcceptValue(secWsKey) {
  return crypto
    .createHash(“sha1”)
    .update(secWsKey + MAGIC_KEY, “utf8”)
    .digest(“base64”);
}

function parseMessage(buffer) {
  // 第一个字节,包含了FIN位,opcode, 掩码位
  const firstByte = buffer.readUInt8(0);
  // [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
  // 右移7位取首位,1位,表示是否是最后一帧数据
  const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
  console.log("isFIN: ", isFinalFrame);
  // 取出操作码,低四位
  /**
   * %x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片;
   * %x1:表示这是一个文本帧(text frame);
   * %x2:表示这是一个二进制帧(binary frame);
   * %x3-7:保留的操作代码,用于后续定义的非控制帧;
   * %x8:表示连接断开;
   * %x9:表示这是一个心跳请求(ping);
   * %xA:表示这是一个心跳响应(pong);
   * %xB-F:保留的操作代码,用于后续定义的控制帧。
   */
  const opcode = firstByte & 0x0f;
  if (opcode === 0x08) {
    // 连接关闭
    return;
  }
  if (opcode === 0x02) {
    // 二进制帧
    return;
  }
  if (opcode === 0x01) {
    // 目前只处理文本帧
    let offset = 1;
    const secondByte = buffer.readUInt8(offset);
    // MASK: 1位,表示是否使用了掩码,在发送给服务端的数据帧里必须使用掩码,而服务端返回时不需要掩码
    const useMask = Boolean((secondByte >>> 7) & 0x01);
    console.log("use MASK: ", useMask);
    const payloadLen = secondByte & 0x7f; // 低7位表示载荷字节长度
    offset += 1;
    // 四个字节的掩码
    let MASK = [];
    // 如果这个值在0-125之间,则后面的4个字节(32位)就应该被直接识别成掩码;
    if (payloadLen <= 0x7d) {
      // 载荷长度小于125
      MASK = buffer.slice(offset, 4 + offset);
      offset += 4;
      console.log("payload length: ", payloadLen);
    } else if (payloadLen === 0x7e) {
      // 如果这个值是126,则后面两个字节(16位)内容应该,被识别成一个16位的二进制数表示数据内容大小;
      console.log("payload length: ", buffer.readInt16BE(offset));
      // 长度是126, 则后面两个字节作为payload length,32位的掩码
      MASK = buffer.slice(offset + 2, offset + 2 + 4);
      offset += 6;

算法刷题

大厂面试还是很注重算法题的,尤其是字节跳动,算法是问的比较多的,关于算法,推荐《LeetCode》和《算法的乐趣》,这两本我也有电子版,字节跳动、阿里、美团等大厂面试题(含答案+解析)、学习笔记、Xmind思维导图均可以分享给大家学习。

开源分享:【大厂前端面试题解析+核心总结学习笔记+真实项目实战+最新讲解视频】

写在最后

最后,对所以做Java的朋友提几点建议,也是我的个人心得:

  1. 疯狂编程

  2. 学习效果可视化

  3. 写博客

  4. 阅读优秀代码

  5. 心态调整

  • 10
    点赞
  • 29
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值