Websocket 初识

这篇文章给学校组织上课的一部分,同时也是为了帮我补充以前没注意的问题。

以前都没怎么了解过 Websocket,最多也是用过 pusher 之类的成套解决方案,所以这里就对 Websocket 发生的整个过程做一个了解。这里使用原生搭建,因为 nodejs 的 WebSocket 很原始,需要自己做很多事情。

1. 服务端创建一个Net服务器

// 引入net模块
const net = require('net')

// 使用net模块创建服务器,返回的是一个原始的socket对象
const server = net.createServer((socket) => {

})

server.listen(8002, () => {
	console.log('Runing at: ws://localhost:8002')
})

在你的页面的 script 标签中写上

const ws = new WebSocket('ws://localhost:8080/')

2. Web端创建一个WebSocket链接

创建一个 WebSocket 连接,此时控制台的 Network 模块可以看到一个处于pending状态的HTTP连接。

image-20200313211224400

这个连接是一个 HTTP 请求,与普通HTTP请求的请求你头相比,增加了以下内容:

  • Sec-WebSocket-Extensions: permessage-deflate; clientmaxwindow_bits // 扩展信息
  • Sec-WebSocket-Key: D27bMuu0x12Q2VJ7iE0ANw== // 发送一个Key到服务端,用于校验服务端是否支持WebSocket
  • Sec-WebSocket-Version: 13 // WebSocket 版本
  • Upgrade: websocket // 告知服务器通信协议将会升级到 WebSocket 若服务器支持则继续下一步

3. 服务端使用socket.once,触发一次data事件处理HTTP请求头数据

socket.once('data', (buffer) => {
  // 接收到HTTP请求头数据
  const str = buffer.toString()
  console.log(str)
})

打印结果如下:

Runing at: ws://localhost:8002
GET / HTTP/1.1
Host: localhost:8002
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/80.0.3987.132 Safari/537.36 Edg/80.0.361.66      
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,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cookie: xxx
Sec-WebSocket-Key: p9koDI+fzMQI/LAka5jzng==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

和以前的一样我们将换行符转换之后就成了这样

Runing at: ws://localhost:8002\r\nGET / HTTP/1.1\r\nHost: localhost:8002\r\n...

就可以通过以下的函数得到 header 信息

const parseHeader = str => {
    // 将请求头数据按回车符切割为数组,得到每一行数据
    let arr = str.split('\r\n').filter(item => item)
    // 第一行数据为GET / HTTP/1.1,可以丢弃。
    arr.shift()
    let headers = {}  // 存储最终处理的数据
    arr.forEach((item) => {
        // 需要用":"将数组切割成key和value
        let [name, value] = item.split(':')
        // 去除无用的空格,将属性名转为小写
        name = name.toLowerCase()
        value = value.trim()
        // 获取所有的请求头属性
        headers[name] = value
    })
    return headers
}

5. 根据请求头参数,判断是否WebSocket请求

  • 根据 headers['upgrade'] !== 'websocket',判断该HTTP连接是否可升级为 WebSocket,若可以升级,表示为WebSocket 请求。
  • 根据 headers['sec-websocket-version'] !== '13',判断WebSocket的版本是否为 13,以免因为版本不同出现兼容问题。
socket.once('data', buffer => {
    // 接收到HTTP请求头数据
    const str = buffer.toString()
    // 将请求头数据转为对象
    const headers = parseHeader(str)
    // 判断请求是否为WebSocket连接
    if (headers['upgrade'] !== 'websocket') {
        // 若当前请求不是WebSocket连接,则关闭连接
        console.log('非 WebSocket 连接')
        socket.end()
    } else if (headers['sec-websocket-version'] !== '13') {
        // 判断WebSocket版本是否为13,防止是其他版本,造成兼容错误
        console.log('WebSocket 版本错误')
        socket.end()
    } else {
        // 请求为WebSocket连接时,进一步处理
    }
})

6. 校验Sec-WebSocket-Key,完成连接

根据协议规定的方式,向前端返回一个请求头,完成建立 WebSocket 连接的过程。

若客户端校验结果正确,在控制台的 Network 模块可以看到HTTP请求的状态码变为101 Switching Protocols,同时客户端的 ws.onopen 事件被触发。

// 校验Sec-WebSocket-Key,完成连接
/* 
  协议中规定的校验用GUID,可参考如下链接:
  https://tools.ietf.org/html/rfc6455#section-5.5.2
  https://stackoverflow.com/questions/13456017/what-does-258eafa5-e914-47da-95ca-c5ab0dc85b11-means-in-websocket-protocol
*/
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const key = headers['sec-websocket-key']
const hash = crypto.createHash('sha1')  // 创建一个签名算法为sha1的哈希对象

hash.update(`${key}${GUID}`)  // 将key和GUID连接后,更新到hash
const result = hash.digest('base64') // 生成base64字符串
const header = `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-Websocket-Accept: ${result}\r\n\r\n` // 生成供前端校验用的请求头

socket.write(header)  // 返回HTTP头,告知客户端校验结果,HTTP状态码101表示切换协议:https://httpstatuses.com/101。
// 若客户端校验结果正确,在控制台的 Network 模块可以看到HTTP请求的状态码变为 101 Switching Protocols,同时客户端的ws.onopen事件被触发。

// 将建立连接的客户端推到数组中
list.push(socket)
console.log(list.length)
// 处理聊天数据

7. 建立连接后,通过data事件接收客户端的数据并处理

连接开始后,可以在控制台的 Network 模块看到,该连接会一直保留在 pending 状态,直到连接断开。

因为这里使用的是原生,所以数据帧也要我们自己解析,有兴趣的同学可以稍微看一下(我反正只能说抱歉了)

此时可以通过 data 事件处理客户端的数据,但此时双方通信的数据为二进制,需要按照其格式进行处理后才可以正常使用。

处理收到的数据:

// 处理收到的数据
function 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;
}

处理发出的数据:

function 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;
}

发送给客户端 (这里的广播处理尽量简单)

const data = decodeWsFrame(buffer)
// opcode为8,表示客户端发起了断开连接
if (data.opcode === 8) {
  socket.end()  // 与客户端断开连接
} else {
  // 接收到客户端数据时的处理,此处默认为返回接收到的数据。
  // 更新那些客户端还在
  list = list.filter(item => !item.destroyed)
  list.forEach(item => {
    item.write(encodeWsFrame({ payloadData: `服务端接收到的消息为:${data.payloadData ? data.payloadData.toString() : ''}` }))
  })
}

完整代码

// 引入net模块
const net = require('net')
const crypto = require('crypto')

// 处理收到的数据
function 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;
}

// 处理发出的数据
function 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;
}


const parseHeader = str => {
  // 将请求头数据按回车符切割为数组,得到每一行数据
  let arr = str.split('\r\n').filter(item => item)
  // 第一行数据为GET / HTTP/1.1,可以丢弃。
  arr.shift()
  let headers = {}  // 存储最终处理的数据
  arr.forEach((item) => {
    // 需要用":"将数组切割成key和value
    let [name, value] = item.split(':')
    // 去除无用的空格,将属性名转为小写
    name = name.toLowerCase()
    value = value.trim()
    // 获取所有的请求头属性
    headers[name] = value
  })
  return headers
}

// 在内存里面存储一个连接服务器的列表
let list = [];

// 使用net模块创建服务器,返回的是一个原始的socket对象,与Socket.io的socket对象不同。
const server = net.createServer((socket) => {
  socket.once('data', buffer => {
    // 接收到HTTP请求头数据
    const str = buffer.toString()
    // 将请求头数据转为对象
    const headers = parseHeader(str)
    // 判断请求是否为WebSocket连接
    if (headers['upgrade'] !== 'websocket') {
      // 若当前请求不是WebSocket连接,则关闭连接
      console.log('非 WebSocket 连接')
      socket.end()
    } else if (headers['sec-websocket-version'] !== '13') {
      // 判断WebSocket版本是否为13,防止是其他版本,造成兼容错误
      console.log('WebSocket 版本错误')
      socket.end()
    } else {
      // 校验Sec-WebSocket-Key,完成连接
      /* 
        协议中规定的校验用GUID,可参考如下链接:
        https://tools.ietf.org/html/rfc6455#section-5.5.2
        https://stackoverflow.com/questions/13456017/what-does-258eafa5-e914-47da-95ca-c5ab0dc85b11-means-in-websocket-protocol
      */
      const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
      const key = headers['sec-websocket-key']
      const hash = crypto.createHash('sha1')  // 创建一个签名算法为sha1的哈希对象

      hash.update(`${key}${GUID}`)  // 将key和GUID连接后,更新到hash
      const result = hash.digest('base64') // 生成base64字符串
      const header = [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        `Sec-WebSocket-Accept: ${result}`,
        '\r\n'
      ].join('\r\n'); // 生成供前端校验用的请求头

      socket.write(header)  // 返回HTTP头,告知客户端校验结果,HTTP状态码101表示切换协议:https://httpstatuses.com/101。
      // 若客户端校验结果正确,在控制台的 Network 模块可以看到HTTP请求的状态码变为 101 Switching Protocols,同时客户端的ws.onopen事件被触发。

      // 将建立连接的客户端推到数组中
      list.push(socket)
      console.log(list.length)

      // 7. 建立连接后,通过data事件接收客户端的数据并处理
      socket.on('data', (buffer) => {
        const data = decodeWsFrame(buffer)

        // opcode为8,表示客户端发起了断开连接
        if (data.opcode === 8) {
          socket.end()  // 与客户端断开连接
        } else {
          // 接收到客户端数据时的处理,此处默认为返回接收到的数据。
          // 更新那些客户端还在
          list = list.filter(item => !item.destroyed)
          list.forEach(item => {
            item.write(encodeWsFrame({ payloadData: `服务端接收到的消息为:${data.payloadData ? data.payloadData.toString() : ''}` }))
          })
        }
      })
    }
  })
})

server.listen(8002, () => {
  console.log('Runing at: ws://localhost:8002')
})

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>
    <input type="text" id="input" value="test message"><br />
    <input type="button" value="发送消息" id="send"><br />
    <h3>接收到的消息:</h3>
    <input type="button" value="关闭聊天" id="close"><br />
    <script>
        const ws = new WebSocket('ws://localhost:8002/')

        // 连接打开的事件,连接开始后,可以在控制台的Network模块看到,该连接会一直保留在pending状态,直到连接断开。
        ws.onopen = function () {
            console.log('连接已建立')
        }

        // 接收消息的事件
        ws.onmessage = function (response) {
            console.log(response)
            document.querySelector('h3').insertAdjacentHTML('afterend', `<p>${response.data}<p>`)
        }

        // 连接正常关闭的事件
        ws.onclose = function () {
            console.log('连接已关闭')
            document.querySelector('p').innerHTML = '连接已关闭'
        }

        // 连接出错的事件
        ws.onerror = function () {
            console.log('连接出错')
        }
    </script>
    <script>
        document.querySelector('#send').addEventListener('click', function () {
            ws.send(document.querySelector('#input').value)
        })

        document.querySelector('#close').addEventListener('click', function () {
            ws.close()
        })
    </script>
</body>

</html>

参考

https://github.com/chencl1986/Blog/issues/51