springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制


概要设计类似竞技问答游戏:用户随机匹配一名对手 , 双方同时开始答题 , 直到双方都完成答题 , 对局结束 。基本的逻辑就是这样 , 如果有其他需求 , 可以在其基础上进行扩展
明确了这一点 , 下面介绍开发思路 。为每个用户拟定四种在线状态 , 分别是:待匹配、匹配中、游戏中、游戏结束 。下面是流程图 , 用户的流程是被规则约束的 , 状态也随流程而变化

springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
对流程再补充如下:
  • 用户进入匹配大厅(具体效果如何由客户端体现) , 将用户的状态设置为待匹配
  • 用户开始匹配 , 将用户的状态设置为匹配中 , 系统搜索其他同样处于匹配中的用户 , 在这个过程中 , 用户可以取消匹配 , 返回匹配大厅 , 此时用户状态重新设置为待匹配 。匹配成功 , 保存匹配信息 , 将用户状态设置为游戏中
  • 根据已保存的匹配信息 , 用户可以获得对手的信息 。答题是时 , 每次用户分数更新 , 也会向对手推送更新后的分数
  • 用户完成答题 , 则等待对手也完成答题 。双方都完成答题 , 用户状态设置为游戏结束 , 展示对局结果

详细设计针对概要设计提出的思路 , 我们需要思考以下几个问题:
  • 如何保持客户端与服务器的连接?
  • 如何设计客户端与服务端的消息交互?
  • 如何保存以及改变用户状态?
  • 如何匹配用户?
下面我们一个一个来解决
1. 如何保持用户与服务器的连接?以往我们使用 Http 请求服务器 , 并获取响应信息 。然而 Http 有个缺陷 , 就是通信只能由客户端发起 , 无法做到服务端主动向客户端推送信息 。根据概要设计我们知道 , 服务端需要向客户端推送对手的实时分数 , 因此这里不适合使用 Http , 而选择了 WebSocket 。WebSocket 最大的特点就是服务端可以主动向客户端推送信息 , 客户端也可以主动向服务端发送信息 , 是真正的双向平等对话
有关 SpringBoot 集成 WebSocket 可参考这篇博客:https://blog.csdn.net/qq_35387940/article/details/93483678
2. 如何设计客户端与服务端的消息交互?按照匹配机制要求 , 把消息划分为 ADD_USER(用户加入)、MATCH_USER(匹配对手)、CANCEL_MATCH(取消匹配)、PLAY_GAME(游戏开始)、GAME_OVER(游戏结束)
public enum MessageTypeEnum {/*** 用户加入*/ADD_USER,/*** 匹配对手*/MATCH_USER,/*** 取消匹配*/CANCEL_MATCH,/*** 游戏开始*/PLAY_GAME,/*** 游戏结束*/GAME_OVER,}【springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制】使用 WebSocket 客户端可以向服务端发送消息 , 服务端也能向客户端发送消息 。把消息按照需求划分成不同的类型 , 客户端发送某一类型的消息 , 服务端接收后判断 , 并按照类型分别处理 , 最后返回向客户端推送处理结果 。区别客户端 WebSocket 连接的是从客户端传来的 userId , 用 HashMap 保存
@Component@Slf4j@ServerEndpoint(value = "https://tazarkount.com/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();@Autowiredpublic void setMatchCacheUtil(MatchCacheUtil matchCacheUtil) {ChatWebsocket.matchCacheUtil = matchCacheUtil;}@Autowiredpublic void setQuestionSev(QuestionSev questionSev) {ChatWebsocket.questionSev = questionSev;}@OnOpenpublic 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);}@OnErrorpublic 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);}@OnClosepublic 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);}@OnMessagepublic 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*/@Resourceprivate 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 连接了
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
点击加入用户按钮 , 用户“进入匹配大厅”
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
点击随机匹配按钮 , 开始匹配 , 再取消匹配
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
按照之前的步骤再建立一个用户连接 , 都点击随机匹配按钮 , 匹配成功 , 服务端返回响应信息
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
用户分数更新时 , 在输入框输入新的分数 , 比如 6 , 点击实时更新按钮 , 对手将受到最新的分数消息
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图
当双方都点击游戏结束按钮 , 则游戏结束
springboot启动流程 SpringBoot + WebSocket 实现答题对战匹配机制

文章插图