Skip to main content

WebSocket 原理、使用、封装

原本只是想封装下 WebSocket 的心跳检测,后来越查越多的知识点,干脆一篇文章尽量简单易懂地整理下。

Socket

Socket 概念

Socket (套接字)是通信的基石,是支持 TCP/IP 协议的网络通信的基本操作单元。它是网络通信过程中端点的抽象表示, 包含进行网络通信必须的五种信息:

  • 连接使用的协议
  • 本地主机的 IP 地址
  • 本地进程的协议端口
  • 远地主机的 IP 地址
  • 远地进程的协议端口

应用层通过传输层进行数据通信时,TCP 会遇到同时为多个应用程序进程提供并发服务的问题,多个 TCP 连接或多个应用程序进程可能需要通过同一个 TCP 协议端口传输数据。

为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与 TCP/IP 协议交互提供了套接字(Socket)接口。

应用层可以和传输层通过 Socket 接口,区分来自不同应用程序进程或网络连接的通信,实现数据传输的并发服务。

建立 Socket 连接

建立 Socket 连接至少需要一对套接字,其中一个运行于客户端,称为 ClientSocket ,另一个运行于服务器端,称为 ServerSocket。

套接字之间的连接过程分为三个步骤:

  1. 服务器监听:服务器端套接字并不定位具体的客户端套接字,而是处于等待连接的状态,实时监控网络状态,等待客户端的连接请求。
  2. 客户端请求:指客户端的套接字提出连接请求,要连接的目标是服务器端的套接字。为此,客户端指出服务器端套接字的地址和端口号,然后就向服务器端套接字提出连接请求。
  3. 连接确认:当服务器端套接字监听到或者说接收到客户端套接字的连接请求时,就响应客户端套接字的请求,建立一个新的线程,把服务器端套接字的描述发给客户端,一旦客户端确认了此描述,双方就正式建立连接。而服务器端套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。

WebSocket

WebSocket protocol 是 HTML5 一种新的协议。目前除了 IE 浏览器,其他浏览器都基本支持。它实现了浏览器与服务器全双工通信(full-duplex)。一开始的握手需要借助 HTTP 请求完成。

他的目的是,即时通讯,替代轮询,使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。网站上的即时通讯是很常见的,比如网页的 QQ,聊天系统等。

WebSocket 与 HTTP 的关系

WebSocket 同 HTTP 一样也是应用层的协议,但它是一种在单个 TCP 连接上进行全双工通信的协议,是建立在 TCP 之上的。

在 WebSocket API 中,浏览器和服务器只需要完成一次握手, 两者之间就直接可以创建持久性的连接,并进行双向数据传输。在建立握手时,数据是通过 HTTP 传输的。但是建立之后,在真正传输时候是不需要 HTTP 协议的。

握手过程:

  1. 浏览器、服务器建立 TCP 连接,三次握手。这是通信的基础,传输控制层,若失败后续都不执行。
  2. TCP 连接成功后,浏览器通过 HTTP 协议向服务器传送 WebSocket 支持的版本号等信息。(开始前的 HTTP 握手)
  3. 服务器收到客户端的握手请求后,同样采用 HTTP 协议回馈数据。
  4. 当收到了连接成功的消息后,通过 TCP 通道进行传输通信。

Websocket 默认使用请求协议为 :ws://,默认端口 80。对 TLS 加密请求协议为 :wss://,端口 443。

WebSocket 与 Socket 的关系

Socket 其实并不是一个协议,而是为了方便使用 TCP 或 UDP 而抽象出来的一层,是位于应用层和传输控制层之间的一组接口

在设计模式中,Socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口后面,对用户来说,一组简单的接口就是全部,让 Socket 去组织数据,以符合指定的协议。

当两台主机通信时,必须通过 Socket 连接,Socket 则利用 TCP/IP 协议建立 TCP 连接。TCP 连接则更依靠于底层的 IP 协议,IP 协议的连接则依赖于链路层等更低层次。

短连接与长连接

短连接指通信双方有数据交互时,就建立一个 TCP 连接,数据发送完成之后,则断开此连接。在 HTTP1.0 中,每次打开一个网页,请求若干 CSS、JS 等一系列资源,基本就要建立几个甚至几十个 TCP 连接,浪费很多网络资源。

所谓长连接,指的是 TCP 连接,而不是 HTTP 连接。多个 HTTP 请求共用一个 TCP 连接,这样可以减少多次临近 HTTP 请求导致 TCP 建立关闭所产生的时间消耗。

现在基本用的HTTP1.1协议,HTTP1.1 默认使用长连接。connection: keep-alive表明是长连接,connection: close表明服务器关闭 TCP 连接。

与 connection 对应的一个字段是 Keep-Alive,他的格式是 Keep-Alive: timeout=60, max=100, timeout 是两次 http 请求保持的时间(s), , max 是这个 tcp 连接最多为几个 http 请求重用。

因此,通常需要双方发送心跳包维持 WebSocket 的连接。后面会详细讲心跳的内容。

Websocket 事件

WebSocket API 是纯事件驱动,通过监听事件可以处理到来的数据和改变的链接状态。

WebSocket 编程遵循一个异步编程模型,只需要对 WebSocket 对象增加回调函数就可以监听事件。也可以使用 addEventListener()方法来监听。

onopen

一旦服务端响应 WebSocket 连接请求,就会触发 open 事件。

ws.onopen = function (e) {
console.log('Connection open...')
}

open 事件触发的时候,意味着协议握手结束,WebSocket 已经准备好收发数据。如果你的应用收到 open 事件,就可以确定服务端已经处理了建立连接的请求,且同意和你的应用通信。

onmessage

当消息被接受会触发消息事件,会触发 message 事件。

ws.onmessage = function(e) {
console.log("String message received", e, e.data);
}
}

除了文本消息,WebSocket 消息机制还能处理二进制数据,有 Blob 和 ArrayBuffer 两种类型,在读取到数据之前需要决定好数据的类型。

ws.binaryType = 'blob'
ws.onmessage = function (e) {
if (e.data instanceof Blob) {
console.log('Blob message received', e.data)
var blob = new Blob(e.data)
}
}
ws.binaryType = 'arraybuffer'
ws.onmessage = function (e) {
if (e.data instanceof ArrayBuffer) {
console.log('ArrayBuffer Message Received', +e.data) // e.data即ArrayBuffer类型
var a = new Uint8Array(e.data)
}
}

onerror

如果发生意外的失败会触发 error 事件,错误会导致连接关闭。在关闭事件中也许会告诉你错误的原因。而对错误事件的处理比较适合做重连的逻辑。

ws.onerror = function (e) {
console.log('WebSocket Error: ', e)
}

onclose

当连接关闭的时候会触发 close 事件,连接关闭之后,服务端和客户端就不能再收发消息。 客户端可以主动调用 close 方法断开与服务端的链接来触发该事件。

ws.onclose = function (e) {
console.log('Connection closed', e)
}

WebSocket 方法

send

一旦在服务端和客户端建立了全双工的双向连接,可以使用 send 方法去发送消息。

只有在 open 之后才可以 send 数据,当连接关闭或获取不到的时候 send 会抛出异常。

if (ws.readyState === WebSocket.OPEN) {
//open的时候即可发送
ws.send(data)
} else {
// Do something else in this case.
}

close

使用 close 方法来关闭连接。

如果连接以及关闭,这方法将什么也不做。调用 close 方法只后,将不能发送数据。

搭建 WebSocket 服务

安装 ws 模块

npm i ws

使用 NodeJs 编写服务端代码

const WebSocket = require('ws').Server
const port = 8000

// 创建服务器
const server = new WebSocket({ port }, () => {
console.log('websocket 服务开启')
})

// 建立连接
server.on('connection', handleConnection)

function handleConnection(ws) {
console.log('websocket 服务连接')
ws.on('message', handleMessage)
ws.on('close', handleClose)
ws.on('error', handleError)
}

// 注意:因为这里用到this的指向,因此用普通的函数
function handleMessage(data) {
const { mode, msg } = JSON.parse(data)
switch (mode) {
case 'MESSAGE':
console.log('---User message---')
this.send(JSON.stringify(JSON.parse(data)))
break
case 'HEART_BEAT':
console.log('---HeartBeat message---')
this.send(JSON.stringify(JSON.parse(data)))
break
default:
break
}
}

function handleClose(e) {
console.log('websocket 服务关闭', e)
this.send(
JSON.stringify({
mode: 'MESSAGE',
msg: 'websocket 服务关闭'
})
)
}

function handleError(e) {
console.log('websocket 服务错误', e)
}

封装自己的 WebSocket 类

const WS_MODE = {
MESSAGE: 'MESSAGE', // 普通消息
HEART_BEAT: 'HEART_BEAT' // 心跳
}

class MyWebSocket extends WebSocket {
constructor(url) {
super(url)
this.wsUrl = url

this.init()
}

static create(url) {
return new MyWebSocket(url)
}

init() {
this.bindEvent()
}

bindEvent() {
this.addEventListener('open', this.handleOpen, false)
this.addEventListener('close', this.handleClose, false)
this.addEventListener('error', this.handleError, false)
this.addEventListener('message', this.handleMessage, false)
}

handleOpen() {
console.log('---Client is connected---')
}

handleClose() {
console.log('---Client is closed---')
}

handleError(e) {
console.log('---Client is error---', e)
}

handleMessage(data) {
const { mode, msg } = this.receiveMsg(data)

switch (mode) {
case WS_MODE.MESSAGE:
console.log('---接受服务端消息---', msg)
break
case WS_MODE.HEART_BEAT:
console.log('---HEART_BEAT---')
default:
break
}
}

receiveMsg({ data }) {
return JSON.parse(data)
}

sendMsg(data) {
if (this.readyState === WebSocket.OPEN) {
this.send(JSON.stringify(data))
} else {
console.log('websocket 未连接,请稍后再试!')
}
}
}

export default MyWebSocket

心跳检测

在使用原生 Websocket 的时候,如果设备网络断开,不会立刻触发 Websocket 的任何事件,前端也就无法得知当前连接是否已经断开。这个时候如果调用 Websocket.send 方法,浏览器才会发现链接断开了,便会立刻或者一定时间后(不同浏览器或者浏览器版本可能表现不同)触发 onclose 函数。

后端 Websocket 服务也可能出现异常,造成连接断开,这时前端也并没有收到断开通知,因此需要前端定时发送心跳消息 ping,后端收到 ping 类型的消息,立马返回消息,告知前端连接正常。如果一定时间没收到消息,就说明连接不正常,前端便会执行重连。

心跳检测的目的是为了保持 WebSocket 的长连接,避免因长时间没有数据传输而导致的连接断开,并且在发现断开的情况下及时重连。

上面的代码提到了心跳检测,但是还未实现。

ping pong

其实协议规定,WebSocket 是有自己的心跳消息的!连接两端,一端发送了 Ping 帧, 那么接收方必须尽快的回复 Pong 帧数据。

chrome 是实现了 ping/pong 的,只要服务端发送了 ping,那么会立即收到一个 pong。

// ...
let connections = new Set()

server.on('connection', handleConnection)

function handleConnection(ws) {
console.log('websocket 服务连接')
// ...
connections.add(ws)
ws.on('pong', () => {
console.log('pong received')
})
ws.ping()
}

setInterval(() => {
connections.forEach(socket => {
socket.ping()
console.log('ping~~')
})
}, 5000)

// ...

然后就可以看到服务端打印出来

对于 web 应用来说,服务端直接 ping 就好,然后客户端不需要设置什么东西,也完全没有必要构造自己的心跳。

但是,这东西并不是协议强制的

所以当使用的库没有实现 ping/pong 方法时,可以自己实现心跳检测。

封装心跳检测

下面基于 MyWebSocket 类进一步封装,并且主动断线重连:

  • 添加心跳检测 heartBeatTimer 定时器,定时发送心跳消息
  • 添加重连 reconnectingTimer 定时器
  • 需要在重连时调用 wsReConnect() 重新生成实例,可以用发布订阅模式在外部处理,这里选择保存到实例在内部执行
  • open 时开启心跳 startHeartBeat()
  • close 和 error 时断开重连 reconnect()
const HEART_BEAT_INTERVAL = 30000 // 心跳检测间隔
const RECONNECT_TIMEOUT = 3000 // 3s后重连

const WS_MODE = {
MESSAGE: 'MESSAGE', // 普通消息
HEART_BEAT: 'HEART_BEAT' // 心跳
}

class MyWebSocket extends WebSocket {
constructor(url, wsReConnect) {
super(url)
this.wsUrl = url
this.heartBeatTimer = null
this.reconnectingTimer = null
this.isReConnecting = false // 判断是否已经开启了重连
this.wsReConnect = wsReConnect

this.init()
}

static create(url, wsReConnect) {
return new MyWebSocket(url, wsReConnect)
}

init() {
this.bindEvent()
}

bindEvent() {
this.addEventListener('open', this.handleOpen, false)
this.addEventListener('close', this.handleClose, false)
this.addEventListener('error', this.handleError, false)
this.addEventListener('message', this.handleMessage, false)
}

handleOpen() {
console.log('---Client is connected---')
// 开启心跳检测
this.startHeartBeat()
}

handleClose() {
console.log('---Client is closed---')

// 取消心跳检测定时器和重新连接定时器
if (this.heartBeatTimer) {
clearInterval(this.heartBeatTimer)
this.heartBeatTimer = null
}
if (this.reconnectingTimer) {
clearTimeout(this.reconnectingTimer)
this.reconnectingTimer = null
}

// 重连
this.reconnect()
}

handleError(e) {
console.log('---Client is error---', e)

// 重连
this.reconnect()
}

handleMessage(data) {
const { mode, msg } = this.receiveMsg(data)

switch (mode) {
case WS_MODE.MESSAGE:
console.log('---接受服务端消息---', msg)
break
case WS_MODE.HEART_BEAT:
console.log('---HEART_BEAT---')
default:
break
}
}

receiveMsg({ data }) {
return JSON.parse(data)
}

sendMsg(data) {
if (this.readyState === 1) {
this.send(JSON.stringify(data))
} else {
console.log('websocket 未连接,请稍后再试!')
}
}

startHeartBeat() {
this.heartBeatTimer = setInterval(() => {
if (this.readyState === WebSocket.OPEN) {
this.sendMsg({
mode: WS_MODE.HEART_BEAT,
msg: WS_MODE.HEART_BEAT
})
}
}, HEART_BEAT_INTERVAL)
}

reconnect() {
// 因为 handleError 和 handleClose 都会触发重连
// 当连接错误时会触发两次重连,所以这要判断只执行一次
if (this.isReConnecting) return
this.isReConnecting = true
// 没连接上会一直重连,用定时器设置延迟避免请求过多
this.reconnectingTimer = setTimeout(() => {
this.wsReConnect()
this.isReConnecting = false
}, RECONNECT_TIMEOUT)
}
}

export default MyWebSocket

总结实现过程

心跳机制的实现,在客户端连接成功的回调中即开启心跳。心跳处理函数内部使用定时器延时触发向服务端发送消息的方法,待服务器将消息返回证明是连线成功状态下,继续调用心跳检测方法。

如果客户端给服务端发送心跳消息,在定义的超时时间后客户端没有收到回复,则说明和服务端断线了,此时会触发到客户端连接关闭的回调函数,在此回调中发起重新连接 websocket,如果连接失败继续会触发客户端连接关闭的回调函数继续发起重新连接(如此循环)。

等断线重新连接起来时,在客户端连接成功的回调中又开始了心跳检测。

使用 MyWebSocket

<html lang="en">
<body>
<div>
<button id="connect">连接</button>
<button disabled id="sendMessage">发送</button>
<button disabled id="close">关闭</button>
</div>
</body>
</html>
<script type="module">
import MyWebsocket from './myWebSocket.js'

const btnConnect = document.getElementById('connect')
const btnSendMessage = document.getElementById('sendMessage')
const btnClose = document.getElementById('close')
let ws = null

function wsConnect() {
ws = MyWebSocket.create('ws://localhost:8000', wsReConnect)
}

function wsReConnect() {
if (!ws) {
return wsConnect()
}

if (ws && ws.reconnectingTimer) {
clearTimeout(ws.reconnectingTimer)
ws.reconnectingTimer = null
wsConnect()
}
}

// 点击连接按钮 连接websocket服务器
btnConnect.addEventListener('click', wsConnect)
// 点击发送按钮 向服务端传送数据
btnSendMessage.addEventListener('click', e => {
ws.sendMsg({
mode: 'MESSAGE',
msg: 'Hello World'
})
})
// 点击关闭按钮 断开连接
btnClose.addEventListener('click', e => {
if (ws) {
ws.close()
ws = null
}
})
</script>

Socket.io 库

Socket.io 是众多 websocket 库中的一种,它并不像其它库那样简单地实现了一下 websocket,而是在 websocket 外面包裹了厚厚的一层。普通的 websocket(例如 ws 库)只需要服务端就够了,Socket.io 自定义了一种基于 websocket 的协议,所以 Socket.io 的服务端和客户端必须配套。

简言之,如果服务端使用 Socket.io,那么客户端就没得选了,必然也用 Socket.io 的客户端。

不同点WebSocketSocket.io
1是通过 TCP 连接建立的协议是使用 WebSocket 的库
2在 TCP 连接上提供全双工通信在浏览器和服务器之间提供基于事件的通信
3不支持代理和负载平衡器可以在代理和负载平衡器存在的情况下建立连接
4不支持广播支持广播
5没有反馈机制支持反馈机制

文档地址:https://socket.io/zh-CN/ API 文档地址:https://socket.io/zh-CN/docs/v4/server-api/#socket

Socket.io 主要功能

  • 它有助于一次广播到多个套接字,并透明地处理连接。
  • 它可在所有平台,服务器或设备上工作,以确保其平等性,可靠性和速度。
  • 如果需要,它将自动将需求升级到 WebSocket。
  • 它是在其他协议之上的自定义实时传输协议实现。
  • 它要求同时使用客户端库和服务器端库。
  • IO 处理基于工作的事件。有一些保留的事件可以使用服务器端的套接字来访问,例如连接,消息,断开连接,Ping 和重新连接。
  • 有一些基于客户端的保留事件,例如连接,连接错误,连接超时和重新连接等。