文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

SpringBoot + WebSocket 实现答题对战匹配机制案例详解

2024-04-02 19:55

关注

概要设计

类似竞技问答游戏:用户随机匹配一名对手,双方同时开始答题,直到双方都完成答题,对局结束。基本的逻辑就是这样,如果有其他需求,可以在其基础上进行扩展

明确了这一点,下面介绍开发思路。为每个用户拟定四种在线状态,分别是:待匹配、匹配中、游戏中、游戏结束。下面是流程图,用户的流程是被规则约束的,状态也随流程而变化

对流程再补充如下:

详细设计

针对概要设计提出的思路,我们需要思考以下几个问题:

下面我们一个一个来解决

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. 如何保存以及改变用户状态?

创建一个枚举类,定义用户的状态



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,分别介绍如下:


public enum EnumRedisKey {

    
    USER_STATUS,
    
    USER_IN_PLAY,
    
    USER_MATCH_INFO,
    
    ROOM;

    public String getKey() {
        return this.name();
    }
}

创建一个工具类,用于操作 Redis 中的数据。


@Component
public class MatchCacheUtil {

    
    private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>();

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

    
    public void setUserIDLE(String userId) {
        removeUserOnlineStatus(userId);
        redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(), userId, StatusEnum.IDLE.getValue());
    }

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

    
    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 锁来保证



@SneakyThrows
private 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答题对战的资料请关注编程网其它相关文章!

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     813人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     354人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     318人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     435人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     224人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯