SpringBoot + WebSocket 實(shí)現(xiàn)答題對(duì)戰(zhàn)匹配機(jī)制案例詳解
概要設(shè)計(jì)
類似競(jìng)技問答游戲:用戶隨機(jī)匹配一名對(duì)手,雙方同時(shí)開始答題,直到雙方都完成答題,對(duì)局結(jié)束?;镜倪壿嬀褪沁@樣,如果有其他需求,可以在其基礎(chǔ)上進(jìn)行擴(kuò)展
明確了這一點(diǎn),下面介紹開發(fā)思路。為每個(gè)用戶擬定四種在線狀態(tài),分別是:待匹配、匹配中、游戲中、游戲結(jié)束。下面是流程圖,用戶的流程是被規(guī)則約束的,狀態(tài)也隨流程而變化
對(duì)流程再補(bǔ)充如下:
- 用戶進(jìn)入匹配大廳(具體效果如何由客戶端體現(xiàn)),將用戶的狀態(tài)設(shè)置為待匹配
- 用戶開始匹配,將用戶的狀態(tài)設(shè)置為匹配中,系統(tǒng)搜索其他同樣處于匹配中的用戶,在這個(gè)過程中,用戶可以取消匹配,返回匹配大廳,此時(shí)用戶狀態(tài)重新設(shè)置為待匹配。匹配成功,保存匹配信息,將用戶狀態(tài)設(shè)置為游戲中
- 根據(jù)已保存的匹配信息,用戶可以獲得對(duì)手的信息。答題是時(shí),每次用戶分?jǐn)?shù)更新,也會(huì)向?qū)κ滞扑透潞蟮姆謹(jǐn)?shù)
- 用戶完成答題,則等待對(duì)手也完成答題。雙方都完成答題,用戶狀態(tài)設(shè)置為游戲結(jié)束,展示對(duì)局結(jié)果
詳細(xì)設(shè)計(jì)
針對(duì)概要設(shè)計(jì)提出的思路,我們需要思考以下幾個(gè)問題:
- 如何保持客戶端與服務(wù)器的連接?
- 如何設(shè)計(jì)客戶端與服務(wù)端的消息交互?
- 如何保存以及改變用戶狀態(tài)?
- 如何匹配用戶?
下面我們一個(gè)一個(gè)來解決
1. 如何保持用戶與服務(wù)器的連接?
以往我們使用 Http 請(qǐng)求服務(wù)器,并獲取響應(yīng)信息。然而 Http 有個(gè)缺陷,就是通信只能由客戶端發(fā)起,無法做到服務(wù)端主動(dòng)向客戶端推送信息。根據(jù)概要設(shè)計(jì)我們知道,服務(wù)端需要向客戶端推送對(duì)手的實(shí)時(shí)分?jǐn)?shù),因此這里不適合使用 Http,而選擇了 WebSocket。WebSocket 最大的特點(diǎn)就是服務(wù)端可以主動(dòng)向客戶端推送信息,客戶端也可以主動(dòng)向服務(wù)端發(fā)送信息,是真正的雙向平等對(duì)話
有關(guān) SpringBoot 集成 WebSocket 可參考這篇博客:http://www.dbjr.com.cn/article/208279.htm
2. 如何設(shè)計(jì)客戶端與服務(wù)端的消息交互?
按照匹配機(jī)制要求,把消息劃分為 ADD_USER(用戶加入)、MATCH_USER(匹配對(duì)手)、CANCEL_MATCH(取消匹配)、PLAY_GAME(游戲開始)、GAME_OVER(游戲結(jié)束)
public enum MessageTypeEnum { /** * 用戶加入 */ ADD_USER, /** * 匹配對(duì)手 */ MATCH_USER, /** * 取消匹配 */ CANCEL_MATCH, /** * 游戲開始 */ PLAY_GAME, /** * 游戲結(jié)束 */ GAME_OVER, }
使用 WebSocket 客戶端可以向服務(wù)端發(fā)送消息,服務(wù)端也能向客戶端發(fā)送消息。把消息按照需求劃分成不同的類型,客戶端發(fā)送某一類型的消息,服務(wù)端接收后判斷,并按照類型分別處理,最后返回向客戶端推送處理結(jié)果。區(qū)別客戶端 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 發(fā)生了錯(cuò)誤 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: {} 消息接收結(jié)束", userId); } /** * 群發(fā)消息 */ private void sendMessageAll(MessageReply<?> messageReply) { log.info("ChatWebsocket sendMessageAll 消息群發(fā)開始 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 消息群發(fā)結(jié)束 userId: {}", userId); } // 出于減少篇幅的目的,業(yè)務(wù)處理方法暫不貼出... }
3. 如何保存以及改變用戶狀態(tài)?
創(chuàng)建一個(gè)枚舉類,定義用戶的狀態(tài)
/** * 用戶狀態(tài) * @author yeeq */ public enum StatusEnum { /** * 待匹配 */ IDLE, /** * 匹配中 */ IN_MATCH, /** * 游戲中 */ IN_GAME, /** * 游戲結(jié)束 */ 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 保存用戶狀態(tài),還是創(chuàng)建一個(gè)枚舉類,Redis 中存儲(chǔ)數(shù)據(jù)都有唯一的 Key 做標(biāo)識(shí),因此在這里定義 Redis 中的 Key,分別介紹如下:
- USER_STATUS:存儲(chǔ)用戶狀態(tài)的 Key,存儲(chǔ)類型是 Map<String, String>,其中用戶 userId 為 key,用戶在線狀態(tài) 為 value
- USER_MATCH_INFO:當(dāng)用戶處于游戲中時(shí),我們需要記錄用戶的信息,比如分?jǐn)?shù)等。這些信息不需要記錄到數(shù)據(jù)庫,而且隨時(shí)會(huì)更新,放入緩存方便獲取
- ROOM:可以理解為匹配的兩名用戶創(chuàng)建一個(gè)房間,具體實(shí)現(xiàn)是以鍵值對(duì)方式存儲(chǔ),比如用戶 A 和用戶 B 匹配,用戶 A 的 userId 是 A,用戶 B 的 userId 是 B,則在 Redis 中記錄為 {A -- B},{B -- A}
public enum EnumRedisKey { /** * userOnline 在線狀態(tài) */ USER_STATUS, /** * userOnline 對(duì)局信息 */ USER_IN_PLAY, /** * userOnline 匹配信息 */ USER_MATCH_INFO, /** * 房間 */ ROOM; public String getKey() { return this.name(); } }
創(chuàng)建一個(gè)工具類,用于操作 Redis 中的數(shù)據(jù)。
@Component public class MatchCacheUtil { /** * 用戶 userId 為 key,ChatWebsocket 為 value */ private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>(); /** * key 是標(biāo)識(shí)存儲(chǔ)用戶在線狀態(tài)的 EnumRedisKey,value 為 map 類型,其中用戶 userId 為 key,用戶在線狀態(tài) 為 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); } /** * 移除用戶在線狀態(tài) */ public void removeUserOnlineStatus(String userId) { redisTemplate.opsForHash().delete(EnumRedisKey.USER_STATUS.getKey(), userId); } /** * 獲取用戶在線狀態(tài) */ 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()); } /** * 設(shè)置用戶為 IDLE 狀態(tài) */ public void setUserIDLE(String userId) { removeUserOnlineStatus(userId); redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IDLE.getValue()); } /** * 設(shè)置用戶為 IN_MATCH 狀態(tài) */ public void setUserInMatch(String userId) { removeUserOnlineStatus(userId); redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_MATCH.getValue()); } /** * 隨機(jī)獲取處于匹配狀態(tài)的用戶(除了指定用戶外) */ 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); } /** * 設(shè)置用戶為 IN_GAME 狀態(tài) */ public void setUserInGame(String userId) { removeUserOnlineStatus(userId); redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IN_GAME.getValue()); } /** * 設(shè)置處于游戲中的用戶在同一房間 */ 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(); } /** * 設(shè)置處于游戲中的用戶的對(duì)戰(zhàn)信息 */ public void setUserMatchInfo(String userId, String userMatchInfo) { redisTemplate.opsForHash().put(EnumRedisKey.USER_MATCH_INFO.getKey(), userId, userMatchInfo); } /** * 移除處于游戲中的用戶的對(duì)戰(zhàn)信息 */ public void removeUserMatchInfo(String userId) { redisTemplate.opsForHash().delete(EnumRedisKey.USER_MATCH_INFO.getKey(), userId); } /** * 設(shè)置處于游戲中的用戶的對(duì)戰(zhàn)信息 */ public String getUserMatchInfo(String userId) { return redisTemplate.opsForHash().get(EnumRedisKey.USER_MATCH_INFO.getKey(), userId).toString(); } /** * 設(shè)置用戶為游戲結(jié)束狀態(tài) */ public synchronized void setUserGameover(String userId) { removeUserOnlineStatus(userId); redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.GAME_OVER.getValue()); } }
4. 如何匹配用戶?
匹配用戶的思路之前已經(jīng)提到過,為了不阻塞客戶端與服務(wù)端的 WebSocket 連接,創(chuàng)建一個(gè)線程專門用來匹配用戶,如果匹配成功就向客戶端推送消息
用戶匹配對(duì)手時(shí)遵循這么一個(gè)原則:用戶 A 找到用戶 B,由用戶 A 負(fù)責(zé)一切工作,既由用戶 A 完成創(chuàng)建匹配數(shù)據(jù)并保存到緩存的全部操作。值得注意的一點(diǎn)是,在匹配時(shí)要注意保證狀態(tài)的變化:
- 當(dāng)前用戶在匹配對(duì)手的同時(shí),被其他用戶匹配,那么當(dāng)前用戶應(yīng)當(dāng)停止匹配操作
- 當(dāng)前用戶匹配到對(duì)手,但對(duì)手被其他用戶匹配了,那么當(dāng)前用戶應(yīng)該重新尋找新的對(duì)手
用戶匹配對(duì)手的過程應(yīng)該保證原子性,使用 Java 鎖來保證
/** * 用戶隨機(jī)匹配對(duì)手 */ @SneakyThrows private void matchUser(JSONObject jsonObject) { log.info("ChatWebsocket matchUser 用戶隨機(jī)匹配對(duì)手開始 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 { // 設(shè)置用戶狀態(tài)為匹配中 matchCacheUtil.setUserInMatch(userId); matchCond.signal(); } finally { lock.unlock(); } // 創(chuàng)建一個(gè)異步線程任務(wù),負(fù)責(zé)匹配其他同樣處于匹配狀態(tài)的其他用戶 Thread matchThread = new Thread(() -> { boolean flag = true; String receiver = null; while (flag) { // 獲取除自己以外的其他待匹配用戶 lock.lock(); try { // 當(dāng)前用戶不處于待匹配狀態(tài) if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IN_GAME) == 0 || matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.GAME_OVER) == 0) { log.info("ChatWebsocket matchUser 當(dāng)前用戶 {} 已退出匹配", userId); return; } // 當(dāng)前用戶取消匹配狀態(tài) if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IDLE) == 0) { // 當(dāng)前用戶取消匹配 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 當(dāng)前用戶 {} 已退出匹配", userId); sendMessageAll(messageReply); return; } receiver = matchCacheUtil.getUserInMatchRandom(userId); if (receiver != null) { // 對(duì)手不處于待匹配狀態(tài) if (matchCacheUtil.getUserOnlineStatus(receiver).compareTo(StatusEnum.IN_MATCH) != 0) { log.info("ChatWebsocket matchUser 當(dāng)前用戶 {}, 匹配對(duì)手 {} 已退出匹配狀態(tài)", userId, receiver); } else { matchCacheUtil.setUserInGame(userId); matchCacheUtil.setUserInGame(receiver); matchCacheUtil.setUserInRoom(userId, receiver); flag = false; } } else { // 如果當(dāng)前沒有待匹配用戶,進(jìn)入等待隊(duì)列 try { log.info("ChatWebsocket matchUser 當(dāng)前用戶 {} 無對(duì)手可匹配", userId); matchCond.await(); } catch (InterruptedException e) { log.error("ChatWebsocket matchUser 匹配線程 {} 發(fā)生異常: {}", 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 用戶隨機(jī)匹配對(duì)手結(jié)束 messageReply: {}", JSON.toJSONString(messageReply)); }, CommonField.MATCH_TASK_NAME_PREFIX + userId); matchThread.start(); }
項(xiàng)目展示
項(xiàng)目代碼如下:https://github.com/Yee-Q/match-project
跑起來后,使用 websocket-client 可以進(jìn)行測(cè)試。在瀏覽器打開,在控制臺(tái)查看消息。
在連接輸入框隨便輸入一個(gè)數(shù)字作為 userId,點(diǎn)擊連接,此時(shí)客戶端就和服務(wù)端建立 WebSocket 連接了
點(diǎn)擊加入用戶按鈕,用戶“進(jìn)入匹配大廳”
點(diǎn)擊隨機(jī)匹配按鈕,開始匹配,再取消匹配
按照之前的步驟再建立一個(gè)用戶連接,都點(diǎn)擊隨機(jī)匹配按鈕,匹配成功,服務(wù)端返回響應(yīng)信息
用戶分?jǐn)?shù)更新時(shí),在輸入框輸入新的分?jǐn)?shù),比如 6,點(diǎn)擊實(shí)時(shí)更新按鈕,對(duì)手將受到最新的分?jǐn)?shù)消息
當(dāng)雙方都點(diǎn)擊游戲結(jié)束按鈕,則游戲結(jié)束
以上就是SpringBoot + WebSocket 實(shí)現(xiàn)答題對(duì)戰(zhàn)匹配機(jī)制案例詳解的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot WebSocket答題對(duì)戰(zhàn)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Spring Web MVC和Hibernate的集成配置詳解
這篇文章主要介紹了Spring Web MVC和Hibernate的集成配置詳解,具有一定借鑒價(jià)值,需要的朋友可以參考下2017-12-12基于Java匯總Spock框架Mock靜態(tài)資源經(jīng)驗(yàn)
這篇文章主要介紹了基于Java匯總Spock框架Mock靜態(tài)資源經(jīng)驗(yàn),前面講了?Spock框架Mock對(duì)象、方法經(jīng)驗(yàn)總結(jié),今天分享一下Spock框架中Mock靜態(tài)資源的實(shí)踐經(jīng)驗(yàn)匯總。分成靜態(tài)資源和混合場(chǎng)景,需要的朋友可以參考一下2022-02-02SpringBoot ApplicationContextAware拓展接口使用詳解
當(dāng)一個(gè)類實(shí)現(xiàn)了這個(gè)接口(ApplicationContextAware)之后,這個(gè)類就可以方便獲得ApplicationContext中的所有bean。換句話說,就是這個(gè)類可以直接獲取spring配置文件中,所有有引用到的bean對(duì)象2023-04-04Java使用默認(rèn)瀏覽器打開指定URL的方法(二種方法)
Java使用默認(rèn)瀏覽器打開指定URL。2013-10-10mybaits-plus?lambdaQuery()?和?lambdaUpdate()?常見的使用方法
MyBatis-Plus是一個(gè)?MyBatis?(opens?new?window)的增強(qiáng)工具,在?MyBatis?的基礎(chǔ)上只做增強(qiáng)不做改變,為簡(jiǎn)化開發(fā)、提高效率而生,這篇文章主要介紹了mybaits-plus?lambdaQuery()?和?lambdaUpdate()?比較常見的使用方法,需要的朋友可以參考下2023-01-01Java 超詳細(xì)講解IO操作字節(jié)流與字符流
本章具體介紹了字節(jié)流、字符流的基本使用方法,圖解穿插代碼實(shí)現(xiàn)。 JAVA從基礎(chǔ)開始講,后續(xù)會(huì)講到JAVA高級(jí),中間會(huì)穿插面試題和項(xiàng)目實(shí)戰(zhàn),希望能給大家?guī)韼椭?/div> 2022-03-03SpringBoot + Spring Security 基本使用及個(gè)性化登錄配置詳解
這篇文章主要介紹了SpringBoot + Spring Security 基本使用及個(gè)性化登錄配置詳解,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2018-05-05詳解Java的Hibernate框架中的set映射集與SortedSet映射
這篇文章主要介紹了詳解Java的Hibernate框架中的set映射集與SortedSet映射,Hibernate是Java的SSH三大web開發(fā)框架之一,需要的朋友可以參考下2015-12-12最新評(píng)論