SpringBoot + WebSocket 實現答題對戰匹配機制案例詳解
類似競技問答游戲:用戶隨機匹配一名對手,雙方同時開始答題,直到雙方都完成答題,對局結束。基本的邏輯就是這樣,如果有其他需求,可以在其基礎上進行擴展
明確了這一點,下面介紹開發思路。為每個用戶擬定四種在線狀態,分別是:待匹配、匹配中、游戲中、游戲結束。下面是流程圖,用戶的流程是被規則約束的,狀態也隨流程而變化
對流程再補充如下:
用戶進入匹配大廳(具體效果如何由客戶端體現),將用戶的狀態設置為待匹配 用戶開始匹配,將用戶的狀態設置為匹配中,系統搜索其他同樣處于匹配中的用戶,在這個過程中,用戶可以取消匹配,返回匹配大廳,此時用戶狀態重新設置為待匹配。匹配成功,保存匹配信息,將用戶狀態設置為游戲中 根據已保存的匹配信息,用戶可以獲得對手的信息。答題是時,每次用戶分數更新,也會向對手推送更新后的分數 用戶完成答題,則等待對手也完成答題。雙方都完成答題,用戶狀態設置為游戲結束,展示對局結果詳細設計針對概要設計提出的思路,我們需要思考以下幾個問題:
如何保持客戶端與服務器的連接? 如何設計客戶端與服務端的消息交互? 如何保存以及改變用戶狀態? 如何匹配用戶?下面我們一個一個來解決
1. 如何保持用戶與服務器的連接?
以往我們使用 Http 請求服務器,并獲取響應信息。然而 Http 有個缺陷,就是通信只能由客戶端發起,無法做到服務端主動向客戶端推送信息。根據概要設計我們知道,服務端需要向客戶端推送對手的實時分數,因此這里不適合使用 Http,而選擇了 WebSocket。WebSocket 最大的特點就是服務端可以主動向客戶端推送信息,客戶端也可以主動向服務端發送信息,是真正的雙向平等對話
有關 SpringBoot 集成 WebSocket 可參考這篇博客:https://www.jb51.net/article/208279.htm
2. 如何設計客戶端與服務端的消息交互?
按照匹配機制要求,把消息劃分為 ADD_USER(用戶加入)、MATCH_USER(匹配對手)、CANCEL_MATCH(取消匹配)、PLAY_GAME(游戲開始)、GAME_OVER(游戲結束)
public enum MessageTypeEnum { /** * 用戶加入 */ ADD_USER, /** * 匹配對手 */ MATCH_USER, /** * 取消匹配 */ CANCEL_MATCH, /** * 游戲開始 */ PLAY_GAME, /** * 游戲結束 */ GAME_OVER,}
使用 WebSocket 客戶端可以向服務端發送消息,服務端也能向客戶端發送消息。把消息按照需求劃分成不同的類型,客戶端發送某一類型的消息,服務端接收后判斷,并按照類型分別處理,最后返回向客戶端推送處理結果。區別客戶端 WebSocket 連接的是從客戶端傳來的 userId,用 HashMap 保存
@Component@Slf4j@ServerEndpoint(value = '/game/match/{userId}')public class ChatWebsocket { private Session session; private String userId; static QuestionSev questionSev; static MatchCacheUtil matchCacheUtil; static Lock lock = new ReentrantLock(); static Condition matchCond = lock.newCondition(); @Autowired public void setMatchCacheUtil(MatchCacheUtil matchCacheUtil) {ChatWebsocket.matchCacheUtil = matchCacheUtil; } @Autowired public void setQuestionSev(QuestionSev questionSev) {ChatWebsocket.questionSev = questionSev; } @OnOpen public void onOpen(@PathParam('userId') String userId, Session session) {log.info('ChatWebsocket open 有新連接加入 userId: {}', userId);this.userId = userId;this.session = session;matchCacheUtil.addClient(userId, this);log.info('ChatWebsocket open 連接建立完成 userId: {}', userId); } @OnError public void onError(Session session, Throwable error) {log.error('ChatWebsocket onError 發生了錯誤 userId: {}, errorMessage: {}', userId, error.getMessage());matchCacheUtil.removeClinet(userId);matchCacheUtil.removeUserOnlineStatus(userId);matchCacheUtil.removeUserFromRoom(userId);matchCacheUtil.removeUserMatchInfo(userId);log.info('ChatWebsocket onError 連接斷開完成 userId: {}', userId); } @OnClose public void onClose() {log.info('ChatWebsocket onClose 連接斷開 userId: {}', userId);matchCacheUtil.removeClinet(userId);matchCacheUtil.removeUserOnlineStatus(userId);matchCacheUtil.removeUserFromRoom(userId);matchCacheUtil.removeUserMatchInfo(userId);log.info('ChatWebsocket onClose 連接斷開完成 userId: {}', userId); } @OnMessage public void onMessage(String message, Session session) {log.info('ChatWebsocket onMessage userId: {}, 來自客戶端的消息 message: {}', userId, message);JSONObject jsonObject = JSON.parseObject(message);MessageTypeEnum type = jsonObject.getObject('type', MessageTypeEnum.class);log.info('ChatWebsocket onMessage userId: {}, 來自客戶端的消息類型 type: {}', userId, type);if (type == MessageTypeEnum.ADD_USER) { addUser(jsonObject);} else if (type == MessageTypeEnum.MATCH_USER) { matchUser(jsonObject);} else if (type == MessageTypeEnum.CANCEL_MATCH) { cancelMatch(jsonObject);} else if (type == MessageTypeEnum.PLAY_GAME) { toPlay(jsonObject);} else if (type == MessageTypeEnum.GAME_OVER) { gameover(jsonObject);} else { throw new GameServerException(GameServerError.WEBSOCKET_ADD_USER_FAILED);}log.info('ChatWebsocket onMessage userId: {} 消息接收結束', userId); } /** * 群發消息 */ private void sendMessageAll(MessageReply<?> messageReply) {log.info('ChatWebsocket sendMessageAll 消息群發開始 userId: {}, messageReply: {}', userId, JSON.toJSONString(messageReply));Set<String> receivers = messageReply.getChatMessage().getReceivers();for (String receiver : receivers) { ChatWebsocket client = matchCacheUtil.getClient(receiver); client.session.getAsyncRemote().sendText(JSON.toJSONString(messageReply));}log.info('ChatWebsocket sendMessageAll 消息群發結束 userId: {}', userId); } // 出于減少篇幅的目的,業務處理方法暫不貼出...}
3. 如何保存以及改變用戶狀態?
創建一個枚舉類,定義用戶的狀態
/** * 用戶狀態 * @author yeeq */public enum StatusEnum { /** * 待匹配 */ IDLE, /** * 匹配中 */ IN_MATCH, /** * 游戲中 */ IN_GAME, /** * 游戲結束 */ GAME_OVER, ; public static StatusEnum getStatusEnum(String status) {switch (status) { case 'IDLE':return IDLE; case 'IN_MATCH':return IN_MATCH; case 'IN_GAME':return IN_GAME; case 'GAME_OVER':return GAME_OVER; default:throw new GameServerException(GameServerError.MESSAGE_TYPE_ERROR);} } public String getValue() {return this.name(); }}
選擇 Redis 保存用戶狀態,還是創建一個枚舉類,Redis 中存儲數據都有唯一的 Key 做標識,因此在這里定義 Redis 中的 Key,分別介紹如下:
USER_STATUS:存儲用戶狀態的 Key,存儲類型是 Map<String, String>,其中用戶 userId 為 key,用戶在線狀態 為 value USER_MATCH_INFO:當用戶處于游戲中時,我們需要記錄用戶的信息,比如分數等。這些信息不需要記錄到數據庫,而且隨時會更新,放入緩存方便獲取 ROOM:可以理解為匹配的兩名用戶創建一個房間,具體實現是以鍵值對方式存儲,比如用戶 A 和用戶 B 匹配,用戶 A 的 userId 是 A,用戶 B 的 userId 是 B,則在 Redis 中記錄為 {A -- B},{B -- A}public enum EnumRedisKey { /** * userOnline 在線狀態 */ USER_STATUS, /** * userOnline 對局信息 */ USER_IN_PLAY, /** * userOnline 匹配信息 */ USER_MATCH_INFO, /** * 房間 */ ROOM; public String getKey() {return this.name(); }}
創建一個工具類,用于操作 Redis 中的數據。
@Componentpublic class MatchCacheUtil { /** * 用戶 userId 為 key,ChatWebsocket 為 value */ private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>(); /** * key 是標識存儲用戶在線狀態的 EnumRedisKey,value 為 map 類型,其中用戶 userId 為 key,用戶在線狀態 為 value */ @Resource private RedisTemplate<String, Map<String, String>> redisTemplate; /** * 添加客戶端 */ public void addClient(String userId, ChatWebsocket websocket) {CLIENTS.put(userId, websocket); } /** * 移除客戶端 */ public void removeClinet(String userId) {CLIENTS.remove(userId); } /** * 獲取客戶端 */ public ChatWebsocket getClient(String userId) {return CLIENTS.get(userId); } /** * 移除用戶在線狀態 */ public void removeUserOnlineStatus(String userId) {redisTemplate.opsForHash().delete(EnumRedisKey.USER_STATUS.getKey(), userId); } /** * 獲取用戶在線狀態 */ public StatusEnum getUserOnlineStatus(String userId) {Object status = redisTemplate.opsForHash().get(EnumRedisKey.USER_STATUS.getKey(), userId);if (status == null) { return null;}return StatusEnum.getStatusEnum(status.toString()); } /** * 設置用戶為 IDLE 狀態 */ public void setUserIDLE(String userId) {removeUserOnlineStatus(userId);redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IDLE.getValue()); } /** * 設置用戶為 IN_MATCH 狀態 */ public void setUserInMatch(String userId) {removeUserOnlineStatus(userId);redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_MATCH.getValue()); } /** * 隨機獲取處于匹配狀態的用戶(除了指定用戶外) */ public String getUserInMatchRandom(String userId) {Optional<Map.Entry<Object, Object>> any = redisTemplate.opsForHash().entries(EnumRedisKey.USER_STATUS.getKey()).entrySet().stream().filter(entry -> entry.getValue().equals(StatusEnum.IN_MATCH.getValue()) && !entry.getKey().equals(userId)).findAny();return any.map(entry -> entry.getKey().toString()).orElse(null); } /** * 設置用戶為 IN_GAME 狀態 */ public void setUserInGame(String userId) {removeUserOnlineStatus(userId);redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_GAME.getValue()); } /** * 設置處于游戲中的用戶在同一房間 */ public void setUserInRoom(String userId1, String userId2) {redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId1, userId2);redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId2, userId1); } /** * 從房間中移除用戶 */ public void removeUserFromRoom(String userId) {redisTemplate.opsForHash().delete(EnumRedisKey.ROOM.getKey(), userId); } /** * 從房間中獲取用戶 */ public String getUserFromRoom(String userId) {return redisTemplate.opsForHash().get(EnumRedisKey.ROOM.getKey(), userId).toString(); } /** * 設置處于游戲中的用戶的對戰信息 */ public void setUserMatchInfo(String userId, String userMatchInfo) {redisTemplate.opsForHash().put(EnumRedisKey.USER_MATCH_INFO.getKey(), userId, userMatchInfo); } /** * 移除處于游戲中的用戶的對戰信息 */ public void removeUserMatchInfo(String userId) {redisTemplate.opsForHash().delete(EnumRedisKey.USER_MATCH_INFO.getKey(), userId); } /** * 設置處于游戲中的用戶的對戰信息 */ public String getUserMatchInfo(String userId) {return redisTemplate.opsForHash().get(EnumRedisKey.USER_MATCH_INFO.getKey(), userId).toString(); } /** * 設置用戶為游戲結束狀態 */ public synchronized void setUserGameover(String userId) {removeUserOnlineStatus(userId);redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.GAME_OVER.getValue()); }}
4. 如何匹配用戶?
匹配用戶的思路之前已經提到過,為了不阻塞客戶端與服務端的 WebSocket 連接,創建一個線程專門用來匹配用戶,如果匹配成功就向客戶端推送消息
用戶匹配對手時遵循這么一個原則:用戶 A 找到用戶 B,由用戶 A 負責一切工作,既由用戶 A 完成創建匹配數據并保存到緩存的全部操作。值得注意的一點是,在匹配時要注意保證狀態的變化:
當前用戶在匹配對手的同時,被其他用戶匹配,那么當前用戶應當停止匹配操作 當前用戶匹配到對手,但對手被其他用戶匹配了,那么當前用戶應該重新尋找新的對手用戶匹配對手的過程應該保證原子性,使用 Java 鎖來保證
/** * 用戶隨機匹配對手 */@SneakyThrowsprivate void matchUser(JSONObject jsonObject) { log.info('ChatWebsocket matchUser 用戶隨機匹配對手開始 message: {}, userId: {}', jsonObject.toJSONString(), userId); MessageReply<GameMatchInfo> messageReply = new MessageReply<>(); ChatMessage<GameMatchInfo> result = new ChatMessage<>(); result.setSender(userId); result.setType(MessageTypeEnum.MATCH_USER); lock.lock(); try {// 設置用戶狀態為匹配中matchCacheUtil.setUserInMatch(userId);matchCond.signal(); } finally {lock.unlock(); } // 創建一個異步線程任務,負責匹配其他同樣處于匹配狀態的其他用戶 Thread matchThread = new Thread(() -> {boolean flag = true;String receiver = null;while (flag) { // 獲取除自己以外的其他待匹配用戶 lock.lock(); try {// 當前用戶不處于待匹配狀態if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IN_GAME) == 0 || matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.GAME_OVER) == 0) { log.info('ChatWebsocket matchUser 當前用戶 {} 已退出匹配', userId); return;}// 當前用戶取消匹配狀態if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IDLE) == 0) { // 當前用戶取消匹配 messageReply.setCode(MessageCode.CANCEL_MATCH_ERROR.getCode()); messageReply.setDesc(MessageCode.CANCEL_MATCH_ERROR.getDesc()); Set<String> set = new HashSet<>(); set.add(userId); result.setReceivers(set); result.setType(MessageTypeEnum.CANCEL_MATCH); messageReply.setChatMessage(result); log.info('ChatWebsocket matchUser 當前用戶 {} 已退出匹配', userId); sendMessageAll(messageReply); return;}receiver = matchCacheUtil.getUserInMatchRandom(userId);if (receiver != null) { // 對手不處于待匹配狀態 if (matchCacheUtil.getUserOnlineStatus(receiver).compareTo(StatusEnum.IN_MATCH) != 0) {log.info('ChatWebsocket matchUser 當前用戶 {}, 匹配對手 {} 已退出匹配狀態', userId, receiver); } else {matchCacheUtil.setUserInGame(userId);matchCacheUtil.setUserInGame(receiver);matchCacheUtil.setUserInRoom(userId, receiver);flag = false; }} else { // 如果當前沒有待匹配用戶,進入等待隊列 try {log.info('ChatWebsocket matchUser 當前用戶 {} 無對手可匹配', userId);matchCond.await(); } catch (InterruptedException e) {log.error('ChatWebsocket matchUser 匹配線程 {} 發生異常: {}', Thread.currentThread().getName(), e.getMessage()); }} } finally {lock.unlock(); }}UserMatchInfo senderInfo = new UserMatchInfo();UserMatchInfo receiverInfo = new UserMatchInfo();senderInfo.setUserId(userId);senderInfo.setScore(0);receiverInfo.setUserId(receiver);receiverInfo.setScore(0);matchCacheUtil.setUserMatchInfo(userId, JSON.toJSONString(senderInfo));matchCacheUtil.setUserMatchInfo(receiver, JSON.toJSONString(receiverInfo));GameMatchInfo gameMatchInfo = new GameMatchInfo();List<Question> questions = questionSev.getAllQuestion();gameMatchInfo.setQuestions(questions);gameMatchInfo.setSelfInfo(senderInfo);gameMatchInfo.setOpponentInfo(receiverInfo);messageReply.setCode(MessageCode.SUCCESS.getCode());messageReply.setDesc(MessageCode.SUCCESS.getDesc());result.setData(gameMatchInfo);Set<String> set = new HashSet<>();set.add(userId);result.setReceivers(set);result.setType(MessageTypeEnum.MATCH_USER);messageReply.setChatMessage(result);sendMessageAll(messageReply);gameMatchInfo.setSelfInfo(receiverInfo);gameMatchInfo.setOpponentInfo(senderInfo);result.setData(gameMatchInfo);set.clear();set.add(receiver);result.setReceivers(set);messageReply.setChatMessage(result);sendMessageAll(messageReply);log.info('ChatWebsocket matchUser 用戶隨機匹配對手結束 messageReply: {}', JSON.toJSONString(messageReply)); }, CommonField.MATCH_TASK_NAME_PREFIX + userId); matchThread.start();}項目展示
項目代碼如下:https://github.com/Yee-Q/match-project
跑起來后,使用 websocket-client 可以進行測試。在瀏覽器打開,在控制臺查看消息。
在連接輸入框隨便輸入一個數字作為 userId,點擊連接,此時客戶端就和服務端建立 WebSocket 連接了
點擊加入用戶按鈕,用戶“進入匹配大廳”
點擊隨機匹配按鈕,開始匹配,再取消匹配
按照之前的步驟再建立一個用戶連接,都點擊隨機匹配按鈕,匹配成功,服務端返回響應信息
用戶分數更新時,在輸入框輸入新的分數,比如 6,點擊實時更新按鈕,對手將受到最新的分數消息
當雙方都點擊游戲結束按鈕,則游戲結束
以上就是SpringBoot + WebSocket 實現答題對戰匹配機制案例詳解的詳細內容,更多關于SpringBoot WebSocket答題對戰的資料請關注好吧啦網其它相關文章!
相關文章: