Spring Boot 实战:基于 WebSocket 的前后端实时匹配系统实现
在现代 Web 开发中,前端和后端的协作至关重要,特别是在需要实时交互和数据更新的应用场景。WebSocket 作为一种全双工通信协议,让前后端的数据传输更高效稳定。本文将探讨如何设计和实现一个实时匹配系统,前端负责展示界面与交互,后端通过 WebSocket 处理数据通信。
前端初始化与状态管理
在 Vue 3 项目中,我们需要先搭建好 Vuex 状态管理来维护 WebSocket 连接和用户信息。这里我们定义了一个 pk 模块,用来存储连接对象、对手信息及当前游戏状态(匹配中或对战中)。
store/pk.js
import ModuleUser from './user'
export default {
state: {
socket: null, // ws 链接
opponent_username: "",
opponent_photo: "",
status: "matching", // matching 表示匹配界面,playing 表示对战界面
},
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,
}
}
记得在根 store 中引入这个模块: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,
}
})
接下来是建立连接的核心逻辑。我们在组件挂载时创建 WebSocket 实例,并在卸载时断开连接,防止资源泄漏。
views/pk/PKIndex.vue
<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>
安全性升级:JWT 验证
直接用 userId 建立连接存在安全风险,用户可能伪装成任意 ID。因此我们需要改用 JWT token 进行身份验证。
前端连接 URL 修改为:
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`
后端需要解析 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; // -1 表示不存在
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}
consumer/WebSocketServer.java
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
this.session = session;
System.out.println("connected to websocket");
// 尝试解析 token 获取用户 ID
Integer userId = JwtAuthentication.getUserId(token);
User user = userMapper.selectById(userId);
if (user != null) {
users.put(userId, this);
this.user = user;
} else {
// 验证失败直接断开
this.session.close();
}
}
匹配界面与 UI 切换
我们需要根据状态动态切换匹配页面和对战页面。使用 Bootstrap 栅格布局将头像和用户名分为左右两列,中间放置操作按钮。
views/pk/PKindexView.vue
<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 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 > img {
border-radius: 50%;
width: 20vh;
}
div.user-username {
text-align: center;
font-size: 24px;
font-weight: 600;
color: white;
padding-top: 2vh;
}
</style>
当点击按钮时,前端会发送 start-matching 或 stop-matching 事件给后端。后端收到消息后,需要处理匹配池逻辑。
后端匹配逻辑与线程安全
后端核心在于维护一个匹配池,当池中有两名及以上用户时,自动配对并生成游戏地图。
consumer/WebSocketServer.java
@Component
@ServerEndpoint("/websocket/{token}")
public class WebSocketServer {
final private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
final private 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;
Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if (this.user != null) {
users.put(userId, this);
} else {
this.session.close();
}
}
@OnClose
public void onClose() {
if (this.user != null) {
users.remove(this.user.getId());
matchpool.remove(this.user);
}
}
private void startMatching() {
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() {
matchpool.remove(this.user);
}
@OnMessage
public void onMessage(String message, Session session) {
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if ("start-matching".equals(event)) {
startMatching();
} else if ("stop-matching".equals(event)) {
stopMatching();
}
}
public void sendMessage(String message) {
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
这里使用了 ConcurrentHashMap 存储在线用户,CopyOnWriteArraySet 存储匹配池,确保多线程环境下的数据安全。发送消息时使用 synchronized 锁保证会话的线程安全。
游戏地图生成算法
匹配成功后,双方需要同步游戏地图。我们在后端创建一个 Game 类,使用深度优先搜索(DFS)算法生成带有边界和内部随机墙壁的连通地图。
consumer/utils/Game.java
package org.example.backend.consumer.utils;
import java.util.Random;
public class Game {
final private Integer rows;
final private Integer cols;
final private Integer inner_walls_count;
final private int[][] g;
final private static 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;
}
}
}
前端接收到地图数据后,更新 Vuex 状态并切换到对战界面。
views/PKindex.vue
<script>
// ... imports
export default {
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 = () => {
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 = () => {
store.commit("updateStatus", "matching");
}
});
onUnmounted(() => {
if (socket) socket.close();
});
}
}
</script>
总结
本方案展示了如何使用 WebSocket 构建实时匹配系统。前端利用 Vuex 管理全局状态,配合 Vue 生命周期钩子处理连接;后端通过 Java 实现高并发安全的匹配池与地图生成。关键在于前后端状态的同步以及连接的生命周期管理,确保用户体验流畅且数据准确。


