前言

最近在写一个项目,有一个需求是用户之间进行匹配来打排位赛,为了与服务端及时更新用户匹配状态以及发送用户比赛结果等原因,我们采用了WebSocket这一技术来保证客户端和服务器之间创建持久连接。同时为了更好地服务于我们的项目就将WebSocket封装为一个类,也就是我们这篇文章的内容。

WebSocket简介

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。与 HTTP 协议不同的是,WebSocket 允许服务器和客户端在连接建立后能够在不需要重新请求的情况下,相互发送数据。即浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接, 并进行双向数据传输。这个特性使得 WebSocket 特别适合于需要实时数据交换的应用场景。

MyWebSocket封装

重要的注释代码都有,就懒得写封装的具体流程了,直接上代码()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import { urlEncode } from "./CommonUtils";
import { RankSocketMsgEnum } from "@/enum"
import { ErrorNotice, SuccessNotice } from "./Notification";
import router from "@/router"
import { IVictory } from "@/types/rank";

export interface IWebSocketTD {
event: string
}

export default class MyWebSocket {
private readonly reConnectMaxCount: number = 5; // 最大重连次数
private readonly url: string; // websocket接口url
private readonly token: string | null = null; // 用户token标识
private ws: WebSocket | null = null; // WebScoket实例对象
private reConnectTimer: number | null = null; // 重连时的定时器对象
private heartTimer: number | null = null; // 心跳检测的定时器对象
private reConnectCount: number = 1; // 当前重连次数
public flag: boolean = false; // 是否手动断开连接
private handleOpen: Function | null = null; // 开启连接回调
private handleError: Function | null = null; // 连接出错回调
private handleClose: Function | null = null; // 关闭连接回调
private handleMessage: Function | null = null; // 收到消息回调
private entryUrl: string | null = null; // 重连错误重定向url

constructor(url: string, token: string | null, entryUrl: string | null) {
this.url = url;
this.token = token;
this.entryUrl = entryUrl;
}

// 建立websocket连接
public connect = (handleOpen: Function, handleMessage: Function, handleClose: Function, handleError: Function) => {
if (this.ws) this.ws.close();
this.ws = new WebSocket(this.url + (this.token ? urlEncode({ token: this.token }) : ""));
this.handleOpen = handleOpen;
this.handleMessage = handleMessage;
this.handleError = handleError;
this.handleClose = handleClose;
this.open();
this.message();
this.error();
this.close();
}

// 监听websocket-open
public open = () => {
const context = this;
this.ws?.addEventListener('open', function (e) {
if (context.handleOpen) context.handleOpen(e);
})
}

// 监听websocket-message
public message = () => {
const context = this;
this.ws?.addEventListener('message', function (e) {
if (context.handleMessage) context.handleMessage(e);
})
}

// 监听websocket-error
public error = () => {
const context = this;
this.ws?.addEventListener('error', function (e) {
if (context.handleError) context.handleError(e);
})
}

// 监听websocket-close
public close = () => {
const context = this;
this.ws?.addEventListener('close', function (e) {
if (context.handleClose) context.handleClose(e);
})
}

// 手动关闭websocket
public handClose = () => {
this.flag = true;
this.ws?.close();
}

// 非自然断开(比如网络不稳定断开连接等情况)重连操作
public reConnect = () => {
this.reConnectTimer = window.setInterval(() => {
if (this.reConnectCount > this.reConnectMaxCount) {
ErrorNotice("重连失败!");
router.push(this.entryUrl ? this.entryUrl : "/");
if (this.reConnectTimer) window.clearInterval(this.reConnectTimer);
this.reConnectTimer = null;
} else {
console.log(`正在重连中…… (${this.reConnectCount}/${this.reConnectMaxCount})`);
this.ws = new WebSocket(this.url + (this.token ? urlEncode({ token: this.token }) : ""));
const context = this;
this.ws.addEventListener("open", function () {
SuccessNotice("重连成功!");
if (context.reConnectTimer) window.clearInterval(context.reConnectTimer);
context.reConnectTimer = null;
context.reConnectCount = 1;
context.open();
context.message();
context.error();
context.close();
})
this.ws.addEventListener("error", function () {
context.reConnectCount += 1;
})
}
}, 3000)
}

// 发送消息
public send = (data: IWebSocketTD | IVictory) => {
this.ws?.send(JSON.stringify(data));
}

// 定时发送空数据包保证长连接稳定
public heartCheck = () => {
this.heartTimer = window.setInterval(() => {
this.send({
event: RankSocketMsgEnum.HEARTBEAT,
})
}, 20000)
}

// 清除心跳检测定时器对象
public clearHeartCheck = () => {
if(this.heartTimer !== null) window.clearInterval(this.heartTimer);
}
}

这里需要提到的一点是WebSocket的重连reConnect和心跳检测heartCheck:

  1. 重连reConnect是为了因为客观原因比如用户匹配过程中网络断开等要重新发送WebSocket的连接请求以重新进入匹配队列;
  2. 心跳检测heartCheck是因为WebSocket在客户端和服务端长时间未发送消息会自动断开,在排位赛过程中可能由于比赛过程较长,客户端和服务端长时间未交互导致断开丢失排位用户信息,因此需要通过一定时间间隔的心跳检测保持稳定的连接;

MyWebSocket使用

为了测试MyWebSocket在实际中的用法,可以用node写一个简易的WebSocket服务器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 3001, path: '/api/v1/pk/websocket' });

let data = {
event: 'MATCHSUCCESS',
p1: {
id: 1,
userName: 'outlier-p1',
realName: '默认用户',
age: 18,
email: 'example@xx.com',
password: '',
schoolId: 1,
schoolNumber: '20402131001',
avatar: 'https://q9.itc.cn/q_70/images03/20240110/1f984d01360541d3ad28b4d2c0166b1d.jpeg',
description: '这个人很懒,什么都没有写噢~',
isDeleted: false,
version: 1,
rating: 0,
gender: 1
},
p2: {
id: 1,
userName: 'outlier-p2',
realName: '默认用户',
age: 18,
email: 'example@xx.com',
password: '',
schoolId: 1,
schoolNumber: '20402131000',
avatar: 'https://q9.itc.cn/q_70/images03/20240110/1f984d01360541d3ad28b4d2c0166b1d.jpeg',
description: '这个人很懒,什么都没有写噢~',
isDeleted: false,
version: 1,
rating: 0,
gender: 1
},
problem: {
id: 0,
pid: 'OSP0001',
title: '小鱼比可爱',
background: 'Outlier在开发时复制粘贴的一道测试题',
description: '人比人,气死人;鱼比鱼,难死鱼。小鱼最近参加了一个“比可爱”比赛,比的是每只鱼的可爱程度。' + '\n 参赛的鱼被从左到右排成一排,头都朝向左边,然后每只鱼会得到一个整数数值,表示这只鱼的可爱程度,很显然整数越大,表示这只鱼越可爱,而且任意两只鱼的可爱程度**可能一样**。由于所有的鱼头都朝向左边,所以每只鱼只能看见在它左边的鱼的可爱程度,它们心里都在计算,在自己的眼力范围内有多少只鱼不如自己可爱呢。请你帮这些可爱但是鱼脑不够用的小鱼们计算一下。',
inputFormat: '第一行输入一个正整数 $n$,表示鱼的数目。\n' + '第二行内输入 $n$ 个正整数,用空格间隔,依次表示从左到右每只小鱼的可爱程度 $a_i$。\n',
outputFormat: '一行,输出 $n$ 个整数,用空格间隔,依次表示每只小鱼眼中有多少只鱼不如自己可爱。',
hint: '对于 $100%$ 的数据,$1 \\leq n\\leq 100$,$0 \\leq a_i \\leq 10$。',
difficulty: 100,
authorId: 0,
createTime: Date.now(),
timeLimit: 1,
memoryLimit: 128,
isPublic: true,
isSpj: false,
isAudited: false,
isDeleted: false,
version: 0
},
sampleList: [
[{ input: '3\n1 2 3', output: '6' },
{ input: '3\n1 2 3', output: '6' }]
]
}

wss.on('connection', function (ws) {
console.log(`${new Date} A new WebScoket Connected!`);

ws.on('message', function (message) {
console.log('received: %s', message);
let msg = JSON.parse(message);
if(msg['event'] === 'STOPMATCH') ws.close();
});

ws.on('close', function () {
console.log('The client has disconnected.');
});

ws.on('error', function (error) {
console.error('WebSocket error:', error);
});

setInterval(() => {
ws.send(JSON.stringify(data))
}, 5000)
});

console.log('WebSocket server is running on ws://localhost:3001/api/v1/pk/websocket');

vue项目中的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<template>
<el-container>
<el-button type="primary" @click="handleStartMatch">开始匹配</el-button>
<el-button type="primary" @click="handleStopMatch">取消匹配</el-button>
</el-container>
</template>

<script setup lang='ts'>
import { useRankStore } from '@/store/useRankStore';
import { useUserStore } from '@/store/useUserStore';
import { WarnNotice } from '@/utils/Notification';
import MyWebSocket, { IWebSocketTD } from "@/utils/WebSocket.ts";
import { RankSocketMsgEnum } from "@/enum"
import { IMatchSuccess, IRankResult } from "@/types/rank";

const rankStore = useRankStore();
const userStore = useUserStore();

// 处理不同类型的WebSocket信息
const handleResolveWebSocketMsg = (msg: string) => {
let data: IWebSocketTD | IMatchSuccess | IRankResult = JSON.parse(msg);
console.log(data); // TODO: 处理信息
}

// @ts-ignore
const handleOpen = (e: Event) => {
rankStore.ws?.heartCheck();
// 连接成功后发送匹配请求
rankStore.ws?.send({
event: RankSocketMsgEnum.MATCHREQUEST
})
}

// @ts-ignore
const handleError = (e: Event) => {
WarnNotice("客户端与服务器的排位连接出现错误,正在尝试重连,请稍后……", 2500)
}

// @ts-ignore
const handleClose = (e: Event) => {
if (!rankStore.ws?.flag) {
rankStore.ws!.reConnect();
rankStore.ws!.flag = false;
}
rankStore.ws?.clearHeartCheck();
rankStore.$reset();
}

const handleMessage = (e: MessageEvent) => {
handleResolveWebSocketMsg(e.data);
}

const handleStartMatch = () => {
// TODO: 考虑用gsap添加一些动画
if (rankStore.ws) rankStore.ws?.handClose();
rankStore.ws = new MyWebSocket("ws://localhost:3001/api/v1/pk/websocket", userStore.token, "/rank/entry");
rankStore.ws.connect(handleOpen, handleMessage, handleClose, handleError);
}

const handleStopMatch = () => {
// 发送停止匹配消息
if (rankStore.ws) {
rankStore.ws.flag = true;
rankStore.ws.send({
event: RankSocketMsgEnum.STOPMATCH
});
}
}
</script>

<style scope lang="scss"></style>