websocket前后端实例 及 服务器基于tcp实现从http升级到websocket协议

本文代码:本文代码:

1.HTTP的架构模式
  1.1. HTTP的特点
2. 双向通信
  2.1 轮询
  2.2 长轮询
  2.3 iframe流
  2.4 EventSource流
  2.4.1 浏览器端
  2.4.2 服务端
3.websocket
  3.1 websocket 优势
  3.2 websocket实战
    3.2.1 服务端
    3.2.2 客户端
  3.3 如何建立连接
    3.3.1 客户端:申请协议升级
    3.3.2 服务端:响应协议升级
    3.3.3 Sec-WebSocket-Accept的计算
    3.3.4 Sec-WebSocket-Key/Accept的作用
  3.4 数据帧格式
    3.4.1 数据帧格式
    3.4.2 掩码算法
    3.4.3 服务器实战

1. HTTP的架构模式
Http是客户端/服务器模式中请求-响应所用的协议,在这种模式中,客户端(一般是web浏览器)向服务器提交HTTP请求,服务器响应请求的资源。
1.1. HTTP的特点

  • HTTP是半双工协议,也就是说,在同一时刻数据只能单向流动,客户端向服务器发送请求(单向的),然后服务器响应请求(单向的)。
  • 服务器不能主动推送数据给浏览器。

2. 双向通信
Comet是一种用于web的推送技术,能使服务器能实时地将更新的信息传送到客户端,而无须客户端发出请求,目前有三种实现方式:轮询(polling) 长轮询(long-polling)和iframe流(streaming)。
2.1 轮询

  • 轮询是客户端和服务器之间会一直进行连接,每隔一段时间就询问一次
  • 这种方式连接数会很多,一个接受,一个发送。而且每次发送请求都会有Http的Header,会很耗流量,也会消耗CPU的利用率
    在这里插入图片描述
**index.html**
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <div id="clock"></div>
    <script>
      let clock = document.querySelector("#clock");
      setInterval(function () {
        let xhr = new XMLHttpRequest();
        xhr.open("GET", "/clock", true);
        xhr.onreadystatechange = function () {
          if (xhr.readyState === 4 && xhr.status === 200) {
            clock.innerHTML = xhr.responseText;
          }
        };
        xhr.send();
      });
    </script>
  </body>
</html>
**app.js**
let express = require("express");
let app = express();
// http://localhost:8000/
app.use(express.static(__dirname));
app.get("/clock", function (req, res) {
  res.send(new Date().toLocaleString());
});
app.listen(8000);

2.2 长轮询

  • 长轮询是对轮询的改进版,客户端发送HTTP给服务器之后,看有没有新消息,如果没有新消息,就一直等待
  • 当有新消息的时候,才会返回给客户端。在某种程度上减小了网络带宽和CPU利用率等问题。
  • 由于http数据包的头部数据量往往很大(通常有400多个字节),但是真正被服务器需要的数据却很少(有时只有10个字节左右),这样的数据包在网络上周期性的传输,难免对网络带宽是一种浪费
**index.html**
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <div id="clock"></div>
<script>
let clock = document.querySelector('#clock');
function send(){
    let xhr = new XMLHttpRequest;
    xhr.open('GET','/clock',true);
    xhr.onreadystatechange = function(){
        if(xhr.readyState == 4 && xhr.status == 200){
            clock.innerHTML = xhr.responseText;
            send();
        }
    }
    xhr.send();
}
send();
</script>    
</body>
</html>
**app.js**
let express = require("express");
let app = express();
// http://localhost:8000/
app.use(express.static(__dirname));
app.get("/clock", function (req, res) {
  let $timer = setInterval(function () {
    let date = new Date();
    let seconds = date.getSeconds();
    if (seconds % 5 === 0) {
      res.send(date.toLocaleString());
      clearInterval($timer);
    }
  }, 1000);
});
app.listen(8000);

2.3 iframe流
通过在HTML页面里嵌入一个隐藏的iframe,然后将这个iframe的src属性设为对一个长连接的请求,服务器端就能源源不断地往客户推送数据。

**index.html**
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Document</title>
  </head>
  <body>
    <div id="clock" style="border: 1px solid red; height: 200px"></div>
    <iframe src="/clock" style="border: none; height: 0"></iframe>

    <script>
      //window.setTime =
      function setTime(ts) {
        document.querySelector("#clock").innerHTML = ts;
      }
    </script>
  </body>
</html>

**app.js**
let express = require("express");
let app = express();
// http://localhost:8000/
app.use(express.static(__dirname));
app.get("/clock", function (req, res) {
  res.header("Content-Type", "text/html");
  setInterval(function () {
    res.write(`
      <script>
         parent.setTime("${new Date().toLocaleString()}")
      </script>`);
  }, 1000);
});
app.listen(9001);

2.4 EventSource流

  • HTML5规范中提供了服务端事件EventSource,浏览器在实现了该规范的前提下创建一个EventSource连接后,便可收到服务端的发送的消息,这些消息需要遵循一定的格式,对于前端开发人员而言,只需在浏览器中侦听对应的事件皆可
  • SSE的简单模型是:一个客户端去从服务器端订阅一条流,之后服务端可以发送消息给客户端直到服务端或者客户端关闭该“流”,所以eventsource也叫作"server-sent-event`
  • EventSource流的实现方式对客户端开发人员而言非常简单,兼容性良好
  • 对于服务端,它可以兼容老的浏览器,无需upgrade为其他协议,在简单的服务端推送的场景下可以满足需求

2.4.1 浏览器端 #

  • 浏览器端,需要创建一个EventSource对象,并且传入一个服务端的接口URI作为参数
  • 默认EventSource对象通过侦听message事件获取服务端传来的消息
  • open事件则在http连接建立后触发
  • error事件会在通信错误(连接中断、服务端返回数据失败)的情况下触发
  • 同时EventSource规范允许服务端指定自定义事件,客户端侦听该事件即可
**index.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>
      let eventSource = new EventSource("/clock");
      let clock = document.querySelector("#clock");
      eventSource.onmessage = function (event) {
        let message = event.data;
        clock.innerHTML = message;
      };
      eventSource.onopen = function (event) {
        console.log("open");
      };
      eventSource.onerror = function (event) {
        console.log("error");
      };
    </script>
  </body>
</html>

2.4.2 服务端 #
事件流的对应MIME格式为text/event-stream,而且其基于HTTP长连接。针对HTTP1.1规范默认采用长连接,针对HTTP1.0的服务器需要特殊设置。
event-source必须编码成utf-8的格式,消息的每个字段使用"\n"来做分割,并且需要下面4个规范定义好的字段:
Event: 事件类型
Data: 发送的数据
ID: 每一条事件流的ID
Retry: 告知浏览器在所有的连接丢失之后重新开启新的连接等待的时间,在自动重新连接的过程中,之前收到的最后一个事件流ID会被发送到服务端

**app.js**
let express = require("express");
let app = express();

app.use(express.static(__dirname));
app.get("/clock", function (req, res) {
  res.header("Content-Type", "text/event-stream");
  let counter = 0;
  let $timer = setInterval(function () {
    res.write(
      `id:${counter++}\nevent:abc\ndata:${new Date().toLocaleTimeString()}\n\n`
    );
  }, 1000);
  res.on("close", function () {
    clearInterval($timer);
  });
});
app.listen(7777);

**app2.js** 使用ssestream库
let express = require("express");
let app = express();
app.use(express.static(__dirname));
//passThrough 通过流,它是一转换流
const SseStream = require("ssestream");

app.get("/clock", function (req, res) {
  let counter = 0;
  const sseStream = new SseStream(req);
  sseStream.pipe(res);
  const pusher = setInterval(function () {
    sseStream.write({
      id: counter++,
      event: "message",
      retry: 2000,
      data: new Date().toString(),
    });
    // 他内部会帮你转换成:event:message\nid:0\nretry:2000\ndata:2019年3月23日17:45:51\n\n
  }, 1000);
  res.on("close", function () {
    clearInterval(pusher);
    sseStream.unpipe(res);
  });
});
app.listen(7777);

3. websocket

  • WebSockets_API 规范定义了一个 API 用以在网页浏览器和服务器建立一个 socket 连接。通俗地讲:在客户端和服务器保有一个持久的连接,两边可以在任意时间开始发送数据。
  • HTML5开始提供的一种浏览器与服务器进行全双工通讯的网络技术
  • 属于应用层协议,它基于TCP传输协议,并复用HTTP的握手通道。

3.1 websocket优势

  • 支持双向通信,实时性更强。
  • 更好的二进制支持。
  • 较少的控制开销。连接创建后,ws客户端、服务端进行数据交换时,协议控制的数据包头部较小。

3.2 websocket实战
3.2.1 服务端

**app.js**
let express = require("express");
let app = express();

app.use(express.static(__dirname));

app.listen(3000);

let websocketServer = require("ws").Server;
let server = new websocketServer({ port: 8888 });
server.on("connection", (socket) => {
  console.log("2.服务器监听到了客户端请求");
  socket.on("message", (message) => {
    console.log("4.客户端连接过来的消息", message);
    socket.send("5.服务器说" + message);
  });
});

3.2.2 客户端

**index.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>
    <script>
      let socket = new WebSocket("ws://localhost:8888");
      socket.onopen = function () {
        console.log("1. 客户端连接上了服务器");
        socket.send("hello");
      };

      socket.onmessage = function (event) {
        console.log(event.data);
      };
    </script>
  </body>
</html>

3.3. 如何建立连接
WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

3.3.1 客户端:申请协议升级
首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

请求头:
GET ws://localhost:8888/ HTTP/1.1
Host: localhost:8888
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: IHfMdf8a0aQXbwQO1pkGdA==

  • Connection: Upgrade:表示要升级协议
  • Upgrade: websocket:表示要升级到websocket协议
  • Sec-WebSocket-Version: 13:表示websocket的版本
  • Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。

3.3.2 服务端:响应协议升级
服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

响应头:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: aWAY+V/uyz5ILZEoWuWdxjnlb7E=

3.3.3 Sec-WebSocket-Accept的计算
Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。 计算公式为:

  • 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11(固定是这个)拼接。
  • 通过SHA1计算出摘要,并转成base64字符串
const crypto = require('crypto');
const CODE = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const webSocketKey = 'IHfMdf8a0aQXbwQO1pkGdA==';
let websocketAccept = require('crypto').createHash('sha1').update(webSocketKey + CODE ).digest('base64');
console.log(websocketAccept);//aWAY+V/uyz5ILZEoWuWdxjnlb7E=

3.3.4 Sec-WebSocket-Key/Accept的作用

  • 避免服务端收到非法的websocket连接
  • 确保服务端理解websocket连接
  • 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的
  • Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)

3.4 数据帧格式
WebSocket客户端、服务端通信的最小单位是帧,由1个或多个帧组成一条完整的消息(message)。

  • 发送端:将消息切割成多个帧,并发送给服务端
  • 接收端:接收消息帧,并将关联的帧重新组装成完整的消息

3.4.1 数据帧格式
单位是比特。比如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  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 | Extended payload length       | 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位,或7+64位。所以一个数据帧最大数据载荷是(2^64-1)字节(B)。
    • 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字节。
    • 扩展数据:如果没有协商使用扩展的话,扩展数据数据为0字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
    • 应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度。

3.4.2 掩码算法
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。掩码操作不会影响数据载荷的长度。掩码、反掩码操作都采用如下算法:

  • 对索引i模以4得到j,因为掩码一共就是四个字节
  • 对原来的索引进行异或对应的掩码字节
  • 异或就是两个数的二进制形式,按位对比,相同取0,不同取1
function maskOrUnmask(buffer, mask) {
  const length = buffer.length;
  for (let i = 0; i < length; i++) {
    buffer[i] ^= mask[i % 4];
  }
  return buffer;
}

const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]); // 随机写的字节数组
const buffer = Buffer.from([0x68, 0x65, 0x6c, 0x6c, 0x6f]); // 随机写的字节数组
const masked = maskOrUnmask(buffer, mask); // 掩码
console.log(masked); // <Buffer 7a 51 3a 14 7d>

const unmasked = maskOrUnmask(buffer, mask); // 反掩码
console.log(unmasked); // <Buffer 68 65 6c 6c 6f>,和buffer一致

3.4.3 服务器实战

**app2.js**
let net = require("net"); // net模块用于创建tcp服务
let CODE = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
let crypto = require("crypto");

// 实现http协议升级到ws协议。模拟服务端响应头返回
/**
* 
GET ws://localhost:8888/ HTTP/1.1/r/n
Connection: Upgrade/r/n
Upgrade: websocket/r/n
Sec-WebSocket-Version: 13/r/n
Sec-WebSocket-Key: O/SldTn2Th7GfsD07IxrwQ==/r/n
/r/n
*/

/**
*
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: H8BlFmSUnXVpM4+scTXjZIwFjzs= 
*/

let server = net.createServer((socket) => {
  socket.once("data", (data) => {
    // 建立连接,使用once
    data = data.toString(); // buffer转字符串
    if (data.match(/Connection: Upgrade/)) {
      let rows = data.split("\r\n");
      //   rows格式:
      //   'GET / HTTP/1.1',
      //   'Host: localhost:9999',
      //   '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/127.0.0.0 Safari/537.36',
      //   'Upgrade: websocket',
      //   'Origin: null',
      //   'Sec-WebSocket-Version: 13',
      //   'Accept-Encoding: gzip, deflate, br, zstd',
      //   'Accept-Language: zh-CN,zh;q=0.9',
      //   'Sec-WebSocket-Key: MwGApjw5wYjUrCrC2Rr1Cg==',
      //   'Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits',
      //   '',
      //   ''
      rows = rows.slice(1, -2);
      let headers = {};
      rows.reduce((memo, item) => {
        let [key, value] = item.split(": ");
        memo[key] = value;
        return memo;
      }, headers);
      if (headers["Sec-WebSocket-Version"] == "13") {
        let secWebSocketKey = headers["Sec-WebSocket-Key"];
        let secWebSocketAccept = crypto
          .createHash("sha1")
          .update(secWebSocketKey + CODE)
          .digest("base64");
        let response = [
          "HTTP/1.1 101 Switching Protocols",
          "Upgrade: websocket",
          "Connection: Upgrade",
          `Sec-WebSocket-Accept: ${secWebSocketAccept}`,
          "\r\n",
        ].join("\r\n");
        socket.write(response);
        //后面所有的格式都是基于websocket协议的
        socket.on("data", (buffers) => {
          // 通讯,使用on
          // data默认是一个Buffer
          let fin = buffers[0] & (0b10000000 === 0b10000000); // 结束位是true还是false,第0个字节第一位
          let opcode = buffers[0] & 0b00001111; // 操作码,第0个字节后4位
          let isMask = buffers[1] & (0b10000000 == 0b10000000); // 是否进行了掩码
          let payloadLength = buffers[1] & 0b01111111; // 获得第1个字节后7位
          let mask = buffers.slice(2, 6); // 掩码键,这里假设payloadLength是7位,下面根据这个来写代码
          let payload = buffers.slice(6); // 携带的真实数据
          payload = maskOrUnmask(payload, mask);
          let response = Buffer.alloc(2 + payload.length);
          response[0] = 0b10000000 | opcode;
          response[1] = payloadLength;
          payload.copy(response, 2); // 将 payload 的内容复制到 response 的第二个字节开始的位置,等于把客户端的消息又传了回去
          socket.write(response);
        });
      }
    }
  });
});
server.listen(9999);

function maskOrUnmask(buffer, mask) {
  const length = buffer.length;
  for (let i = 0; i < length; i++) {
    buffer[i] ^= mask[i % 4];
  }
  return buffer;
}


**index.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>
    <script>
      let socket = new WebSocket("ws://localhost:9999");
      socket.onopen = function () {
        console.log("1. 客户端连接上了服务器");
        socket.send(
          "hello from the other side, I must've called a thousand times"
        );
      };

      socket.onmessage = function (event) {
        console.log(event.data);
      };
    </script>
  </body>
</html>

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值