前后端 WebSocket 连接
阮一峰大佬 WebSocket 技术博客
H5 中提供的 WebSocket 协议是基于 TCP 的全双工传输协议。它属于应用层协议,并复用 HTTP 的握手通道。它只需要一次握手就可以创建持久性的连接。
那么什么是全双工呢?
全双工是计算机网络中的一个网络传输方式:数据在线路中的传送方式。一般来说,传送方式有三种方式:单工、半双工、全双工。
全双工:允许数据同时在两个方向上进行传输。这就需要通信的两端设备都需要具备有发送数据和发送数据的能力。
WebSocket 时代之前
在 WebSocket 以前,我们想要实现类似实时聊天这样的功能一般都是使用 AJAX 轮询(轮询、长轮询)实现,
也就是浏览器每隔一段时间主动向服务器发送 HTTP 请求。
轮询:客户端定期向服务器发送请求
长轮询:在客户端发送请求后,保持连接打开,等待新数据响应后再关闭连接。
由于需要每隔一段时间请求服务端,这就带来一定的缺点:只能由客户端发送请求才返回最新的内容给客户端。在某些场景下(实时聊天应用、实时协作应用、实时数据推送、多人在线游戏、在线客服和客户支持等),这就导致了消息的实时性不好,在应用使用人数过少时产生没有必要的网络开销。
WebSocket
在有这些前提了解以后,我们来看看 WebSocket,它出现的原因就是解决客户端和服务端通信的问题。它可以支持服务端主动向客户端发送消息,这样就大大减少了网络开销,同时还保证了一定的消息实时性。
一般来说,WS 连接流程为:客户端在连接前向服务端发送一个常规的 GET 请求:请求将连接方式改为 WebSocket,这个时候请求状态码将为 101 Switching Protocols
,请求头中将会有 Upgrade: websocket
字段,表示将连接方式改为 WebSocket。如果服务器响应,那么将会在响应头中带有 Connection
且值为 Upgrade
,响应头中还有 Upgrade: websocket
字段,这时候两端就建立起了 ws 连接通道了。
简单实例
接下来我们就简单上手一下 WebSocket 吧。
前端:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0,maximum-scale=1.0,minimum-scale=1.0">
<title>独往独来银粟地,一行一步玉沙声</title>
</head>
<body>
<div>前后端 Websocket 连接交互</div>
<script>
const ws = new WebSocket('ws://localhost:3000');
ws.addEventListener('open', function (e) {
console.log('ws 已经连接', e);
ws.send('Hello server');
});
ws.addEventListener('error', function (error) {
console.log('ws 异常', error);
});
ws.addEventListener('message', function (e) {
console.log('Message from server ', e.data);
});
ws.addEventListener('close', function (e) {
console.log('ws 已经关闭');
});
</script>
</body>
</html>
后端:
后端我就采用 node 来实现了
# 初始化
yarn init -y
# 引入依赖
yarn add ws
app.js
// 引入 WebSocket模块
const WebSocket = require('ws');
// 创建 WebSocket 服务器,监听端口3000
const server = new WebSocket.Server({port: 3000});
// 当有客户端连接时触发
server.on('connection', (socket) => {
console.log('客户端已连接...');
// 处理收到的消息
socket.on('message', (data) => {
console.log(`收到客户端发送的消息: ${data}`);
});
socket.send('hello client!');
});
console.log("ws 服务示例已经启动 ws://localhost:3000")
效果如下:
WebSocket 心跳机制
RFC
看完简单示例,我们就来说说目前 WebSocket 存在的缺点以及使用什么方式来解决。
- 兼容性
这是因为 WS 协议不是所有的浏览器都支持,所以在开发旧版浏览器就需要考虑兼容性问题了。兼容性可使用 node 可使用 socket.io
包,如果用户使用旧版浏览器,那么它就会将 WS 连接转为轮询方式。
- 连接稳定性
看完上面的内容,相信大家都大概知道 WS 是一个保证客户端和服务端长连接的协议。既然是长连接那么就涉及到一个问题:如果在通信的时候,一断突然掉线了,那另外一方肯定会马上知道的,但是如果链路上没有数据在传输,那么双发就不知道对方是否在线了。想象一下:小明和他女朋友晚上在打电话,小明给她讲笑话,讲完如果小明他女朋友笑了,那么小明知道他成功了,自己的这个笑话是好笑的,但是如果小明讲完以后他女朋友也没有什么回应,那么小明就不知道他是不是女朋友是睡着了。所以,为了防止这种情况发生我们就需要一种机制,能让双方都知道对方还在线。那引出了我们的心跳机制了。
需要知道一下 WebSocket 中必不可少的心跳机制了。那心跳机制是是什么呢?
其实它是 Websocket 协议中的一种保活机制,主要用于维持客户端和服务端两端的长连接,保证两端在连接过程中是否有一端因为意外的错误或者防止长时间不通讯的机制。
通俗一点就是,这种机制可以让客户端和服务端保证双方都在线。比如:客户端发送每隔一段时间发送心跳包通知服务端我还在线,服务端收到这个心跳包以后也发送一个心跳包给客户端同时我也在线。这就好比小明的笑话实在太好笑了,他每讲完一句话,他女朋友就笑出声了,这样小明也知道女朋友没有睡着。
前端实现心跳机制主要有两种方式:
- 使用
setTimeout
或setInterval
定时器方法定时发送心跳包–没有实际数据,仅用于维持连接状态 - 前端监听到
WebSocket().close
事件后重新创建 WebSocket 连接
一般来说,第一种方式因为需要定时发送心跳包,就会消耗掉服务器资源。而第二种方式虽然减轻了服务器的负担,但是在重连时很有可能会丢失一部分数据。
这里就重点说一下第一种方式的实现过程吧:
1、客户端和服务端建立 WS 连接
2、客户端向服务端发送心跳包,服务端接收并返回一个表示接收到心跳包的响应
3、当服务端长时间没有接收到心跳包时,服务端将向客户端发送一个关闭连接的请求
4、服务端定时向客户端发送一个心跳包,客户端接收并返回一个表示接收到心跳包的响应
5、当客户端没有接收到服务器发送的心跳包时,客户端会发起重新连接 WS
客户端要实现一个封装好的 socket 类应该具备以下功能:
心跳检测
1、定时发送心跳包
2、客户端发送 ping 的同时需要检测服务端是否响应(设置一个延时器,检测是否有返回 pong,如果没有返回那就开启重连策略 )
断线重连
1、客户端监听发生错误或者掉线就开启重连策略
2、设置重连锁,防止发送多个重连请求
3、开启重连次数限制,超过限制次数就停止重连
其中,这两个功能都需要使用到计时器,所以我们在运行过程中一定一定要记得消除定时器,否则将有可能导致内存泄漏问题。
代码如下,下面的封装是我看了一下网上大多数的案例再结合自己的需求整合出来的,如果对这部分代码还有疑惑或者优化的建议还烦请大家赐教!大家一起讨论才可以一起进步🥰🥰🥰
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=0,maximum-scale=1.0,minimum-scale=1.0">
<title>独往独来银粟地,一行一步玉沙声</title>
</head>
<body>
<div>前后端 Websocket 连接交互</div>
<button id="createBtn">创建连接</button>
<button id="closeBtn">断开连接</button>
<button id="sendBtn">发送消息</button>
<script>
const createBtn = document.getElementById('createBtn');
const closeBtn = document.getElementById('closeBtn');
const sendBtn = document.getElementById('sendBtn');
let ws;
const WsOption = {
url: 'ws://localhost:3000',
timeout: 5,
isHeartBeat: true,
isReconnect: true,
};
/**
* 创建 ws 连接
*/
createBtn.addEventListener('click', function () {
ws = new Socket(WsOption);
ws.connect();
});
/**
* 关闭连接按钮
*/
closeBtn.addEventListener('click', function () {
if (ws && ws.readyState === ws.OPEN) ws.clientCloseHandler();
});
/**
* 发送消息按钮
*/
sendBtn.addEventListener('click', function () {
ws.sendHandler({type: 'info', date: new Date(), info: 'Hello Server'});
});
const WS_STATUS = {
OPEN: 'open',
CLOSE: 'close',
READY: 'ready',
ERROR: 'error',
RECONNECT: 'reconnect',
};
export default class Socket {
/**
* @param ws ws 实例
* @param name ws id
* @param status ws 状态
* @param timer 重连计时器
* @param url ws 连接地址
* @param pingInterval 心跳计时器
* @param isHeartBeat 是否开启心跳检测
* @param timeout 心跳频率
* @param isReconnect 是否开启断开重连
* @param reconnectNum 最大重连次数
* @param lockReconnect 重连锁
* @param pingTimeout 心跳返回检查时间计时
* @param pingTimer 心跳返回检查计时器
*/
constructor(option) {
this.ws = null;
this.status = null;
this.timer = null;
this.pingInterval = null;
this.pingTimer = null;
this.pingTimeout = (3 * 1000) | option.pingTimeout;
this.url = option.url;
this.name = option.name || 'default';
this.reconnectNum = option.reconnectNum || 5;
this.lockReconnect = true;
this.reconnectTimeout = (option.reconnectTimeout * 1000) | (5 * 1000);
this.timeout = option.timeout * 1000 || 2 * 1000;
this.isHeartBeat = option.isHeartBeat || false;
this.isReconnect = option.isReconnect || false;
}
/**
* 入口
*/
connect() {
if (!this.ws) {
this.ws = new WebSocket(this.url);
this.status = WS_STATUS.READY;
console.log(`[WS STATUS] ${this.status}`);
// 连接
this.ws.onopen = (e) => {
this.openHandler(e);
};
// 收到信息
this.ws.onmessage = (e) => {
if (JSON.parse(e.data).type === 'pong') {
clearTimeout(this.pingTimer)
}
this.receiveHandler(JSON.parse(e.data));
};
// 关闭
this.ws.onclose = (e) => {
this.serverCloseHandler(e);
};
// 意外错误
this.ws.onerror = (e) => {
this.errorHandler(e);
};
}
}
/**
* ws 连接处理
* @param {*} e
*/
openHandler(e) {
this.status = WS_STATUS.OPEN;
console.log(`[WS CONNECT] ${this.url} connect`);
if (this.pingInterval) clearTimeout(this.pingInterval);
this.sendHandler({
type: 'init',
date: new Date(),
data: `i am ${this.name}`,
});
if (this.isHeartBeat) {
this.startHeartCheck();
}
}
/**
* 收到服务端信息处理
* @param {*} data
*/
receiveHandler(data) {
console.log(`[WS RECEIVE] receive: ${JSON.stringify(data)}`);
}
/**
* 服务端关闭 ws 连接
*/
serverCloseHandler() {
if (this.pingInterval) clearInterval(this.pingInterval);
this.status = WS_STATUS.CLOSE;
console.log(`[WS STATUS] ${this.status}`);
}
/**
* ws 错误处理
* @param {*} e
*/
errorHandler(e) {
this.status = WS_STATUS.ERROR;
console.log(`[WS STATUS] ${this.status}`);
if (this.pingInterval) clearInterval(this.pingInterval);
if (this.isReconnect) {
this.status = WS_STATUS.RECONNECT;
console.log(`[WS STATUS] ${this.status}`);
if (this.isReconnect) {
this.reconnectHandler();
}
}
}
/**
* 客户端发送消息处理
* @param {*} data
*/
sendHandler(data) {
console.log(`[SEND MSG] ${JSON.stringify(data)}`);
if (this.pingInterval) clearInterval(this.pingInterval);
this.ws.send(JSON.stringify(data));
if (this.isHeartBeat) {
this.startHeartCheck();
}
}
/**
* 重连
*/
reconnectHandler() {
console.log('[WS ERROR] reconnection mechanism enabled!');
if (this.pingInterval) clearInterval(this.pingInterval);
// 重连锁
if (this.lockReconnect) {
this.lockReconnect = false;
// 重连次数限制
if (this.reconnectNum === 0) {
console.log('[WS ERROR] server is offline!!!');
this.lockReconnect = true;
return;
}
setTimeout(() => {
this.ws = null;
this.connect();
console.log(`拉取请求还剩下 ${this.reconnectNum} 次`);
this.reconnectNum--;
this.lockReconnect = true;
}, this.timeout);
}
}
/**
* 心跳检测
*/
startHeartCheck() {
this.pingInterval = setInterval(() => {
if (
this.ws.readyState === WebSocket.OPEN &&
this.status === 'open'
) {
const pingInfo = {type: 'ping', date: new Date()};
this.sendHandler(pingInfo);
}
this.pingTimer = setTimeout(() => {
// 未收到 pong 消息,尝试重连...
this.reconnectHandler();
}, this.pingTimeout);
}, this.timeout);
}
/**
* 客户端关闭 ws 连接
*/
clientCloseHandler() {
if (this.pingInterval) clearInterval(this.pingInterval);
this.ws.close();
}
}
</script>
</body>
</html>
效果如下: