概要设计
类似竞技问答游戏:用户随机匹配一名对手,双方同时开始答题,直到双方都完成答题,对局结束。基本的逻辑就是这样,如果有其他需求,可以在其基础上进行扩展
明确了这一点,下面介绍开发思路。为每个用户拟定四种在线状态,分别是:待匹配、匹配中、游戏中、游戏结束。下面是流程图,用户的流程是被规则约束的,状态也随流程而变化
对流程再补充如下:
- 用户进入匹配大厅(具体效果如何由客户端体现),将用户的状态设置为待匹配
- 用户开始匹配,将用户的状态设置为匹配中,系统搜索其他同样处于匹配中的用户,在这个过程中,用户可以取消匹配,返回匹配大厅,此时用户状态重新设置为待匹配。匹配成功,保存匹配信息,将用户状态设置为游戏中
- 根据已保存的匹配信息,用户可以获得对手的信息。答题是时,每次用户分数更新,也会向对手推送更新后的分数
- 用户完成答题,则等待对手也完成答题。双方都完成答题,用户状态设置为游戏结束,展示对局结果
2|0详细设计
针对 概要设计 提出的思路,我们需要思考以下几个问题:
- 如何保持客户端与服务器的连接?
- 如何设计客户端与服务端的消息交互?
- 如何保存以及改变用户状态?
- 如何匹配用户?
下面我们一个一个来解决
1. 如何保持用户与服务器的连接?
以往我们使用 Http 请求服务器,并获取响应信息。然而 Http 有个缺陷,就是通信只能由客户端发起,无法做到服务端主动向客户端推送信息。根据概要设计我们知道,服务端需要向客户端推送对手的实时分数,因此这里不适合使用 Http,而选择了 WebSocket。WebSocket 最大的特点就是服务端可以主动向客户端推送信息,客户端也可以主动向服务端发送信息,是真正的双向平等对话
有关 SpringBoot 集成 WebSocket 可参考这篇博客:
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 保存
"/game/match/{userId}") | (value =|
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(); | |
public void setMatchCacheUtil(MatchCacheUtil matchCacheUtil) { | |
ChatWebsocket.matchCacheUtil = matchCacheUtil; | |
} | |
public void setQuestionSev(QuestionSev questionSev) { | |
ChatWebsocket.questionSev = questionSev; | |
} | |
public void onOpen("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); | |
} | |
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); | |
} | |
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); | |
} | |
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 sendMessage All(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 Status Enum 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 中的数据。
public class MatchCacheUtil { | |
/** | |
* 用户 userId 为 key,ChatWebsocket 为 value | |
*/ | |
private static final Map<String, ChatWebsocket> CLIENTS = new HashMap<>(); | |
/** | |
* key 是标识存储用户在线状态的 EnumRedisKey,value 为 map 类型,其中用户 userId 为 key,用户在线状态 为 value | |
*/ | |
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 userId, String userId2) { | |
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId, userId2); | |
redisTemplate.opsForHash().put(EnumRedisKey.ROOM.getKey(), userId, 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) == | |
|| matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.GAME_OVER) ==) { | |
log.info("ChatWebsocket matchUser 当前用户 {} 已退出匹配", userId); | |
return; | |
} | |
// 当前用户取消匹配状态 | |
if (matchCacheUtil.getUserOnlineStatus(userId).compareTo(StatusEnum.IDLE) ==) { | |
// 当前用户取消匹配 | |
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) !=) { | |
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(); | |
receiverInfo.setUserId(receiver); | |
receiverInfo.setScore(); | |
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(); | |
} |
3|0项目展示
项目代码如下:
跑起来后,使用 websocket-client 可以进行测试。在浏览器打开,在控制台查看消息。
在连接输入框随便输入一个数字作为 userId,点击连接,此时客户端就和服务端建立 WebSocket 连接了
点击加入用户按钮,用户“进入匹配大厅”
点击随机匹配按钮,开始匹配,再取消匹配
按照之前的步骤再建立一个用户连接,都点击随机匹配按钮,匹配成功,服务端返回响应信息
用户分数更新时,在输入框输入新的分数,比如 6,点击实时更新按钮,对手将收到最新的分数消息
当双方都点击游戏结束按钮,则游戏结束