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

小编给大家分享一下SpringBoot + WebSocket如何实现答题对战匹配机制案例详解,希望大家阅读完这篇文章之后都有所收获,下面让我们一起去探讨吧!

概要设计

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

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

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

对流程再补充如下:

  • 用户进入匹配大厅(具体效果如何由客户端体现),将用户的状态设置为待匹配

  • 用户开始匹配,将用户的状态设置为匹配中,系统搜索其他同样处于匹配中的用户,在这个过程中,用户可以取消匹配,返回匹配大厅,此时用户状态重新设置为待匹配。匹配成功,保存匹配信息,将用户状态设置为游戏中

  • 根据已保存的匹配信息,用户可以获得对手的信息。答题是时,每次用户分数更新,也会向对手推送更新后的分数

  • 用户完成答题,则等待对手也完成答题。双方都完成答题,用户状态设置为游戏结束,展示对局结果

详细设计

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

  • 如何保持客户端与服务器的连接?

  • 如何设计客户端与服务端的消息交互?

  • 如何保存以及改变用户状态?

  • 如何匹配用户?

下面我们一个一个来解决

1. 如何保持用户与服务器的连接?

以往我们使用 Http 请求服务器,并获取响应信息。然而 Http 有个缺陷,就是通信只能由客户端发起,无法做到服务端主动向客户端推送信息。根据概要设计我们知道,服务端需要向客户端推送对手的实时分数,因此这里不适合使用 Http,而选择了 WebSocket。WebSocket 最大的特点就是服务端可以主动向客户端推送信息,客户端也可以主动向服务端发送信息,是真正的双向平等对话

有关 SpringBoot 集成 WebSocket 可参考这篇博客:https://www.yisu.com/article/208279.htm

2. 如何设计客户端与服务端的消息交互?

按照匹配机制要求,把消息划分为 ADD_USER(用户加入)、MATCH_USER(匹配对手)、CANCEL_MATCH(取消匹配)、PLAY_GAME(游戏开始)、GAME_OVER(游戏结束)

publicenumMessageTypeEnum{

/**
*用户加入
*/
ADD_USER,
/**
*匹配对手
*/
MATCH_USER,
/**
*取消匹配
*/
CANCEL_MATCH,
/**
*游戏开始
*/
PLAY_GAME,
/**
*游戏结束
*/
GAME_OVER,
}

使用 WebSocket 客户端可以向服务端发送消息,服务端也能向客户端发送消息。把消息按照需求划分成不同的类型,客户端发送某一类型的消息,服务端接收后判断,并按照类型分别处理,最后返回向客户端推送处理结果。区别客户端 WebSocket 连接的是从客户端传来的 userId,用 HashMap 保存

@Component
@Slf4j
@ServerEndpoint(value="/game/match/{userId}")
publicclassChatWebsocket{

privateSessionsession;

privateStringuserId;

staticQuestionSevquestionSev;
staticMatchCacheUtilmatchCacheUtil;

staticLocklock=newReentrantLock();

staticConditionmatchCond=lock.newCondition();

@Autowired
publicvoidsetMatchCacheUtil(MatchCacheUtilmatchCacheUtil){
ChatWebsocket.matchCacheUtil=matchCacheUtil;
}

@Autowired
publicvoidsetQuestionSev(QuestionSevquestionSev){
ChatWebsocket.questionSev=questionSev;
}

@OnOpen
publicvoidonOpen(@PathParam("userId")StringuserId,Sessionsession){

log.info("ChatWebsocketopen有新连接加入userId:{}",userId);

this.userId=userId;
this.session=session;
matchCacheUtil.addClient(userId,this);

log.info("ChatWebsocketopen连接建立完成userId:{}",userId);
}

@OnError
publicvoidonError(Sessionsession,Throwableerror){

log.error("ChatWebsocketonError发生了错误userId:{},errorMessage:{}",userId,error.getMessage());

matchCacheUtil.removeClinet(userId);
matchCacheUtil.removeUserOnlineStatus(userId);
matchCacheUtil.removeUserFromRoom(userId);
matchCacheUtil.removeUserMatchInfo(userId);

log.info("ChatWebsocketonError连接断开完成userId:{}",userId);
}

@OnClose
publicvoidonClose()
{
log.info("ChatWebsocketonClose连接断开userId:{}",userId);

matchCacheUtil.removeClinet(userId);
matchCacheUtil.removeUserOnlineStatus(userId);
matchCacheUtil.removeUserFromRoom(userId);
matchCacheUtil.removeUserMatchInfo(userId);

log.info("ChatWebsocketonClose连接断开完成userId:{}",userId);
}

@OnMessage
publicvoidonMessage(Stringmessage,Sessionsession){

log.info("ChatWebsocketonMessageuserId:{},来自客户端的消息message:{}",userId,message);

JSONObjectjsonObject=JSON.parseObject(message);
MessageTypeEnumtype=jsonObject.getObject("type",MessageTypeEnum.class);

log.info("ChatWebsocketonMessageuserId:{},来自客户端的消息类型type:{}",userId,type);

if(type==MessageTypeEnum.ADD_USER){
addUser(jsonObject);
}elseif(type==MessageTypeEnum.MATCH_USER){
matchUser(jsonObject);
}elseif(type==MessageTypeEnum.CANCEL_MATCH){
cancelMatch(jsonObject);
}elseif(type==MessageTypeEnum.PLAY_GAME){
toPlay(jsonObject);
}elseif(type==MessageTypeEnum.GAME_OVER){
gameover(jsonObject);
}else{
thrownewGameServerException(GameServerError.WEBSOCKET_ADD_USER_FAILED);
}

log.info("ChatWebsocketonMessageuserId:{}消息接收结束",userId);
}

/**
*群发消息
*/
privatevoidsendMessageAll(MessageReply<?>messageReply){

log.info("ChatWebsocketsendMessageAll消息群发开始userId:{},messageReply:{}",userId,JSON.toJSONString(messageReply));

Set<String>receivers=messageReply.getChatMessage().getReceivers();
for(Stringreceiver:receivers){
ChatWebsocketclient=matchCacheUtil.getClient(receiver);
client.session.getAsyncRemote().sendText(JSON.toJSONString(messageReply));
}

log.info("ChatWebsocketsendMessageAll消息群发结束userId:{}",userId);
}

//出于减少篇幅的目的,业务处理方法暂不贴出...
}

3. 如何保存以及改变用户状态?

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

/**
*用户状态
*@authoryeeq
*/
publicenumStatusEnum{

/**
*待匹配
*/
IDLE,
/**
*匹配中
*/
IN_MATCH,
/**
*游戏中
*/
IN_GAME,
/**
*游戏结束
*/
GAME_OVER,
;

publicstaticStatusEnumgetStatusEnum(Stringstatus){
switch(status){
case"IDLE":
returnIDLE;
case"IN_MATCH":
returnIN_MATCH;
case"IN_GAME":
returnIN_GAME;
case"GAME_OVER":
returnGAME_OVER;
default:
thrownewGameServerException(GameServerError.MESSAGE_TYPE_ERROR);
}
}

publicStringgetValue(){
returnthis.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}

publicenumEnumRedisKey{

/**
*userOnline在线状态
*/
USER_STATUS,
/**
*userOnline对局信息
*/
USER_IN_PLAY,
/**
*userOnline匹配信息
*/
USER_MATCH_INFO,
/**
*房间
*/
ROOM;

publicStringgetKey(){
returnthis.name();
}
}

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

@Component
publicclassMatchCacheUtil{

/**
*用户userId为key,ChatWebsocket为value
*/
privatestaticfinalMap<String,ChatWebsocket>CLIENTS=newHashMap<>();

/**
*key是标识存储用户在线状态的EnumRedisKey,value为map类型,其中用户userId为key,用户在线状态为value
*/
@Resource
privateRedisTemplate<String,Map<String,String>>redisTemplate;

/**
*添加客户端
*/
publicvoidaddClient(StringuserId,ChatWebsocketwebsocket){
CLIENTS.put(userId,websocket);
}

/**
*移除客户端
*/
publicvoidremoveClinet(StringuserId){
CLIENTS.remove(userId);
}

/**
*获取客户端
*/
publicChatWebsocketgetClient(StringuserId){
returnCLIENTS.get(userId);
}

/**
*移除用户在线状态
*/
publicvoidremoveUserOnlineStatus(StringuserId){
redisTemplate.opsForHash().delete(EnumRedisKey.USER_STATUS.getKey(),userId);
}

/**
*获取用户在线状态
*/
publicStatusEnumgetUserOnlineStatus(StringuserId){
Objectstatus=redisTemplate.opsForHash().get(EnumRedisKey.USER_STATUS.getKey(),userId);
if(status==null){
returnnull;
}
returnStatusEnum.getStatusEnum(status.toString());
}

/**
*设置用户为IDLE状态
*/
publicvoidsetUserIDLE(StringuserId){
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(),userId,StatusEnum.IDLE.getValue());
}

/**
*设置用户为IN_MATCH状态
*/
publicvoidsetUserInMatch(StringuserId){
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(),userId,StatusEnum.IN_MATCH.getValue());
}

/**
*随机获取处于匹配状态的用户(除了指定用户外)
*/
publicStringgetUserInMatchRandom(StringuserId){
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();
returnany.map(entry->entry.getKey().toString()).orElse(null);
}

/**
*设置用户为IN_GAME状态
*/
publicvoidsetUserInGame(StringuserId){
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(),userId,StatusEnum.IN_GAME.getValue());
}

/**
*设置处于游戏中的用户在同一房间
*/
publicvoidsetUserInRoom(StringuserId1,StringuserId2){
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(),userId1,userId2);
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(),userId2,userId1);
}

/**
*从房间中移除用户
*/
publicvoidremoveUserFromRoom(StringuserId){
redisTemplate.opsForHash().delete(EnumRedisKey.ROOM.getKey(),userId);
}

/**
*从房间中获取用户
*/
publicStringgetUserFromRoom(StringuserId){
returnredisTemplate.opsForHash().get(EnumRedisKey.ROOM.getKey(),userId).toString();
}

/**
*设置处于游戏中的用户的对战信息
*/
publicvoidsetUserMatchInfo(StringuserId,StringuserMatchInfo){
redisTemplate.opsForHash().put(EnumRedisKey.USER_MATCH_INFO.getKey(),userId,userMatchInfo);
}

/**
*移除处于游戏中的用户的对战信息
*/
publicvoidremoveUserMatchInfo(StringuserId){
redisTemplate.opsForHash().delete(EnumRedisKey.USER_MATCH_INFO.getKey(),userId);
}

/**
*设置处于游戏中的用户的对战信息
*/
publicStringgetUserMatchInfo(StringuserId){
returnredisTemplate.opsForHash().get(EnumRedisKey.USER_MATCH_INFO.getKey(),userId).toString();
}

/**
*设置用户为游戏结束状态
*/
publicsynchronizedvoidsetUserGameover(StringuserId){
removeUserOnlineStatus(userId);
redisTemplate.opsForHash().put(EnumRedisKey.USER_STATUS.getKey(),userId,StatusEnum.GAME_OVER.getValue());
}
}

4. 如何匹配用户?

匹配用户的思路之前已经提到过,为了不阻塞客户端与服务端的 WebSocket 连接,创建一个线程专门用来匹配用户,如果匹配成功就向客户端推送消息

用户匹配对手时遵循这么一个原则:用户 A 找到用户 B,由用户 A 负责一切工作,既由用户 A 完成创建匹配数据并保存到缓存的全部操作。值得注意的一点是,在匹配时要注意保证状态的变化:

  • 当前用户在匹配对手的同时,被其他用户匹配,那么当前用户应当停止匹配操作

  • 当前用户匹配到对手,但对手被其他用户匹配了,那么当前用户应该重新寻找新的对手

用户匹配对手的过程应该保证原子性,使用 Java 锁来保证

/**
*用户随机匹配对手
*/
@SneakyThrows
privatevoidmatchUser(JSONObjectjsonObject){

log.info("ChatWebsocketmatchUser用户随机匹配对手开始message:{},userId:{}",jsonObject.toJSONString(),userId);

MessageReply<GameMatchInfo>messageReply=newMessageReply<>();
ChatMessage<GameMatchInfo>result=newChatMessage<>();
result.setSender(userId);
result.setType(MessageTypeEnum.MATCH_USER);

lock.lock();
try{
//设置用户状态为匹配中
matchCacheUtil.setUserInMatch(userId);
matchCond.signal();
}finally{
lock.unlock();
}

//创建一个异步线程任务,负责匹配其他同样处于匹配状态的其他用户
ThreadmatchThread=newThread(()->{
booleanflag=true;
Stringreceiver=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("ChatWebsocketmatchUser当前用户{}已退出匹配",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=newHashSet<>();
set.add(userId);
result.setReceivers(set);
result.setType(MessageTypeEnum.CANCEL_MATCH);
messageReply.setChatMessage(result);
log.info("ChatWebsocketmatchUser当前用户{}已退出匹配",userId);
sendMessageAll(messageReply);
return;
}
receiver=matchCacheUtil.getUserInMatchRandom(userId);
if(receiver!=null){
//对手不处于待匹配状态
if(matchCacheUtil.getUserOnlineStatus(receiver).compareTo(StatusEnum.IN_MATCH)!=0){
log.info("ChatWebsocketmatchUser当前用户{},匹配对手{}已退出匹配状态",userId,receiver);
}else{
matchCacheUtil.setUserInGame(userId);
matchCacheUtil.setUserInGame(receiver);
matchCacheUtil.setUserInRoom(userId,receiver);
flag=false;
}
}else{
//如果当前没有待匹配用户,进入等待队列
try{
log.info("ChatWebsocketmatchUser当前用户{}无对手可匹配",userId);
matchCond.await();
}catch(InterruptedExceptione){
log.error("ChatWebsocketmatchUser匹配线程{}发生异常:{}",
Thread.currentThread().getName(),e.getMessage());
}
}
}finally{
lock.unlock();
}
}

UserMatchInfosenderInfo=newUserMatchInfo();
UserMatchInforeceiverInfo=newUserMatchInfo();
senderInfo.setUserId(userId);
senderInfo.setScore(0);
receiverInfo.setUserId(receiver);
receiverInfo.setScore(0);

matchCacheUtil.setUserMatchInfo(userId,JSON.toJSONString(senderInfo));
matchCacheUtil.setUserMatchInfo(receiver,JSON.toJSONString(receiverInfo));

GameMatchInfogameMatchInfo=newGameMatchInfo();
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=newHashSet<>();
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("ChatWebsocketmatchUser用户随机匹配对手结束messageReply:{}",JSON.toJSONString(messageReply));

},CommonField.MATCH_TASK_NAME_PREFIX+userId);
matchThread.start();
}

项目展示

项目代码如下:https://github.com/Yee-Q/match-project

跑起来后,使用 websocket-client 可以进行测试。在浏览器打开,在控制台查看消息。

在连接输入框随便输入一个数字作为 userId,点击连接,此时客户端就和服务端建立 WebSocket 连接了

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

点击加入用户按钮,用户“进入匹配大厅”

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

点击随机匹配按钮,开始匹配,再取消匹配

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

按照之前的步骤再建立一个用户连接,都点击随机匹配按钮,匹配成功,服务端返回响应信息

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

用户分数更新时,在输入框输入新的分数,比如 6,点击实时更新按钮,对手将受到最新的分数消息

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

当双方都点击游戏结束按钮,则游戏结束

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

看完了这篇文章,相信你对“SpringBoot + WebSocket如何实现答题对战匹配机制案例详解”有了一定的了解,如果想了解更多相关知识,欢迎关注恰卡编程网行业资讯频道,感谢各位的阅读!

发布于 2021-05-30 14:09:54
收藏
分享
海报
0 条评论
232
上一篇:Qt自定义控件如何实现进度仪表盘 下一篇:Composer如何实现自动加载原理
目录

    0 条评论

    本站已关闭游客评论,请登录或者注册后再评论吧~

    忘记密码?

    图形验证码