关注

【Spring Boot开发实战手册】掌握Springboot开发技巧和窍门(十三)前端匹配界面、后端匹配WebSocket

前言

在现代 Web 开发中,前端和后端的协作变得越来越重要,特别是在需要实时交互和数据更新的应用场景中。WebSocket 技术作为一种全双工通信协议,使得前端和后端之间的实时数据传输变得更加高效和稳定。本篇博客将会探讨如何设计和实现一个实时匹配系统,其中前端负责展示用户界面并与后端进行交互,而后端则通过 WebSocket 协议来处理数据通信。


前端

onMounted: 当组件被挂载的时候执行的函数
onUnmonted: 当组件被卸载的时候执行的函数
初步调试阶段,我们是将token传进user.id的
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,
    }
}

将pk引入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,
  }
})

前端与后端建立连接
views/pk/PKIndex.vue

<template>

<PlayGround/>


</template>

<script>
//import ContentBase from "@/components/ContentBase.vue"
import PlayGround from "@/components/PlayGround.vue"
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex"


export default {
    name: "PKindex",
    components: {
       // ContentBase,
        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); //js自带的WebSocket()
            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(() => { //当当前页面关闭时调用
            socket.close(); //卸载的时候断开连接
        });
    }
}


</script>


<style scoped>
</style>

至此,前端与后端就可以通过websocket互相连接了。
在这里插入图片描述
在这里插入图片描述

将token改成jwt验证

若使用userId建立ws连接,用户可伪装成任意用户,因此这是不安全的

const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;

添加ws的jwt验证,根据token判断用户是否存在
consumer/utils/JwtAuthenciation.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

如果可以正常解析出jwt token的话表示登录成功,否则登录不成功,直接close

...
@OnOpen
    public void onOpen(Session session, @PathParam("token") String token) throws IOException {
        // 建立连接
        this.session = session;
        System.out.println("connected to websocket");
        int userId = Integer.parseInt(token);
        this.user = userMapper.selectById(userId);
        if(user != null){
            users.put(userId, this);
        }else {
            this.session.close();
        }

    }

...

实现前端逻辑

对战界面和匹配界面的切换

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>
</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。

左侧用户信息:

  • img 显示用户头像,src 来自 Vuex 状态 store.state.user.photo。
  • div 显示用户名,{{ $store.state.user.username }}。

右侧对手信息:

  • 类似用户,显示对手头像和用户名,来自 store.state.pk.opponent_photo 和 store.state.pk.opponent_username。

按钮:

  • 居中显示,绑定点击事件 @click=“click_match_btn”。
  • 按钮文字使用 match_btn_info 变量绑定(响应式)。

点击事件逻辑:

  • 如果按钮显示“开始匹配”:

    • 改成“取消”。
    • 通过 WebSocket store.state.pk.socket.send 发事件 “start-matching”。
  • 如果按钮显示“取消”:

    • 改回“开始匹配”。
    • 发事件 “stop-matching”。
  • 返回模板使用:

    • return { match_btn_info, click_match_btn },模板可以直接使用这些变量和方法。

页面展示 两位玩家头像 + 名字。

  • 中间有 匹配按钮,点击会:
  • 改变按钮文字(“开始匹配” ↔ “取消”)。
  • 通过 WebSocket 发送匹配事件给后端。

使用 Vue 3 Composition API + Vuex + Bootstrap 栅格布局 实现

后端consumer/WebSocketServer.java

package org.example.backend.consumer;

import com.alibaba.fastjson.JSONObject;
import org.example.backend.consumer.utils.Game;
import org.example.backend.consumer.utils.JwtAuthentication;
import org.example.backend.mapper.UserMapper;
import org.example.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@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;
        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();
            }
        }
    }
}


@ServerEndpoint(“/websocket/{token}”):这是 WebSocket 的入口点,指定了 WebSocket 服务的 URL 路径。{token} 是一个路径参数,用来验证和识别用户的身份。
users:使用 ConcurrentHashMap 存储所有连接的用户,用户 ID 是键,WebSocketServer 实例是值。
matchpool:使用 CopyOnWriteArraySet 存储正在匹配的用户。CopyOnWriteArraySet 是线程安全的集合类。
user:当前 WebSocket 连接对应的用户信息。
session:Session 对象表示 WebSocket 连接的会话。

onOpen方法:

  • @OnOpen:WebSocket 连接建立时会调用这个方法。
  • session:WebSocket 会话。
  • @PathParam(“token”):从 URL 中提取用户的身份验证 token。
  • 使用 JwtAuthentication.getUserId(token) 从 token 获取用户 ID,然后查询数据库获取用户信息。
  • 如果用户存在,就将 WebSocketServer 实例与用户 ID 关联,并保存到 users 中;否则关闭连接。

onClose方法:

  • @OnClose:WebSocket 连接关闭时调用这个方法。
  • 如果用户存在,移除 users 和 matchpool 中该用户的信息。

游戏匹配逻辑startMatching方法:
启动匹配过程,将当前用户添加到 matchpool 中。
如果匹配池中有两个以上的用户(至少两个人可以开始匹配),则开始配对:

  • 从 matchpool 中取出两名用户。
  • 创建一个新的游戏实例,并生成地图。
  • 向这两名用户发送匹配成功的消息,消息内容包括对方用户名、头像和游戏地图。

stopMatching方法:

  • 停止匹配,将当前用户从 matchpool 中移除。

消息处理onMessage方法:

  • @OnMessage:当 WebSocket 收到消息时会调用此方法。
  • 解析收到的消息,根据 event 字段的值来决定是开始匹配还是停止匹配。

发送消息sendMessage方法:

  • 发送消息给当前 WebSocket 客户端。
  • 使用 synchronized 确保线程安全。

后端返回信息给前端后,在前端接受并处理信息
views/PKindex.vue

<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(() => {
            socket.close();
            store.commit("updateStatus", "matching");
        })
    }
}
</script>

<style scoped></style>

const store = useStore(); 用于访问 Vuex 状态管理对象,获取和修改全局状态。
const socketUrl = ws://127.0.0.1:3000/websocket/${store.state.user.token}/; 构建 WebSocket 连接的 URL,使用当前用户的 token 来进行身份验证。
在 onMounted 生命周期钩子中:
使用 store.commit(“updateOpponent”, …) 设置默认的对手信息(用户名和头像)。
创建 WebSocket 实例并建立连接,连接成功时通过 socket.onopen 事件回调执行:
连接成功后,调用 store.commit(“updateSocket”, socket) 更新全局状态中的 WebSocket 实例。
通过 socket.onmessage 监听消息,接收从 WebSocket 服务器发送的数据:
如果 data.event 为 “start-matching”,表示匹配成功。
更新对手信息和游戏地图,并在 2 秒后通过 store.commit(“updateStatus”, “playing”) 更新状态为 “playing”,表示开始游戏。
通过 socket.onclose 监听 WebSocket 连接关闭事件。

在 onUnmounted 生命周期钩子中:
关闭 WebSocket 连接。
更新 pk.status 状态为 “matching”,表示重新回到匹配阶段。

基于 WebSocket 的游戏匹配系统,具体流程如下:

  • 当组件挂载时,通过 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 {
    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;
        }
    }
}


Game 类主要用于生成一个带有边界和内部随机墙壁的连通游戏地图,使用了深度优先搜索(DFS)算法来检查地图的连通性。

功能概述:

  • 初始化地图的大小和墙壁数量。
  • 生成并检查地图的连通性。
  • 提供获取地图的方法以便其他类或模块使用。

在前端的pk.js中:

export default {
    state: {
        status: "matching",  // matching表示匹配界面,playing表示对战界面
        socket: null,
        opponent_username: "",
        opponent_photo: "",
        gamemap: null,
    },
    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;
        },
        updateGamemap(state, gamemap) {
            state.gamemap = gamemap;
        }
    },
    actions: {
    },
    modules: {
    }
}

status:当前游戏状态

  • “matching” → 正在匹配阶段(显示匹配界面)
  • “playing” → 游戏进行中(显示对战界面)

socket:存储 WebSocket 对象,用于实时通信。
opponent_username & opponent_photo:记录对手信息。
gamemap:存储游戏地图或游戏数据(例如双方棋盘或战斗场景等)。

updateSocket: 更新 WebSocket 对象
updateOpponent: 更新对手的用户名和头像
updateStatus: 更新游戏状态(matching ↔ playing)
updateGamemap: 更新游戏地图信息

展示结果:
在这里插入图片描述


总结

通过本文的介绍,您应该对如何使用 WebSocket 实现前端与后端的实时匹配有了一个清晰的理解。在开发过程中,前端和后端需要通过紧密的配合来确保实时数据的正确传输和处理。前端负责展示用户的操作界面并通过 WebSocket 与后端保持实时连接,后端则处理客户端的请求并返回实时数据。

转载自CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/m0_63267251/article/details/158685822

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--