websocket在什么背景下诞生?

WebSocket 是一种网络通信协议,它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。初次接触 WebSocket 的人,都会问同样的问题:我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?下面来看一下websocket诞生之前,都是怎么实现服务端推送的?

短轮询(Polling)

短轮询(Polling)的实现思路就是浏览器端每隔几秒钟向服务器端发送http请求,服务端在收到请求后,不论是否有数据更新,都直接进行响应。在服务端响应完成,就会关闭这个Tcp连接,如下图所示:

webSocket

示例代码实现如下:

function Polling() {
    fetch(url).then(data => {
        // somthing
    }).catch(err => {
        console.log(err);
    });
}
setInterval(polling, 5000);
  • 优点:可以看到实现非常简单,它的兼容性也比较好的只要支持http协议就可以用这种方式实现。
  • 缺点:但是它的缺点也很明显就是非常的消耗资源,因为建立Tcp连接是非常消耗资源的,服务端响应完成就会关闭这个Tcp连接,下一次请求再次建立Tcp连接。

长轮询(Long-Polling)

客户端发送请求后服务器端不会立即返回数据,服务器端会阻塞请求连接不会立即断开,直到服务器端有数据更新或者是连接超时才返回,客户端才再次发出请求新建连接、如此反复从而获取最新数据。大致效果如下:

webSocket

客户端的代码如下:

function LongPolling() {
    fetch(url).then(data => {
        LongPolling();
    }).catch(err => {
        LongPolling();
        console.log(err);
    });
}
LongPolling();
  • 优点: 长轮询和短轮询比起来,明显减少了很多不必要的http请求次数,相比之下节约了资源。
  • 缺点:连接挂起也会导致资源的浪费

WebSocket

WebSocket是一种协议,是一种与HTTP 同等的网络协议,两者都是应用层协议,都基于 TCP 协议。但是 WebSocket 是一种双向通信协议,在建立连接之后,WebSocketserver 与 client 都能主动向对方发送或接收数据。

WebSocket 对象提供了一组 API,用于创建和管理 WebSocket 连接,以及通过连接发送和接收数据。浏览器提供的WebSocket API很简洁,调用示例如下:

var ws = new WebSocket('wss://example.com/socket'); // 创建安全WebSocket 连接(wss)
ws.onerror = function (error) { ... } // 错误处理
ws.onclose = function () { ... } // 关闭时调用
ws.onopen = function () { ws.send("Connection established. Hello server!");} // 连接建立时调用向服务端发送消息
ws.onmessage = function(msg) {  ... }// 接收服务端发送的消息复制代码

websocket与http区别:

  • HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的。我们可以把这些高级协议理解成对 TCP 的封装。既然大家都使用 TCP 协议,那么大家的连接和断开,都要遵循 TCP 协议中的三次握手和四次握手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。对于 WebSocket 来说,它必须依赖 HTTP 协议进行一次握手 ,握手成功后,数据就直接从 TCP 通道传输,与 HTTP 无关了。
  • 相比于短轮询、长轮询(采用http请求)的每次“请求-应答”都要clientserver 建立连接的模式,WebSocket 是一种持久连接的模式。就是一旦WebSocket 连接建立后,除非client 或者 server 中有一端主动断开连接,否则每次数据传输之前都不需要HTTP 那样请求数据。
  • 另外,短轮询、长轮询(采用http请求)方式的服务端都是被动的响应,属于单工通信。而websocket客户端、服务端都能主动的向对方发送消息,属于全双工通信。

WebSocket有以下特点:

  • 建立在 TCP 协议之上,服务器端的实现比较容易。
  • HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器
  • 数据格式比较轻量,性能开销小,通信高效。
  • 可以发送文本,也可以发送二进制数据。
  • 没有同源限制,客户端可以与任意服务器通信
  • 协议标识符是ws(如果加密,则为wss

websocket是怎样握手的?

image.png

  • 浏览器、服务器三次握手建立TCP连接。这是通信的基础,传输控制层,若失败后续都不执行。
  • TCP连接成功后,浏览器通过HTTP协议向服务器发送带有Upgrade头的HTTP Request消息,目的是将协议升级为websocket
    image.png
    ConnectionHTTP1.1中规定Upgrade只能应用在直接连接中。带有Upgrade头的HTTP1.1消息必须含有Connection头,因为Connection头的意义就是,任何接收到此消息的人(往往是代理服务器)都要在转发此消息之前处理掉Connection中指定的域(即不转发Upgrade域)。
    UpgradeHTTP1.1中用于定义转换协议的header域。 如果服务器支持的话,客户端希望使用已经建立好的HTTP(TCP)连接,切换到WebSocket协议。
    Sec-WebSocket-Key是一个Base64 encode的值,这个是客户端随机生成的,用于服务端的验证,服务器会使用此字段组装成另一个key值放在握手返回信息里发送客户端。
    Sec-WebSocket-Version标识了客户端支持的WebSocket协议的版本列表。
    Sec-WebSocket-Extensions是客户端用来与服务端协商扩展协议的字段,permessage-deflate表示协商是否使用传输数据压缩,client_max_window_bits表示采用LZ77压缩算法时,滑动窗口相关的SIZE大小。
    Sec_WebSocket-Protocol是一个用户定义的字符串,用来区分同URL下,不同的服务所需要的协议,标识了客户端支持的子协议的列表。
  • 服务器收到客户端的握手请求后,同样采用HTTP协议回馈
    image.png
    HTTP的版本为HTTP1.1,返回码是101,表示升级到websocket协议
    Connection字段,包含Upgrade
    Upgrade字段,包含websocket
    Sec-WebSocket-Accept字段,详细介绍一下:

    Sec-WebSocket-Accept字段生成步骤:

    1. Sec-WebSocket-Key与协议中已定义的一个GUID “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”进行拼接。
    2. 将步骤1中生成的字符串进行SHA1编码。
    3. 将步骤2中生成的字符串进行Base64编码。

    客户端通过验证服务端返回的Sec-WebSocket-Accept的值, 来确定两件事情:

    1. 服务端是否理解WebSocket协议, 如果服务端不理解,那么它就不会返回正确的Sec-WebSocket-Accept,则建立WebSocket连接失败。
    2. 服务端返回的Response是对于客户端的此次请求的,而不是之前的缓存。主要是防止有些缓存服务器返回缓存的Response.
  • 至此,握手过程就完成了,此时的TCP连接不会释放。客户端和服务端可以互相通信了。

websocket如何身份认证?

大体上Websocket的身份认证都是发生在握手阶段,通过请求中的内容来认证。一个常见的例子是在url中附带参数。

new WebSocket("ws://localhost:3000?token=xxxxxxxxxxxxxxxxxxxx");

淘宝的直播弹幕也是用这种方式做的身份认证。另外,websocket是采用http协议握手的,可以用请求中携带cookie的方式做身份认证。

npmws模块实现为例,其创建Websocket服务器时提供了verifyClient方法。

const wss = new WebSocket.Server({
  host: SystemConfig.WEBSOCKET_server_host,
  port: SystemConfig.WEBSOCKET_server_port,
  // 验证token识别身份
  verifyClient: (info) => {
    const token = url.parse(info.req.url, true).query.token
    let user
    console.log('[verifyClient] start validate')
    // 如果token过期会爆TokenExpiredError
    if (token) {
      try {
        user = jwt.verify(token, publicKey)
        console.log(`[verifyClient] user ${user.name} logined`)
      } catch (e) {
        console.log('[verifyClient] token expired')
        return false
      }
    }
    // verify token and parse user object
    if (user) {
      info.req.user = user
      return true
    } else {
      info.req.user = {
        name: `游客${parseInt(Math.random() * 1000000)}`,
        mail: ''
      }
      return true
    }
  }
})

相关的ws源码位于ws/websocket-server

 // ...
  if (this.options.verifyClient) {
    const info = {
      origin: req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
      secure: !!(req.connection.authorized || req.connection.encrypted),
      req
    };

    if (this.options.verifyClient.length === 2) {
      this.options.verifyClient(info, (verified, code, message) => {
        if (!verified) return abortHandshake(socket, code || 401, message);
        this.completeUpgrade(extensions, req, socket, head, cb);
      });
      return;
    }

    if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
  }
  this.completeUpgrade(extensions, req, socket, head, cb);
}

websocket如何断开重连?

先来看下以下场景:

  • 如果设备网络断开,不会立刻触发websocket的任何事件,浏览器也就无法得知当前连接是否已经断开,后端发送的消息包将会丢失。
  • 后端websocket服务也可能出现异常,造成连接断开,这时浏览器也并没有收到断开通知,一直在等后端的消息

为了解决以上两个问题,websocket连接成功之后,浏览器会定时发送心跳消息ping,后端收到ping类型的消息,立马返回pong消息,告知浏览器连接正常,以此方式检测网络和前后端连接问题。一旦发现异常,前端持续执行重连逻辑,直到重连成功。
image.png

Node实现websocket

客户端使用

import React, { useEffect, useState } from 'react';

const Main = () => {
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:8888/ws');
  }, []);

  return <div>websocket</div>
}

export default Main;

搭建websocket服务
websocket服务有别于传统的http服务,因为它的服务需要在http服务的基础进行升级。先搭建一个http服务,它是websocket服务的基础。

const http = require('http');

const PORT = 8888;

const server = http.createServer((req, res) => {});

server.listen(PORT, () => {
  console.info('listening on websocket~~');
})

浏览器使用http协议连接websocket服务的请求头中有两个很关键的字段Connection:UpgradeUpgrade:websocket,这两个请求头告诉服务端,我想要升级协议并且升级的协议是websocket

这里就可以看出两个问题:

  1. 服务器必须拥有升级协议的能力,此处对服务器提出来要求;
  2. 这个机制只属于http1.1

下面我们开始升级协议,当有服务升级的请求时会触发upgrade事件,

server.on('upgrade', (req, socket, head) => {
  // 处理升级逻辑,socket是套接字
  handleUpgrade(req, socket, head);
})

服务监听upgrade事件,在回调函数中处理升级逻辑

// 校验是否是升级为websocket协议的请求
const checkHeader = (req, socket, key, version) => {
  if (req.method.toLowerCase() !== 'get' ||
    req.headers.upgrade.toLowerCase() !== 'websocket' ||
    !key || version !== '13' || req.url !== '/ws') 
  {
    let message = http.STATUS_CODES[400];
    let headers = {
      Connection: 'close',
      'Content-Type': 'text/html',
      'Content-Length': Buffer.byteLength(message),
    };

    socket.write(
      `HTTP/1.1 400 ${message}\r\n` + 
      Object.keys(headers).map(h => `${h}: ${headers[h]}`).join('\r\n') + 
      '\r\n\r\n' + 
      message
    );

    socket.destroy();

    return true;
  }
  return false;
}

// 处理升级逻辑
const handleUpgrade = (req, socket, head) => {
  const key = req.headers['sec-websocket-key'] ? req.headers['sec-websocket-key'].trim() : false;
  const version = req.headers['sec-websocket-version'];

  if (checkHeader(req, socket, key, version)) {
    return;
  }
}

浏览器发来的请求头包含sec-websocket-keysec-websocket-version,前者的主要作用有两点:

  1. 用于校验请求是否真的就是websocket请求,因为从以上代码中就可以看出普通的ajax在设置了各种请求头后也可以对这个服务发起请求,所以用这个key可以做一次简单的校验;
  2. 这个key会在每次连接之初由浏览器随机生成,可以用来标记请求与响应对应,比如如果连续发送两次连接,可能存在服务器把第一次请求的数据发送给第二次请求这样的情况,所以用这个key可以为每次连接做一个标记;

后者告诉服务器需要支持的websocket版本。

拿到这些信息后需要先在逻辑中做一些基础校验:

  1. 必须是get请求;
  2. 升级的协议必须是websocket
  3. 必须存在key
  4. 版本是8或者13。

如果不满足以上条件,会返回400,请求无效,如果验证通过,我们可以继续下面的处理!

const handleUpgrade = (req, socket, head) => {
  const key = req.headers['sec-websocket-key'] ? req.headers['sec-websocket-key'].trim() : false;
  const version = req.headers['sec-websocket-version'];

  if (checkHeader(req, socket, key, version)) {
    return;
  }

  completeUpgrade(req, socket, key, head);
}

后面的处理逻辑都封装在completeUpgrade方法中

const completeUpgrade = (req, socket, key, head) => {
  const UUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
  const digest = require('crypto').createHash('sha1').update(key + UUID).digest('base64');

  const headers = [
    'HTTP/1.1 101 Switching Protocols',
    'Upgrade: websocket',
    'Connection: Upgrade',
    `Sec-WebSocket-Accept: ${digest}`
  ];

  socket.write(headers.concat('\r\n').join('\r\n'));
}

Sec-WebSocket-Accept响应头的值计算方式是,将key和一个固定的字符串(258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接在一起通过sha1计算出摘要并转化为base64得到此值。

当链接成功时响应状态码应该是101,表示协议转换。

image.png

讲到这里我们已经成功把页面与websocket服务连接起来了,如果请求协议改成http会在浏览器中报错
image.png

握手成功后,就可以进行数据传输了,然而不进行解码操作是得不到正确的结果的。

// 打印的数据类似是这样的格式 <Buffer aa bb cc> socket.on('data', console.log.bind(console)); 

我们可以来看一下ws帧的完整格式:

1               2               3               4              
0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|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 ...                |
+---------------------------------------------------------------+

解释如下:

  • FIN: 表示帧是否结束,1 结束,0 没结束
  • RSV[1-3]: 通常来说置零即可,但可以根据扩展协商非零值的具体含义
  • opcode: 操作码,0, 1, 2 属于数据帧,8, 9, 10 属于控制帧,具体含义如下

    • 0: 附加帧
    • 1: 文本帧
    • 2: 二进制帧
    • 3-7: 保留作为未来的非控制帧
    • 8: 关闭帧
    • 9: ping 帧
    • 10: pong 帧
    • 11-15: 保留作为未来的控制帧
  • MASK: 掩码,0 表示不使用掩码,1 表示使用 Masking-key 对负载数据进行掩码运算
  • Payload len:

    • 0-125: 实际负载数据长度
    • 126: 接下来的两字节对应的无符号整数作为负载长度
    • 127: 扩展的 8 字节对应的无符号帧数作为负载长度
  • Masking-key: 如果 MASK 为 1 时,后续的四字节作为 Masking-key,MASK 为 0 时则缺省 Masking-key
  • Payload Data: (x+y) bytes 负载数据

    • Extension data (x bytes): 扩展数据通常来说是 0 字节,除非协商了一个扩展
    • Application data (y bytes): 应用数据

解码操作代码如下:

const decodeWsFrame = (data) => {
  let start = 0;
  let frame = {
    isFinal: (data[start] & 0x80) === 0x80,
    opcode: data[start++] & 0xF,
    masked: (data[start] & 0x80) === 0x80,
    payloadLen: data[start++] & 0x7F,
    maskingKey: '',
    payloadData: null
  };

  if (frame.payloadLen === 126) {
    frame.payloadLen = (data[start++] << 8) + data[start++];
  } else if (frame.payloadLen === 127) {
    frame.payloadLen = 0;
    for (let i = 7; i >= 0; --i) {
      frame.payloadLen += (data[start++] << (i * 8));
    }
  }

  if (frame.payloadLen) {
    if (frame.masked) {
      const maskingKey = [
        data[start++],
        data[start++],
        data[start++],
        data[start++]
      ];

      frame.maskingKey = maskingKey;

      frame.payloadData = data
        .slice(start, start + frame.payloadLen)
        .map((byte, idx) => byte ^ maskingKey[idx % 4]);
    } else {
      frame.payloadData = data.slice(start, start + frame.payloadLen);
    }
  }

  return frame;
}

编码操作代码如下:

const encodeWsFrame = (data) => {
  const isFinal = data.isFinal !== undefined ? data.isFinal : true,
  opcode = data.opcode !== undefined ? data.opcode : 1,
  payloadData = data.payloadData ? Buffer.from(data.payloadData) : null,
  payloadLen = payloadData ? payloadData.length : 0;

  let frame = [];

  if (isFinal) frame.push((1 << 7) + opcode);
  else frame.push(opcode);

  if (payloadLen < 126) {
    frame.push(payloadLen);
  } else if (payloadLen < 65536) {
    frame.push(126, payloadLen >> 8, payloadLen & 0xFF);
  } else {
    frame.push(127);
    for (let i = 7; i >= 0; --i) {
      frame.push((payloadLen & (0xFF << (i * 8))) >> (i * 8));
    }
  }

  frame = payloadData ? Buffer.concat([Buffer.from(frame), payloadData]) : Buffer.from(frame);

  return frame;
}

接下来就可以愉快的传输数据了

ws.onopen = () => {
  ws.send('ping')
}

服务端在completeUpgrade中处理与浏览器建立连接后监听data事件接收,并且回应

socket.on('data', (buffer) => {
    const data = decodeWsFrame(buffer);
    // opcode为8,表示客户端发起了断开连接
    if (data.opcode === 8) {
      socket.end()  // 与客户端断开连接
    } else {
      socket.write(encodeWsFrame({ payloadData: 'pong' }))
    }
})

image.png

上面我们仅仅发送了一帧数据,但是在有些场景下一个完整数据分为多个数据帧进行发送,其可以分为三个部分:

  • 起始帧(数量==1): FIN == 0, opcode != 0
  • 附加帧(数量>=0): FIN == 0, opcode == 0
  • 终止帧(数量==1): FIN == 1, opcode == 0

具体分片处理代码实现如下:

function rawFrameParseHandle(socket) {
  let frame,
    frameArr = [], // 用来保存分片帧的数组     totalLen = 0;  // 记录所有分片帧负载叠加的总长度   socket.on('data', rawFrame => {
    frame = decodeWsFrame(rawFrame);

    if (frame.isFinal) {
      // 分片的终止帧       if (frame.opcode === 0) {
        frameArr.push(frame);
        totalLen += frame.payloadLen;

        let frame = frameArr[0],
          payloadDataArr = [];
        payloadDataArr = frameArr
          .filter(frame => frame.payloadData)
          .map(frame => frame.payloadData);
        // 将所有分片负载合并         frame.payloadData = Buffer.concat(payloadDataArr);
        frame.payloadLen = totalLen;
        // 根据帧类型进行处理         opHandle(socket, frame);
        frameArr = [];
        totalLen = 0;
      } else { // 普通帧         opHandle(socket, frame);
      }
    } else { // 分片起始帧与附加帧       frameArr.push(frame);
      totalLen += frame.payloadLen;
    }
  });
} 

进行测试

// 测试代码 // 客户端将三个帧进行拼接为 'bbbcccddd' socket.write(encodeWsFrame({isFinal: false, opcode: 1, payloadData: 'bbb'}));
socket.write(encodeWsFrame({isFinal: false, opcode: 0, payloadData: 'ccc'}));
socket.write(encodeWsFrame({isFinal: true, opcode: 0, payloadData: 'ddd'}));

参考:
深入浅出Websocket(一)Websocket协议
详解前端如何搭建一个websocket服务器
原生模块打造一个简单的 WebSocket 服务器


记得要微笑
1.9k 声望4.5k 粉丝

知不足而奋进,望远山而前行,卯足劲,不减热爱。