跳到主要内容Spring Boot 与 Vue 实现 WebSocket 实时匹配系统 | 极客日志Java大前端java算法
Spring Boot 与 Vue 实现 WebSocket 实时匹配系统
综述由AI生成基于 Spring Boot 和 Vue 构建实时游戏匹配系统。前端使用 Vuex 管理状态,通过 WebSocket 建立全双工通信。后端采用 Java 实现 WebSocket 服务端,集成 JWT 进行身份验证,确保安全性。匹配逻辑包含用户池管理与地图生成算法,支持动态切换匹配与对战界面。系统解决了前后端数据同步及连接安全问题,实现了完整的实时交互流程。
蓝绿部署11 浏览 前言
在现代 Web 开发中,前端和后端的协作至关重要,特别是在需要实时交互和数据更新的应用场景中。WebSocket 技术作为一种全双工通信协议,使得前端和后端之间的实时数据传输更加高效和稳定。本文探讨如何设计和实现一个实时匹配系统,其中前端负责展示用户界面并与后端进行交互,而后端则通过 WebSocket 协议来处理数据通信。
前端状态管理
在 Vue 3 项目中,使用 Vuex 管理全局状态,包括 WebSocket 连接、对手信息及游戏状态。
store/pk.js:
import ModuleUser from './user';
export default {
state: {
socket: null,
opponent_username: "",
opponent_photo: "",
status: "matching",
},
getters: {},
mutations: {
updateSocket(state, socket) { state.socket = socket; },
updateOpponent(state, opponent) {
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status) { state.status = status; }
},
actions: {},
modules: {
user: ModuleUser,
}
};
将 pk 模块引入 store/index.js:
import { createStore } from 'vuex';
import ModuleUser ;
;
({
: {},
: {},
: {},
: {},
: {
: ,
: ,
}
});
from
'./user'
import
ModulePk
from
'./pk'
export
default
createStore
state
getters
mutations
actions
modules
user
ModuleUser
pk
ModulePk
前端与后端建立连接
<template>
<PlayGround/>
</template>
<script>
import PlayGround from '@/components/PlayGround.vue';
import { onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex';
export default {
name: "PKIndex",
components: { PlayGround },
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}/`;
let socket = null;
onMounted(() => {
socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connected!");
store.commit("updateSocket", socket);
};
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
};
socket.onclose = () => {
console.log("disconnected!");
};
});
onUnmounted(() => {
if (socket) socket.close();
});
}
};
</script>
<style scoped></style>
将 token 改成 JWT 验证
若使用 userId 建立 WebSocket 连接,用户可伪装成任意用户,因此这是不安全的。应使用 JWT Token 进行身份验证。
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
添加 WebSocket 的 JWT 验证,根据 token 判断用户是否存在。
consumer/utils/JwtAuthentication.java:
package org.example.backend.consumer.utils;
import io.jsonwebtoken.Claims;
import org.example.backend.utils.JwtUtil;
public class JwtAuthentication {
public static Integer getUserId(String token) {
int userId = -1;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}
修改后端 WebSocketServer.java:
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
this.session = session;
System.out.println("connected to websocket");
Integer userId = JwtAuthentication.getUserId(token);
User user = userMapper.selectById(userId);
if (user != null) {
users.put(userId, this);
this.user = user;
} else {
this.session.close();
}
}
如果可以正常解析出 JWT token 则表示登录成功,否则直接关闭连接。
实现前端逻辑
对战界面和匹配界面的切换
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'"/>
<MatchGround v-if="$store.state.pk.status === 'matching'"/>
</template>
创建匹配页面 components/MatchGround.vue:
<template>
<div class="matchground"></div>
</template>
<script>
export default {};
</script>
<style scoped>
div.matchground {
width: 60vw;
height: 70vh;
margin: 40px auto;
background-color: lightblue;
}
</style>
匹配界面
使用 Grid 系统布局,自己头像与对手头像比例为 6:6。
逻辑很简单,点击匹配按钮,就向后端发送请求开始匹配。
components/MatchGround.vue:
<template>
<div class="matchground">
<div class="row">
<div class="col-6">
<div class="user-photo">
<img :src="$store.state.user.photo" alt="">
</div>
<div class="user-username">{{ $store.state.user.username }}</div>
</div>
<div class="col-6">
<div class="user-photo">
<img :src="$store.state.pk.opponent_photo" alt="">
</div>
<div class="user-username">{{ $store.state.pk.opponent_username }}</div>
</div>
<div class="col-12" style="text-align: center; padding-top: 15vh;">
<button @click="click_match_btn" type="button" class="btn btn-warning btn-lg">{{ match_btn_info }}</button>
</div>
</div>
</div>
</template>
<script>
import { ref } from 'vue';
import { useStore } from 'vuex';
export default {
setup() {
const store = useStore();
let match_btn_info = ref("开始匹配");
const click_match_btn = () => {
if (match_btn_info.value === "开始匹配") {
match_btn_info.value = "取消";
store.state.pk.socket.send(JSON.stringify({ event: "start-matching" }));
} else {
match_btn_info.value = "开始匹配";
store.state.pk.socket.send(JSON.stringify({ event: "stop-matching" }));
}
};
return { match_btn_info, click_match_btn };
}
};
</script>
<style scoped>
div.matchground { width: 60vw; height: 70vh; margin: 40px auto; background-color: rgba(50,50,50,0.5); }
div.user-photo { text-align: center; padding-top: 10vh; }
div.user-photo > img { border-radius: 50%; width: 20vh; }
div.user-username { text-align: center; font-size: 24px; font-weight: 600; color: white; padding-top: 2vh; }
</style>
- 外层 div.matchground 作为容器。
- 内部是 Bootstrap 栅格布局 row + col-6/col-12。
- 左侧显示当前用户信息(头像、用户名)。
- 右侧显示对手信息(来自 Vuex 状态)。
- 中间有匹配按钮,点击会改变按钮文字并通过 WebSocket 发送匹配事件给后端。
后端 consumer/WebSocketServer.java:
@Component
@ServerEndpoint("/websocket/{token}")
public class WebSocketServer {
private final static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
private final static CopyOnWriteArraySet<User> matchpool = new CopyOnWriteArraySet<>();
private User user;
private Session session = null;
private static UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
this.session = session;
System.out.println("connected!");
Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if (this.user != null) {
users.put(userId, this);
} else {
this.session.close();
}
System.out.println(users);
}
@OnClose
public void onClose() {
System.out.println("disconnected!");
if (this.user != null) {
users.remove(this.user.getId());
matchpool.remove(this.user);
}
}
private void startMatching() {
System.out.println("start matching!");
matchpool.add(this.user);
while (matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);
Game game = new Game(13, 14, 20);
game.createMap();
JSONObject respA = new JSONObject();
respA.put("event", "start-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("gamemap", game.getG());
users.get(a.getId()).sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject();
respB.put("event", "start-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("gamemap", game.getG());
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
private void stopMatching() {
System.out.println("stop matching");
matchpool.remove(this.user);
}
@OnMessage
public void onMessage(String message, Session session) {
System.out.println("receive message!");
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if ("start-matching".equals(event)) {
startMatching();
} else if ("stop-matching".equals(event)) {
stopMatching();
}
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
public void sendMessage(String message) {
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'"/>
<MatchGround v-if="$store.state.pk.status === 'matching'"/>
</template>
<script>
import PlayGround from '@/components/PlayGround.vue';
import MatchGround from '@/components/MatchGround.vue';
import { onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex';
export default {
components: { PlayGround, MatchGround },
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
let socket = null;
onMounted(() => {
store.commit("updateOpponent", {
username: "我的对手",
photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png"
});
socket = new WebSocket(socketUrl);
socket.onopen = () => {
console.log("connected!");
store.commit("updateSocket", socket);
};
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
if (data.event === "start-matching") {
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo
});
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
store.commit("updateGamemap", data.gamemap);
}
};
socket.onclose = () => {
console.log("disconnected!");
};
});
onUnmounted(() => {
if (socket) socket.close();
store.commit("updateStatus", "matching");
});
}
};
</script>
- 组件挂载时,通过 WebSocket 与服务器建立连接。
- 匹配阶段,用户与服务器实时交换数据,匹配成功后显示对手信息并开始游戏。
- 游戏过程中的状态(如对手信息和游戏地图)通过 Vuex 管理,并根据状态动态渲染不同的界面组件(MatchGround 或 PlayGround)。
- 组件卸载时关闭 WebSocket 连接,确保没有资源泄漏。
解决同步问题
首先要在后端创建一个 Game 类实现游戏流程,其实就是把之前在前端写的 JS 全部翻译成 Java。
consumer/utils/Game.java:
package org.example.backend.consumer.utils;
import java.util.Random;
public class Game {
private final Integer rows;
private final Integer cols;
private final Integer inner_walls_count;
private final int[][] g;
private static final int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
public Game(Integer rows, Integer cols, Integer inner_walls_count) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new int[rows][cols];
}
public int[][] getG() {
return g;
}
private boolean check_connectivity(int sx, int sy, int tx, int ty) {
if (sx == tx && sy == ty) return true;
g[sx][sy] = 1;
for (int i = 0; i < 4; i++) {
int x = sx + dx[i], y = sy + dy[i];
if (x >= 0 && x < this.rows && y >= 0 && y < this.cols && g[x][y] == 0) {
if (check_connectivity(x, y, tx, ty)) {
g[sx][sy] = 0;
return true;
}
}
}
g[sx][sy] = 0;
return false;
}
private boolean draw() {
for (int i = 0; i < this.rows; i++) {
for (int j = 0; j < this.cols; j++) {
g[i][j] = 0;
}
}
for (int r = 0; r < this.rows; r++) {
g[r][0] = g[r][this.cols - 1] = 1;
}
for (int c = 0; c < this.cols; c++) {
g[0][c] = g[this.rows - 1][c] = 1;
}
Random random = new Random();
for (int i = 0; i < this.inner_walls_count / 2; i++) {
for (int j = 0; j < 1000; j++) {
int r = random.nextInt(this.rows);
int c = random.nextInt(this.cols);
if (g[r][c] == 1 || g[this.rows - 1 - r][this.cols - 1 - c] == 1) continue;
if (r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;
g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = 1;
break;
}
}
return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}
public void createMap() {
for (int i = 0; i < 1000; i++) {
if (draw()) break;
}
}
}
Game 类主要用于生成一个带有边界和内部随机墙壁的连通游戏地图,使用了深度优先搜索(DFS)算法来检查地图的连通性。
在前端的 pk.js 中增加 gamemap 状态:
export default {
state: {
status: "matching",
socket: null,
opponent_username: "",
opponent_photo: "",
gamemap: null,
},
mutations: {
updateSocket(state, socket) { state.socket = socket; },
updateOpponent(state, opponent) {
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status) { state.status = status; },
updateGamemap(state, gamemap) { state.gamemap = gamemap; }
},
actions: {},
modules: {}
};
总结
本文介绍了如何使用 WebSocket 实现前端与后端的实时匹配。前端负责展示用户的操作界面并通过 WebSocket 与后端保持实时连接,后端则处理客户端的请求并返回实时数据。前后端需紧密配合以确保实时数据的正确传输和处理。
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online