WebSocket协议概述(原生)

1,512 阅读12分钟

一、产生背景

在Web开发中,我们经常和HTTP协议打交道,它是一个请求-响应的通信方式。但是除了HTTP 2.0版本以外,服务器端是无法主动发送消息的,它只支持单向数据通信的方式。

如果要去实现一个以双向通信方式的简易聊天室,浏览器需要以一种轮询的方式去不停地发送AJAX请求更新数据,这样做会造成资源高消耗和低性能。于是WebSocket协议就这么诞生了,虽然它诞生的时间比HTTP协议晚,但它实现的功能比HTTP更加成熟和可靠。

WebSocket最初在HTML5规范中被引用为TCPConnection,作为基于TCP的套接字API的占位符。2008年6月,Michael Carter进行了一系列讨论,最终形成了称为WebSocket的协议。

二、特点

WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。

补充一个小知识:TCP上的连接通信分为半双工通信全双工通信单向通信三种方式。

目前大部分浏览器均已支持WebSocket,可以放心使用。

它主要特点如下:

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小,而HTTP协议每次请求需要携带完整的头部。
  • 更强的实时性。支持全双工通信,低消耗,高性能。
  • 可以保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
  • 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
  • 支持跨域。(这个太强了!)
  • 与 HTTP 协议有着良好的兼容性。默认端口也是80(ws)和443(wss),并且握手阶段采用 HTTP 协议中的Upgrade头来升级协议,支持HTTP代理和中介。
  • 可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
  • 更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。

思考:从上面的特点可以看出,同样是应用层上的协议,WebSocket协议要比HTTP协议要优秀很多。所以,数据传输都用WebSocket协议它不香吗?(没得标准答案,我自己也要思考。我没有歧视HTTP协议的意思啊...😝)

三、客户端API介绍

WebSocket提供了用于创建和管理 WebSocket 连接以及可以通过该连接发送和接收数据的 API。

我们可以通过创建一个实例对象ws建立与本地服务器(localhost:8080)的WebSocket连接:

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

下面的案例和知识都会围绕这个实例对象来展开讲述。

3.1 常量

下表是WebSocket构造函数的原型中存在的一些常量介绍:

Constant Value
WebSocket.CONNECTING(正在连接中) 0
WebSocket.OPEN(连接已打开,可以通信) 1
WebSocket.CLOSING (连接正在关闭) 2
WebSocket.CLOSED (表示连接已经关闭,或者打开连接失败。) 3

WebSocketXMLHttpRequest对象很相似,它们都可以通过readyState属性来判断当前连接状态(XMLHttpRequest对象有5个状态)。

if (ws.readyState === WebSocket.OPEN) {
	// 连接建立后做点啥...
}

3.2 常见属性

  • WebSocket.bufferedAmount:是一个只读属性,用于返回已经被send方法放入队列中但还没有被发送到网络中的数据的字节数。一旦队列中的所有数据被发送至网络,则该属性值将被重置为0。但是,若在发送过程中连接被关闭,则属性值不会重置为0。它可以用来判断二进制数据发送是否结束
  • WebSocket.onclose:用于指定连接关闭后的回调函数。
  • WebSocket.onerror:用于指定连接失败后的回调函数。
  • WebSocket.onmessage:用于指定当从服务器接受到信息时的回调函数。
  • WebSocket.onopen:用于指定连接成功后的回调函数。
  • WebSocket.readyState: 只读属性,表示当前的连接状态。

3.3 方法

  • WebSocket.send:发送数据。数据类型只能是utf-8编码的字符串和二进制数据(BlobArrayBuffer)这两种。
  • WebSocket.close:关闭连接。

这里补充一个小知识:Blob对象和ArrayBuffer对象的区别在于前者用于操作二进制文件,而后者用于操作内存(模拟内存)。如果大家对这个小知识感兴趣可以去看这篇文章

结合上述提及的WebSocket的属性和方法,我给出一个浏览器端简单使用WebSocket的例子:

demo.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="file" name="filename" />
    <button>关闭连接</button>

    <script>
      let ws = new WebSocket("ws://localhost:8080");
      let buffer = new ArrayBuffer(8);
      let file = document.querySelector('input[type="file"]');
      let btn = document.querySelector("button");
      let blob = null;

      // 1. 可以自己指定接受二进制数据类型
      ws.binaryType = "arraybuffer" || "blob";

      // 2. 可获取二进制数据占用内存长度(字节大小)
      console.log(buffer.byteLength); // 8 bytes

      // 3. 使用Blob读取二进制文件数据
      file.addEventListener("change", function(event) {
        blob = new Blob(this.files, { type: "mutipart/form-data" });
      });

      // 4. 点击按钮关闭连接
      btn.addEventListener("click", function() {
        ws.close();
      });

      // 5. 监听连接建立事件
      ws.addEventListener("open", function(event) {
        console.log("连接已建立");
        // 5.1 发送不同类型数据
        ws.send(buffer);
        ws.send(blob);
        ws.send("send message...");
        // 5.2 判断二进制数据是否完全发送
        if (ws.bufferedAmount === 0) {
          console.log("发送完毕!");
        } else {
          console.log("还没发送成功!");
        }
      });
      // 6. 监听接受信息事件
      ws.addEventListener("message", function({ data }) {
        // 6.1 可以对数据类型先进行判断,然后对数据进行分类处理
        if (typeof data === "string") {
          console.log("Received data string" + data);
        }
        if (data instanceof ArrayBuffer) {
          console.log("Received arraybuffer");
        }
        if (data instanceof Blob) {
          console.log("Received blob");
        }
      });

      // 7. 监听连接关闭事件
      ws.addEventListener("close", function(event) {
        console.log("连接关闭了...");
      });

      // 8. 监听错误事件
      ws.addEventListener("error", function(event) {
        console.log("连接出错了...");
      });
    </script>
  </body>
</html>

四、Node服务器应用WebSocket

上面已经提到:WebSocket协议是基于底层TCP协议的,而且客户端需要借助HTTP发起一个请求,服务器通过请求头中的Upgrade字段来完成协议升级。所以接下来我会使用Node中的net模块来建立一个TCP服务器来实现这个过程。

客户端

    const ws = new WebSocket('ws://localhost:8080')
    
    ws.addEventListener('open', function() {
      console.log('连接已打开!')
    })

服务端

引入net模块和crypto模块(后面使用sha1来加密处理key需要crypto模块),然后建立一个TCP服务器,监听8080端口。当连接成功后(自己监听connection事件)会触发一个回调函数,该函数有一个参数为socket,它是Socket对象的实例,也是TCP套接字的抽象。

补充知识点:TCP套接字是客户端和服务端通信的基础,由ip+端口号组成。

const net = require('net')
const crypto = require('crypto')

net.createServer(socket => {
 // socket
}).listen(8080, () => {
  console.log('TCP服务器已经启动在8080')
})

然后监听socket的data事件,拿到buffer数据后转化为字符串。

socket.on('data', buffer => {
 console.log(buffer.toString())
})

我们启动一下服务器,然后打印一下HTTP头:

在这里插入图片描述

我们可以看到它和正常HTTP请求相比多了几个比较重要的头部字段,我在下方列了一个表详细地说明了它们的作用:

请求头字段 作用
Sec-WebSocekt-Extensions 拓展信息,可以忽略
Sec-WebSocket-Key 随机的字符串。这段字符串只有支持WebSocket协议的服务器才理解,这样可以判断服务器是否支持WebSocket协议,同时通过一些算法返回同样类型的字符串来回应。
Sec-WebSocket-Version 协议版本,目前还是13
Upgrade 请求升级的协议类型,这里为websocket

当然,还有一个Connection字段,表明此次连接时协议升级(Upgrade )。

服务器需要对这些字段进行判断,并且返回一些响应头,告诉客户端协议升级成功了,并且连接已经建立。所以,下一步,服务器就需要解析请求头,这里我定义了一个parseHeaders函数解析成一个对象:


function parseHeaders(headers) {
  console.log(headers)
  let obj = {}
  // 将每一行字符串以`\r\n`分隔,并去掉空行
  let arr = headers.split('\r\n').filter(line => line)
  // 去掉请求行
  arr.shift()
  arr.forEach(item => {
    let [name, value] = item.split(': ')
    obj[name.toLowerCase()] = value
  })
  return obj
}

let headers = parseHeaders(buffer.toString())

注意:由于客户端没有传递其他二进制数据过来,服务端拿到的是HTTP头部字段,因此可以转为字符串。但是如果有数据过来,就不可以直接转化字符串。

此时我们拿到的头长这样:

在这里插入图片描述
接下来,我们需要对请求头进行判断,然后返回符合要求的响应头。这一步最重要的就是需要生成一个Sec-websocket-Acceptkey,这个key必须是官方规定的key校验生成算法:

Sec-WebSocket-Key加上一个特殊字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,然后计算SHA-1摘要,之后进行BASE-64编码,将结果做为Sec-WebSocket-Accept头的值,返回给客户端。

这里使用node中的crypto模块处理。

 if (headers['upgrade'] !== 'websocket') {
      console.log('invalid upgrade value')
      socket.end()
    } else if (headers['sec-websocket-version'] !== '13') { 
      console.log('invalid version')
      socket.end()
    } else {
      // 校验算法 base64(sha1(key+uuid))
      const key = headers['sec-websocket-key']
      const uuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
      let hash = crypto.createHash('sha1')
      hash.update(key + uuid)
      let resKey = hash.digest('base64')
      // 返回一个响应头,完成协议升级
      socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\rSec-websocket-Accept: ${resKey}\r\n\r\n`)
    }

启动Node服务器测试一下:

在这里插入图片描述

发现连接成功了,然后打开控制台看看响应头:

在这里插入图片描述
我们可以看到,这个字段已经成功传过来了,而且它跨域的特点也能体现出来了。状态码也告诉浏览器,协议已经更新为WebSocket 了,可以进行数据交互了。这还只是客户端和服务器建立连接,服务器还需要做很多很多事情,如怎么处理二进制数据,如何缓存等等,超级复杂,自己去做这些事情简直就是找die🌚。所以,接下来我们肯定需要一个很优秀的库:socket.io

五、使用socket.io库

我们先来介绍这个库,这个库是对WebSocket的一个封装,实现了很多原始没有的功能,比如超时重传、断开后状态缓存、数据解析、兼容低版本浏览器、自动重连等功能,它十分强大而且使用很简单。下面教大家如何简单使用这个库:

本地安装

npm i socket.io -D

步骤

  1. 引入http模块,建立http服务
  2. 引入socket.io库,监听http服务,三次握手后建立Websocket连接
  3. 建立是否连接,设置连接成功后的回调函数,参数设为socket,参数是一个socket server对象
  4. 在回调函数中可以发送数据和接受数据
const http = require('http')
const io = require('socket.io')

// 1. 先建立http服务
let server = http.createServer().listen(8080)
// 2. 转化协议,将http请求转化成websocket
let ws = io.listen(server)
// 3. 建立websocket连接,可以通信,emit 发数据, on 监听数据
ws.on('connection', socket => {
  setInterval(() => {
    socket.emit('sayHi', 'hi,laocao')
  }, 1000)
  
  socket.on('meet', data => {
    console.log(data)
  })
})

在客户端(这里使用浏览器)也必须引入socket.io库

  <script src="http://localhost:8080/socket.io/socket.io.js"></script>

然后使用io对象(这是一个封装好的WebSocket对象)来发起连接,然后通过on方法接收数据,使用emit发送数据。

注意的是,两个方法第一个参数必须要和服务端的对应起来,我这里为了简单就发送了一个简单的字符串。

    let ws = io.connect('ws://localhost:8080')

    ws.on('sayHi', data => {
      console.log(data)
    })
    ws.emit('meet', 'hi,nice to meet you!')

我们打开服务器控制台看看:

在这里插入图片描述
果然收到了来自浏览器发送的数据,我们再看看浏览器是否收到服务器的数据:

在这里插入图片描述
它也不停地收到来自服务的数据,我设置了一个定时器,每隔一秒服务器就会向客户端发送数据。这个库是不是很简单呢,而且我个人很喜欢,强力推荐🌝!

有关更多特点,大家可以去看这个网址,API查看这个网址

六、总结

本文我简单介绍了WebSocket的产生背景、特点以及API的简单应用。我们可以看到,使用原生的WebSocket是很麻烦的,还要考虑很多东西,比如断开连接了如何重连,状态如何保持等问题等。在实际开发中我们还是得借助现在已经很成熟的库如socket.io,这样开发效率才会高。当然,为了更好去深入学习这些东西,还是得看原生的知识,这样才能走得更远。

我希望这个入门级的介绍对大家有帮助,当然最好的方式就是看文档,我也是找了很多资料进行了学习。当然,光学理论是没得用的,接下来我会找个聊天室的项目来练练手,之后也会总结一篇文章。如果本文章中存在错误,恳请大家可以帮忙纠正,感激不尽!😀

七、参考资料

【1】MDN:WebSocket对象

【2】阮一峰:WebSocket教程

【3】维基百科:WebSocket

【4】NPM:socket.io