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,
}
使用 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 中的数据。
@Component
public 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 锁来保证
/**
* 用户随机匹配对手
*/
@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+JWT实现的即时通讯模块
场景 目前做了一个接口:邀请用户成为某课程的管理员,于是我感觉有能在用户被邀请之后能有个立马通知他本人的机(类似微博.朋友圈被点赞后就有立马能收到通知一样),于是就闲来没事搞了一套. 涉及技术栈 ...
- SpringBoot+WebSocket
SpringBoot+WebSocket 只需三个步骤 导入依赖 <dependency> <groupId>org.springframework.boot</grou ...
- springboot+websocket+sockjs进行消息推送【基于STOMP协议】
springboot+websocket+sockjs进行消息推送[基于STOMP协议] WebSocket是在HTML5基础上单个TCP连接上进行全双工通讯的协议,只要浏览器和服务器进行一次握手,就 ...
- SpringBoot WebSocket STOMP 广播配置
目录 1. 前言 2. STOMP协议 3. SpringBoot WebSocket集成 3.1 导入websocket包 3.2 配置WebSocket 3.3 对外暴露接口 4. 前端对接测试 ...
- Java Springboot webSocket简单实现,调接口推送消息到客户端socket
Java Springboot webSocket简单实现,调接口推送消息到客户端socket 后台一般作为webSocket服务器,前台作为client.真实场景可能是后台程序在运行时(满足一定条件 ...
- SpringBoot缓存篇Ⅱ --- 整合Redis以及序列化机制
一.Redis环境搭建 系统默认是使用ConcurrentMapCacheManager,然后获取和创建ConcurrentMapCache类型的缓存组件,再将数据保存在ConcurrentMap中 ...
- SpringBoot WebSocket 消息交互
1. Websocket原理 Websocket协议本质上是一个基于TCP的独立协议,能够在浏览器和服务器之间建立双向连接,以基于消息的机制,赋予浏览器和服务器间实时通信能力. WebSocket资源 ...
- Springboot+websocket+定时器实现消息推送
由于最近有个需求,产品即将到期(不同时间段到期)时给后台用户按角色推送,功能完成之后在此做个小结 1. 在启动类中添加注解@EnableScheduling package com.hsfw.back ...
- springboot+websocket 归纳收集
websocket是h5后的技术,主要实现是一个长连接跟tomcat的comet技术差不多,但websocket是基于web协议的,有更广泛的支持.当然,在处理高并发的情况下,可以结合tomcat的a ...
随机推荐
- Linux保护机制和绕过方式
Linux保护机制和绕过方式 CANNARY(栈保护) 栈溢出保护是一种缓冲区溢出攻击缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让shellcode能够得到执行.用C ...
- 西门子PLC开发笔记(一):PLC介绍,西门子S1200系列接线、编程、下载和仿真
前言 西门西PLC.台达触摸屏.法兰克机床等等多年以前玩得比较多,改造机床.维修机床.给机床编程等等,没事还能扯个零件啥的,之前也没总结过,有时间就重新整理下. 本章后面以西门1200实物为例, ...
- Day14_84_通过反射机制修改和获取class里的属性值
通过反射机制修改和获取class里的属性值 * 属性对象.set(Object,属性值) 给Object对象中的某个属性赋值(属性对象) * 属性对象.get(Object); 获取Object对象中 ...
- ORM 创新解放劳动力 -SqlSugar 新功能介绍
介绍 SqlSugar是一款 老牌 .NET 开源ORM框架,由果糖大数据科技团队维护和更新 ,Github star数仅次于EF 和 Dapper 优点: 简单易用.功能齐全.高性能.轻量级.服务齐 ...
- 【软件工程】《构建之法》 & Git+ & CI/CD
<构建之法> & Git+ & CI/CD 个人阅读作业#2 项目 内容 本作业所属课程 2020春季软件工程(罗杰 任健) 本作业要求 个人阅读作业#2 我的课程目标 具 ...
- 5403. Find the Kth Smallest Sum of a Matrix With Sorted Rows
You are given an m * n matrix, mat, and an integer k, which has its rows sorted in non-decreasing or ...
- 13- APP接口测试以及postman使用
postman安装与操作 ---------------------- 接口操作图片 -------------------- 一.postman操作key值:来源于聚合 请求-->聚合-- ...
- PHP laravel系列之Blade模版
一.什么是Blade模版? Blade 是 Laravel 提供的一个既简单又强大的模板引擎. 和其他流行的 PHP 模板引擎不一样,Blade 并不限制你在视图中使用原生 PHP 代码.所有 Bla ...
- POJ2570 二进制,位运算,Floyd
题意: 给你一个有向图,两点之间有多种连接方式,然后每次询问都问你点A,B之间有哪些方式可以到达,每个小字母是一个方式. 思路: 很巧妙的位运算和Floyd应用,借助Floyd ...
- UVA10382喷水装置
题意: 给你一个矩形的空地,然后有一些圆形的喷水装置,每个装置的圆心都在矩形宽的中间位置,然偶给你每个矩形的圆心位置和半径,问你最少多少个喷水装置可以把矩形的所有编辑都覆盖上. 思路: ...