五子棋双人对战项目(3)——匹配模块

时间:2024-10-02 15:18:34

目录

一、分析需求

二、约定前后端交互接口

匹配请求:

匹配响应:

三、实现游戏大厅页面(前端代码)

game_hall.html:

common.css:

game_hall.css:

四、实现后端代码

WebSocketConfig

MatchAPI(处理请求)

MatchResponse(响应)

MatchRequest (请求)

OnlineUserManager(用户在线状态)

Matcher(匹配器)

Room(游戏房间)

RoomManager(房间管理器)

五、线程安全问题

1、HashMap 和 多开

2、三个队列

六、忙等问题


一、分析需求

        需求多个玩家,在游戏大厅进行匹配,系统会把实力相近的玩家匹配到一起

        要想实现上述效果,就需要利用到消息推送机制,即需要使用到 WebSocket 协议。如图:


二、约定前后端交互接口

        通过需求分析,确认了要使用 WebSocket 协议,来实现消息推送的效果,因此,约定前后端交互接口也是根据 WebSocket 展开的。

        WebSocket 协议,可以传输文本数据,也可以传输二进制数据,这里就采用传输 JSON 格式的文本数据

匹配请求:

        这里并不需要传送用户信息,因为在前面登录的时候,就已经把当前用户信息保存到HttpSession中了,在进行WebSocket连接时,只需要把HttpSession中的Session拿过来就行了,并保存在WebSocket连接中。

匹配响应:

        这里会有两个不同的匹配响应。

匹配响应1是指玩家点击开始匹配,玩家的这个操作的请求发送成功,后端返回回来的响应(立即返回的响应)

匹配响应2指有两个玩家成功匹配到一起了,服务器主动推送回来的响应(多久返回这个响应?服务器并不知道)。(匹配到的对手信息保存在服务器中)


三、实现游戏大厅页面(前端代码)

game_hall.html:

        JSON字符串 和 JS对象的转换:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏大厅</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_hall.css">
</head>

<body>
    <div class="nav">五子棋对战</div>
    <!-- 整个页面的容器元素‘ -->
    <div class="container">
        <!-- 这个div在container中是处于垂直水平居中这样的位置的 -->
        <div>
            <!-- 展示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开始匹配</div>
        </div>
    </div>

    <script src="./js/jquery.min.js"></script>

    <script>
        $.ajax({
            type: 'get',
            url: '/userInfo',
            success: function (body) {
                let screenDiv = document.querySelector("#screen");
                screenDiv.innerHTML = "玩家: " + body.username + "分数: " + body.score
                    + "<br> 比赛场次: " + body.totalCount + "获胜场数: " + body.winCount
            },
            error: function () {
                alert("获取用户信息失败");
            }
        });
        // 此处进行初始化 websocket,并且实现前端的匹配逻辑
        // 此处的路径必须写作 /findMatch
        let websocket = new WebSocket("ws://127.0.0.1:8080/findMatch");
        websocket.onopen = function () {
            console.log("onopen");
        }
        websocket.onclose = function () {
            console.log("onclose");
            // alert("游戏大厅中接收到了失败响应! 请重新登录");
            // location.assign("/login.html");
        }
        websocket.onerror = function () {
            console.log("onerror");
        }
        // 监听页面关闭事件,在页面关闭之前,手动调动这里的 websocket 的 close 方法
        window.onbeforeunload = function () {
            websocket.close();
        }

        //一会重点来实现,要处理服务器返回的响应
        websocket.onmessage = function (e) {
            // 处理服务器返回的响应数据,这个响应就是针对 "开始匹配" / "结束匹配" 来应对的
            //解析得到的响应对象,返回的数据是一个 JSON 字符串,解析成 js 对象
            let resp = JSON.parse(e.data);
            let matchButton = document.querySelector("#match-button");
            if (!resp.ok) {
                console.log("游戏大厅中接收到了失败响应! " + resp.reason);
                alert("游戏大厅中接收到了失败响应! " + resp.reason);
                location.assign("/login.html");
                return;
            }
            if (resp.message == 'startMatch') {
                //开始匹配请求发起成功
                console.log("进入匹配队列成功");
                matchButton.innerHTML = '匹配中...(点击停止)';
            } else if (resp.message == 'stopMatch') {
                //结束匹配请求发起成功
                console.log("离开匹配队列成功");
                matchButton.innerHTML = '开始匹配';
            } else if (resp.message == 'matchSuccess') {
                //已经匹配到对手了
                console.log("匹配到对手! 进入游戏房间");
                location.assign("/game_room.html");
            } else {
                console.log("收到了非法的响应! message=" + resp.message);
            }

        }

        // 给匹配按钮添加一个点击事件
        let matchButton = document.querySelector('#match-button');
        matchButton.onclick = function () {
            //在触发 websocket 请求之前,先确认下 websocket 连接是否好着
            if (websocket.readyState == websocket.OPEN) {
                //如果当前 readyState 处在 OPPEN状态,说明连接是好着的
                //这里发送的数据有两种可能,开始匹配/停止匹配
                if (matchButton.innerHTML == '开始匹配') {
                    console.log("开始匹配");
                    websocket.send(JSON.stringify({
                        message: 'startMatch',
                    }))
                } else if (matchButton.innerHTML == '匹配中...(点击停止)') {
                    console.log("停止匹配");
                    websocket.send(JSON.stringify({
                        message: 'stopMatch',
                    }));
                }
            } else {
                //这是说明当前连接是异常的状态
                alert("当前您的连接已经断开! 请重新登录!");
                location.assign('/login.html');
            }
        }
    </script>

</body>

</html>

common.css:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html, body {
    height: 100%;
    background-image: url(../image/blackboard.jpg);
    background-repeat: no-repeat;
    background-position: center;
    background-size: cover;
}

.nav {
    height: 50px;

    background: rgb(50, 50, 50);
    color: white;

    line-height: 50px;
    padding-left: 20px;
}

.container {
    width: 100%;
    height: calc(100% - 50px);

    display: flex;
    align-items: center;
    justify-content: center;
}

game_hall.css:

.container {
    width: 100%;
    height: calc(100% - 50px);

    display: flex;
    align-items: center;
    justify-content: center;
}

#screen {
    width: 400px;
    height: 200px;
    font-size: 20px;
    background-color: gray;
    color: white;
    border-radius: 10px;

    text-align: center;
    line-height: 100px;
}

#match-button {
    width: 400px;
    height: 50px;
    font-size: 20px;
    background-color: orange;
    color: white;
    border: none;
    outline: none;
    border-radius: 10px;

    text-align: center;
    line-height: 50px;
    margin-top: 20px;
}

#match-button:active{
    background-color: gray;
}   

四、实现后端代码

        后端要想建立WebSocket连接,需要创建一个专门的类(MatchAPI),来处理 WebSocket 的请求;同时,还要新建一个类(WebSocketConfig),进行WebSocket连接、配置 WebSocket 连接的路径,以及拿到之前 HTTP 连接时的 Session

WebSocketConfig

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private MatchAPI matchAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(matchAPI, "/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

MatchAPI(处理请求)

        JSON字符串 和 Java对象的转换:

//通过这个类来处理匹配功能中的 websocket 请求
@Slf4j
@Component
public class MatchAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

    private ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线,加入到 OnlineUserManager 中

        //1、先获取到当前用户的身份信息(谁在游戏大厅中,建立的连接)
        // 此处的代码,之所以能够getAttributes,全靠了在注册 websocket 的时候,
        // 加上了 .addInterceptors(new HttpsessionHandshakeInterceptor())
        // 这个逻辑就把 HttpSession 中的 Attribute 都给拿到 WebSocketSession 中了
        // 在 Http 登录逻辑中,往 HttpSession 中存了 User 数据:httpSession.setAttribute("user", user)
        // 此时就可以在 WebSocketSession 中把之前 HttpSession 里存的 User 对象给拿到了
        // 注意,此处拿到的 user,可能是为空的
        // 如果之前用户压根就没有通过 HTTP 来进行登录,直接就通过 /game_hall.html 这个URL来进行访问游戏大厅了
        // 此时就会出现 user 为 null 的情况
        try {
            User user = (User) session.getAttributes().get("user");
            //2、拿到了身份信息之后,进行判断当前用户是否已经登录过(在线状态),如果已经是在线,就不该继续进行后续逻辑
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession != null) {
                //  说明该用户已经登录了
                //  针对这个情况,要告知客户端,你这里重复登录了
                MatchResponse response = new MatchResponse();
                response.setOk(false);
                response.setReason("当前用户已经登录, 静止多开!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                session.close();
                return;
            }
            onlineUserManager.enterGameHall(user.getUserId(), session);
//            System.out.println("玩家" + user.getUsername() + " 进入游戏大厅");
            log.info("玩家 {}",user.getUsername() + " 进入游戏大厅");
        } catch (NullPointerException e) {
            e.printStackTrace();
            // 出现空指针异常,说明当前用户的身份信息为空,也就是用户未登录
            // 就把当前用户尚未登录,给返回回去
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //  实现处理开始匹配请求和停止匹配请求
        User user = (User) session.getAttributes().get("user");
        //  拿到客户端发给服务器的数据
        String payload = message.getPayload();
        //  当前传过来的数据是JSON格式的字符串,就需要把它转成 Java 对象:MatchRequest
        MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);

        MatchResponse response = new MatchResponse();
        if(request.getMessage().equals("startMatch")) {
            //  进入匹配队列
            //  把当前用户加入到匹配队列中
            matcher.add(user);
            //  把玩家信息放入匹配队列后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("startMatch");
        } else if (request.getMessage().equals("stopMatch")) {
            //  退出匹配队列
            //  在匹配队列中把当前用户给删除了
            matcher.remove(user);
            // 在匹配队列中把当前用户给删除后,就可以返回一个响应给客户端了
            response.setOk(true);
            response.setMessage("stopMatch");
        } else {
            //  非法情况
            response.setOk(false);
            response.setMessage("非法的匹配请求");
        }

        String jsonString = objectMapper.writeValueAsString(response);
        session.sendMessage(new TextMessage(jsonString));
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 玩家下线,删除 OnlineUserManager 中的该用户的Session
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中,但WebSocket断开了,就应该把该玩家移除匹配队列
            log.info("Error玩家: {}", user.getUsername() + " 下线");
            matcher.remove(user);
        } catch (NullPointerException e) {
            e.printStackTrace();
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 玩家下线,删除 OnlineUserManager 中的该用户的Session
        try {
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if(tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中,但WebSocket断开了,就应该把该玩家移除匹配队列
            log.info("Closed玩家: {}", user.getUsername() + " 下线");
            matcher.remove(user);
        } catch (NullPointerException e) {
            e.printStackTrace();
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录,不能进行后续匹配");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));
        }
    }
}

        对于 WebSocket 请求、返回的响应,把传送的数据封装成这两个类:

MatchResponse(响应)

// 这是表示一个 WebSocket响应
@Data
public class MatchResponse {
    private boolean ok;
    private String reason;
    private String message;
}

MatchRequest (请求)

// 这是表示一个 WebSocket 请求
@Data
public class MatchRequest {
    private String message;
}

OnlineUserManager(用户在线状态)

        之所以要维护用户的在线状态,目的是为了能够在代码中比较方便的获取到某个用户当前的WebSocket 会话,从而通过这个会话来对客户端发送消息。

        同时,也能感知到用户的 在线/离线 状态~。

        此处使用 哈希表 来维护 userId 和 WebSocketSession 的映射关系。

@Component
public class OnlineUserManager {
    //这个hash表就是用来表示当前用户在游戏大厅的在线状态
    private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();

    public void enterGameHall(int userId, WebSocketSession webSocketSession) {
        gameHall.put(userId, webSocketSession);
    }

    public void exitGameHall(int userId) {
        gameHall.remove(userId);
    }

    public WebSocketSession getFromGameHall(int userId) {
        return gameHall.get(userId);
    }
}

Matcher(匹配器)

        通过这个匹配器,来处理玩家的匹配功能。

//  这个类表示匹配器,通过这个类来负责整个的匹配功能
@Slf4j
@Component
public class Matcher {
    //  创建三个匹配队列
    private Queue<User> normalQueue = new LinkedList<>();
    private  Queue<User> highQueue = new LinkedList<>();
    private Queue<User> veryHighQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private RoomManager roomManager;

    private ObjectMapper objectMapper = new ObjectMapper();

    //  操作匹配队列的方法
    //  把玩家放到匹配队列中
    public void add(User user) {
        if(user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 normalQueue 中");
        } else if(user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 highQueue 中");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.offer(user);
                veryHighQueue.notify();
            }
            log.info("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue 中");
        }
    }

    //  当玩家点击停止匹配,就需要把该玩家从匹配队列删除
    public void remove(User user) {
        if(user.getScore() < 2000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
            log.info("玩家: " + user.getUsername() + " 在 normalQueue 队列被删除");
        } else if(user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
            log.info("把玩家: " + user.getUsername() + " 在 highQueue 队列被删除");
        } else {
            synchronized (veryHighQueue) {
                veryHighQueue.remove(user);
            }
            log.info("把玩家: " + user.getUsername() + " 在 veryHighQueue 队列被删除");
        }
    }

    public Matcher() {
        //  创建三个线程,分别针对这三个匹配队列,进行操作
        Thread t1 = new Thread() {
            @Override
            public void run() {
                //  扫描normalQueue
                while(true) {
                    handlerMatch(normalQueue);
                }
            }
        };
        t1.start();

        Thread t2 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        t2.start();

        Thread t3 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(veryHighQueue);
                }
            }
        };
        t3.start();
    }

    public void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try {
                //  1、检测队列中元素个数是否达到 2
                //  队列的初始情况可能是 空
                //  如果往队列中添加一个元素,这个时候,仍然是不能进行后续匹配操作的
                //  因此在这里使用 while 循环检查更合理
                while (matchQueue.size() < 2) {
                    matchQueue.wait();
                }
                //  2、尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                log.info("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());

                //  3、获取到玩家的 WebSocket 会话
                //     获取到会话的目的是为了告诉玩家,你排到了~
                WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
                //  理论上来说,匹配队列中的玩家一定是在线的状态
                //  因为前面的逻辑进行了处理,当玩家断开连接的时候,就把玩家从匹配队列移除了
                //  但是这里还是进行一次判定,进行双重判定会更稳妥一点
                if(session1 == null) {
                    //  如果玩家1不在线了,就把玩家2放回匹配队列
                    matchQueue.offer(player2);
                    return;
                }
                if(session2 == null) {
                    //  如果玩家1不在线了,就把玩家2放回匹配队列
                    matchQueue.offer(player1);
                    return;
                }

                //  当前能否排到两个玩家是同一个用户的情况吗?一个玩家入队列两次
                //  理论上也不会存在~
                //  1) 如果玩家下线,就会对玩家移除匹配队列
                //  2) 又禁止了玩家多开
                //  但是仍然在这里多进行一次判定,以免前面的逻辑出现 bug 时,带来严重的后果
                if(session1 == session2) {
                    //  把其中的一个玩家放回匹配队列
                    matchQueue.offer(player1);
                    return;
                }

                //  4、把这两个玩家放到同一个游戏房间中
                Room room = new Room();
                roomManager.add(room, player1.getUserId(), player2.getUserId());

                //  5、给玩家反馈信息
                //    通过 WebSocket 返回一个 message 为 “matchSuccess” 这样的响应
                MatchResponse response1 = new MatchResponse();
                response1.setOk(true);
                response1.setMessage("matchSuccess");
                String json1 = objectMapper.writeValueAsString(response1);
                session1.sendMessage(new TextMessage(json1));

                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                String json2 = objectMapper.writeValueAsString(response2);
                session2.sendMessage(new TextMessage(json2));

            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Room(游戏房间)

//  这个类表示一个游戏房间
@Data
public class Room {
    // 使用字符串类型来表示,方便生成唯一值
    private String roomId;

    private User user1;
    private User user2;

    public Room() {
        // 构造 Room 的时候生成一个唯一的字符串表示房间 id
        // 使用 UUID 来作为房间 id
        roomId = UUID.randomUUID().toString();
    }
}

RoomManager(房间管理器)

// 房间管理器类
// 这个类也希望有唯一实例
@Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();

    public void add(Room room, int userId1, int userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1, room.getRoomId());
        userIdToRoomId.put(userId2, room.getRoomId());
    }

    public void remove(String roomId, int userId1, int userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }

    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

    public Room getRoomByUserId(int userId) {
       String roomId = userIdToRoomId.get(userId);
       if(roomId == null) {
           // userId -> roomId 映射关系不存在,直接返回 null
           return null;
       }
       return rooms.get(roomId);
    }
}

五、线程安全问题

1、HashMap 和 多开

        如果多个线程访问同一个HashMap,就容易出现线程安全问题。

        如果同时多个用户和服务器 建立/断开 连接,此时服务器就是并发的在针对 HashMap 进行修改。

        所以要避免这种情况,解决这个线程安全问题,可以直接使用ConcurrentHashMap。

        当多个浏览器,通时对一个用户进行登录,进入游戏大厅,会引发下面这种问题:

       所以,我们不仅要解决线程安全问题,也要考虑用户多开的情况,那么用户能进行多开操作吗?显然是不能的,所以上面的代码逻辑也是会处理这种多开的情况,如果当前用户已经登录,禁止其他地方再登录

2、三个队列

        在匹配模块(Matcher类),为了划分玩家水平实力,使用了三个队列表示不同的实力分段;

        同时,创建三个线程,当用户进行匹配时,就会不停的扫描这三个队列,看是否能匹配对局成功。

        但因为匹配时,就会把玩家加入到对应段位的队列,而停止匹配,也会把玩家从对应的队列删除,又有多个线程并发的去执行,所以,存在线程安全问题。

        怎么办?

        针对这三个队列对象,分别进行加锁,如图:

        这三个线程都是调用同一个方法。如图:

        因此,我们针对这一个方法加锁就好了:


六、忙等问题

        我们创建了三个线程:

        会不停的去扫描这三个队列,元素个数是否达到2,如果达到2,就要吧这两个用户取出来,放在同一个房间中进行对局。

        但这里要一直扫描码?显然是不用的,所以可以在这里 wait 一下。

        既然 wait了,那就要有 notify,来唤醒它,继续锁竞争。那什么时候唤醒呢?当然是这个队列有新的用户加进来了,那再进行唤醒,再重新判断用户个数是否达到2.

        这样,我们就能解决忙等的问题。