网页五子棋——匹配模块
目录
时序图
约定前后端交互接口
前端页面
game_hall.html
game_hall.css
获取用户信息
约定前后端交互接口
controller 层接口设计
service 层接口设计
前端请求
功能测试
前端实现
服务端实现
OnlineUserManager
创建请求/响应对象
处理连接成功
处理开始/结束匹配请求
Matcher
处理连接关闭
处理连接异常
创建游戏房间
修改前端逻辑
验证匹配功能
在实现用户匹配模块时需要使用到 WebSocket
当玩家发送请求时,服务器返回开始匹配响应:
两个玩家匹配成功后,服务器推送 匹配成功 消息:
WebSocket 相关知识可参考:WebSocket_websocket csdn-CSDN博客
时序图
我们来理解一下匹配过程:
1. 用户点击 开始匹配 按钮后,发送开始匹配请求给服务器
2. 服务器对用户信息进行校验,校验通过后,将用户放入匹配队列进行匹配,并返回开始匹配响应
3. 服务器为用户匹配到对手后,推送匹配成功消息给用户
4. 若用户在匹配过程中点击停止匹配,服务器则将用户从匹配队列移除,并返回停止匹配响应
约定前后端交互接口
前后端约定的交互接口,也是基于 WebSocket 的
[请求] ws://127.0.0.1:8080/findMatch
{
"message": "START"/ "STOP" // 开始/结束匹配
}
在通过 WebSocket 传输请求数据时,数据中可以不必带有用户身份信息
当前用户的身份信息,在登录完成后,就自动保存到 HttpSession 中了,而在 WebSocket 中,可以拿到之前登录时保存的 HttpSession 信息
[响应]
{
"code": 200,
"data": {
"matchMessage": "START" / "STOP",
"rival": null
},
"errorMessage": ""
}
客户端向服务器发送匹配请求后,服务器立即返回匹配响应,表示已经开始 / 结束匹配
由于此时并未匹配到对手,因此 rival 为空
匹配到对手后,服务器主动推送信息:
{
"code": 200,
"data": {
"matchMessage": "SUCCESS",
"rival": {"name": 'lisi', "score": 1000}
},
"errorMessage": ""
}
我们首先来实现前端页面
前端页面
前端页面的内容比较简单: 一个 div 用于显示用户信息,一个 button 用于进行匹配
game_hall.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>游戏大厅</title>
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/game_hall.css">
</head>
<body>
<div class="nav">
五子棋
</div>
<div class="container">
<div>
<!-- 显示用户信息 -->
<div id="screen"></div>
<!-- 匹配按钮 -->
<div id="match-button">开始匹配</div>
</div>
</div>
</body>
</html>
添加 css 样式
game_hall.css
#screen {
width: 400px;
height: 250px;
background-color:antiquewhite;
font-size: 20px;
color: gray;
border-radius: 10px;
text-align: center;
line-height: 100px;
}
#match-button {
width: 400px;
height: 50px;
background-color: antiquewhite;
font-size: 20px;
color: gray;
border-radius: 10px;
text-align: center;
line-height: 50px;
margin-top: 10px;
}
要在页面上显示用户相关信息,因此,需要从后端获取用户信息
接下来,我们就继续实现用户信息的获取
获取用户信息
约定前后端交互接口
[请求] GET /getUserInfo
[响应]
{
"code": 200,
"data": {
"userId": 1,
"userName": "zhangsan",
"score": 1000,
"totalCount": 0,
"winCount": 0
},
"errorMessage": ""
}
由于登录时将相关用户信息存储到 session 中了,因此,可以直接从 HttpSession 中获取用户信息,不必传递相关参数
controller 层接口设计
返回的响应类型:
@Data
public class UserInfoResult implements Serializable {
/**
* 用户 id
*/
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 天梯分数
*/
private Long score;
/**
* 总场数
*/
private Long totalCount;
/**
* 获胜场次
*/
private Long winCount;
}
controller 接口主要完成的功能是:
1. 打印日志
2. 从 request 中获取 session
3. 调用 service 层方法进行业务逻辑处理
3. 构造响应并返回
若从 session 中获取用户信息失败,表明当前用户需要重新登录,因此,我们可以在 CommonResult 中添加 noLogin 方法,用于处理用户未登录的情况
public static <T> CommonResult<T> noLogin() {
CommonResult result = new CommonResult();
result.code = 401;
result.errorMessage = "用户未登录";
return result;
}
getUserInfo:
/**
* 从 session 中获取用户信息
* @param request
* @return
*/
@RequestMapping("/getUserInfo")
public CommonResult<UserInfoResult> getUserInfo(HttpServletRequest request) {
// 日志打印
log.info("getUserInfo 从 HttpSession 中获取用户信息");
// 检查当前请求是否已经有会话对象,如果没有现有的会话,则返回 null
HttpSession session = request.getSession(false);
// 业务逻辑处理
UserInfoDTO userInfoDTO = userService.getUserInfo(session);
// 构造响应并返回
if (null == userInfoDTO) {
return CommonResult.noLogin();
}
return CommonResult.success(convertToUserInfoResult(userInfoDTO));
}
UserInfoDTO:
@Data
public class UserInfoDTO implements Serializable {
/**
* 用户 id
*/
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 天梯分数
*/
private Long score;
/**
* 总场数
*/
private Long totalCount;
/**
* 获胜场次
*/
private Long winCount;
}
类型转化:
/**
* 将 UserInfoDTO 转化为 UserInfoResult
* @param userInfoDTO
* @return
*/
private UserInfoResult convertToUserInfoResult(UserInfoDTO userInfoDTO) {
// 参数校验
if (null == userInfoDTO) {
throw new ControllerException(ControllerErrorCodeConstants.GET_USER_INFO_ERROR);
}
// 构造 UserInfoResult
UserInfoResult userInfoResult = new UserInfoResult();
userInfoResult.setUserId(userInfoDTO.getUserId());
userInfoResult.setUserName(userInfoDTO.getUserName());
userInfoResult.setScore(userInfoDTO.getScore());
userInfoResult.setTotalCount(userInfoDTO.getTotalCount());
userInfoResult.setWinCount(userInfoDTO.getWinCount());
// 返回
return userInfoResult;
}
添加错误码:
public interface ControllerErrorCodeConstants {
// ---------------------- 用户模块错误码 ----------------------
ErrorCode REGISTER_ERROR = new ErrorCode(100, "注册失败");
ErrorCode LOGIN_ERROR = new ErrorCode(101, "登录失败");
ErrorCode GET_USER_INFO_ERROR = new ErrorCode(102, "获取用户信息失败");
}
service 层接口设计
定义业务接口:
/**
* 获取用户信息
* @param session
* @return
*/
UserInfoDTO getUserInfo(HttpSession session);
getUserInfo() 要实现的逻辑:
1. 校验 session 是否为 null
2. 从 session 中获取用户信息
3. 构造响应并返回
@Override
public UserInfoDTO getUserInfo(HttpSession session) {
// 参数校验
if (null == session) {
return null;
}
// 从 session 中获取用户信息
UserInfo userInfo = (UserInfo) session.getAttribute(USER_INFO);
if (null == userInfo) {
return null;
}
// 构造响应并返回
UserInfoDTO userInfoDTO = new UserInfoDTO();
userInfoDTO.setUserId(userInfo.getUserId());
userInfoDTO.setUserName(userInfo.getUserName());
userInfoDTO.setScore(userInfo.getScore());
userInfoDTO.setTotalCount(userInfo.getTotalCount());
userInfoDTO.setWinCount(userInfo.getWinCount());
return userInfoDTO;
}
若从 session 中获取用户信息失败,直接返回 null
前端请求
<script src="js/jquery.min.js"></script>
<script>
$.ajax({
url: "/getUserInfo",
type: "GET",
success: function(result) {
console.log(result)
if(result.code == 200) {
let screenDiv = document.querySelector('#screen');
var user = result.data;
screenDiv.innerHTML = '玩家:' + user.userName + ' 分数:'
+ user.score + '<br>比赛场次:'
+ user.totalCount + ' 获胜场数:' + user.winCount;
}else if(result.code == 401) {
location.assign("/login.html");
}
}
});
</script>
功能测试
运行程序,登录后进入 http://127.0.0.1:8080/game_hall.html:
用户信息正确显示
接下来,我们继续实现匹配功能
前端实现
添加点击事件,onclick() 需要完成的功能:
1. 判断连接是否正常
2. 判断当前是 开始匹配 还是 停止匹配
3. 若是开始匹配,则发送开始匹配请求
4. 若是停止匹配,则发送停止匹配请求
// 为匹配按钮添加点击事件
let matchBtn = document.querySelector('#match-button');
matchBtn.onclick = function() {
// 在发送 websocket 请求前,先判断连接是否正常
if(webSocket.readyState == webSocket.OPEN) {
if(matchBtn.innerHTML == "开始匹配") {
console.log("开始匹配");
webSocket.send(JSON.stringify({
message: "START",
}));
} else if(matchBtn.innerHTML == "匹配中...(点击停止)") {
console.log("停止匹配");
webSocket.send(JSON.stringify({
message: "STOP",
}));
}
} else {
alert("当前连接已断开!请重新登录");
location.replace("/login.html");
}
}
webSocket.readyState 是 WebSocket 对象的一个属性,表示 WebSocket 连接的当前状态:
WebSocket.CONNECTING:WebSocket 正在连接中,表示 WebSocket 对象正在尝试建立连接,但连接还未成功建立
WebSocket.OPEN:WebSocket连接已经建立并且可以进行通信,此时可以通过 WebSocket 发送和接收消息
WebSocket.CLOSING:WebSocket 正在关闭中,此时的 WebSocket 连接已经开始关闭过程,但仍然可以发送和接收一些消息
WebSocket.CLOSIN:WebSocket 连接已经关闭或无法建立连接,此时 WebSocket 不再可用,无法发送和接收消息
创建 WebSocket 实例,并挂载回调函数:
// 初始化 webSocket
let webSocketUrl = 'ws://127.0.0.1/findMatch';
let webSocket = new WebSocket(webSocketUrl);
// 处理服务器响应
webSocket.onmessage = function(e) {
}
// 监听页面关闭事件,在页面关闭之前,手动调用 webSocker 的 close 方法
// 防止连接还没断开就关闭窗口
window.onbeforeunload = function() {
webSocket.close();
}
onmessage() 中主要是对服务器返回的数据进行处理:
首先需要根据返回 code 进行处理:
code 为 200,响应成功,继续判断 matchMessage
code 不为 200,出现异常情况,直接跳转到 登录页面
在此时对于 code 不为 200 的情况,我们就先直接让其跳转到登录页面,后续再进行更细致的处理
若 code 为 200,则继续根据 matchMessage 进行处理:
若返回的 matchMessage 为 START,表示服务器开始进行匹配,将显示的内容修改为 正在进行匹配
若返回的 matchMessage 为 STOP,表示服务器已停止进行匹配,将显示的内容修改为 开始匹配
若返回的 matchMessage 为 SUCCESS,表示服务器已经匹配到对手,显示对手相关信息,跳转到游戏房间页面
webSocket.onmessage = function(e) {
// 处理服务器返回的响应数据
let resp = JSON.parse(e.data);
console.log(resp);
let matchButton = document.querySelector("#match-button");
if (resp.code != 200) {
console.log("发生错误: " + resp.errorMessage);
location.replace("/login.html");
return;
}
if (resp.data.matchMessage == 'START') {
console.log("成功进入匹配队列");
matchButton.innerHTML = "匹配中...(点击停止)";
} else if (resp.data.matchMessage == 'STOP') {
console.log("结束匹配");
matchButton.innerHTML = "开始匹配";
} else if (resp.data.matchMessage == 'SUCCESS') {
// 成功匹配到对手, 进入游戏房间
console.log(resp.data.rival);
alert("匹配到对手:" + resp.data.rival.name + " 分数:" + resp.data.rival.score);
location.replace("/game_room.html");
} else {
console.log("接收到非法响应!" + resp.data);
}
}
服务端实现
首先需要创建 MatchHandler,继承自 TextWebSocketHandler,作为处理 WebSocket 请求的入口类:
@Component
@Slf4j
public class MatchHandler extends TextWebSocketHandler {
/**
* 连接建立
* @param session
* @throws Exception
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
/**
* 处理匹配请求和停止匹配请求
* @param session
* @param message
* @throws Exception
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
}
/**
* 处理异常情况
* @param session
* @param exception
* @throws Exception
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
/**
* 连接关闭
* @param session
* @param status
* @throws Exception
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
}
}
接着,创建 WebSocketConfig,注册 MatchHanlder:
@EnableWebSocket
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private MatchHandler matchHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(matchHandler, "/findMatch")
.addInterceptors(new HttpSessionHandshakeInterceptor()); // 添加拦截器
}
}
在 addHandler 之后,添加 addInterceptors(new HttpSessionHandshakeInterceptor()),其作用是将之前登录过程中存放在 HttpSession 中的数据(主要是 UserInfo),存放到 WebSocket 的 session 中,方便后续获取当前用户信息
OnlineUserManager
注册完成后,我们需要创建一个 Manager 类来管理用户的在线状态
借助这个类,一方面可以判定用户是否在线,另一方面是可以从中获取到 Session 给客户端回话
因此,我们可以使用 哈希表 这样的结果来对用户在线状态进行管理,其中 key 为用户 id,value 为用户的 WebSocketSession
那么,可以使用 HashMap 来存储用户在线状态吗?
若使用 HashMap 来进行存储的话,同时有多个用户和服务器建立连接/断开连接,此时服务器就是在并发的针对 HashMap 进行修改,就很可能会出现线程安全问题,因此使用 HashMap 是不适合的,而 ConcurrentHashMap 更适合当前的场景
ConcurrentHashMap 能够做到读数据不加锁,且在进行写操作时锁的粒度更小,可以允许多个修改操作并发进行
对于用户的在线状态,用户可能在 游戏大厅 中,也可能在 游戏房间 中,因此,我们创建两个 ConcurrentHashMap,分别对 游戏大厅用户在线状态 和 游戏房间用户在线状态 进行管理
@Component
public class OnlineUserManager {
/**
* 游戏大厅用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> hallMap = new ConcurrentHashMap<>();
/**
* 游戏房间用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> roomMap = new ConcurrentHashMap<>();
}
而 OnlineUserManager 需要提供的主要功能有:
当玩家建立好 WebSocket 连接时(进入游戏大厅 / 游戏房间),将键值对加入到OnlineUserManager 中
当玩家断开 WebSocket 连接时(离开游戏大厅 / 游戏房间),将键值对从 OnlineUserManager 中删除
在玩家连接建立好之后,能够随时通过 userId 来查询到对应的会话,以便向客户端返回数据
@Component
public class OnlineUserManager {
/**
* 游戏大厅用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> hallMap = new ConcurrentHashMap<>();
/**
* 游戏房间用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> roomMap = new ConcurrentHashMap<>();
/**
* 用户进入游戏房间,将用户信息存储到 roomMap 中
* @param userId
* @param session
*/
public void enterGameRoom(Long userId, WebSocketSession session) {
roomMap.put(userId, session);
}
/**
* 用户退出游戏房间,将用户信息从 roomMap 中删除
* @param userId
*/
public void exitGameRoom(Long userId) {
roomMap.remove(userId);
}
/**
* 从 roomMap 中获取用户信息
* @param userId
* @return
*/
public WebSocketSession getFromRoom(Long userId) {
return roomMap.get(userId);
}
/**
* 用户进入游戏大厅,将用户信息存储到 hallMap 中
* @param userId
* @param session
*/
public void enterGameHall(Long userId, WebSocketSession session) {
hallMap.put(userId, session);
}
/**
* 用户退出游戏大厅,将用户信息从 hallMap 中删除
* @param userId
*/
public void exitGameHall(Long userId) {
hallMap.remove(userId);
}
/**
* 从 hallMap 中获取用户信息
* @param userId
* @return
*/
public WebSocketSession getFromHall(Long userId) {
return hallMap.get(userId);
}
}
创建请求/响应对象
根据约定的前后端交互接口,来创建对应的请求/响应对象:
请求:
@Data
public class MatchParam implements Serializable {
/**
* 匹配信息
*/
private String message;
}
响应:
@Data
public class MatchResult implements Serializable {
/**
* 匹配结果
*/
private String matchMessage;
private Rival rival;
@Data
public static class Rival {
/**
* 对手姓名
*/
private String name;
/**
* 天梯分数
*/
private Long score;
}
public MatchResult() {}
public MatchResult(String matchMessage) {
this.matchMessage = matchMessage;
}
}
创建好之后 ,我们就可以开始处理 WebSocket 连接建立成功后的业务逻辑了
处理连接成功
连接建立后(afterConnectionEstablished)需要处理的业务逻辑:
1. 从 session 中获取登录时存储的用户信息(UserInfo)
2. 使用 OnlineUserManager 来管理用户状态
3. 判断当前用户是否已经在线
4. 设置玩家的上线状态
其中,在设置玩家的上线状态之前,需要先判定用户是否已经在线,从而防止用户多开
什么是多开?
一个用户,同时打开多个浏览器,同时进行登录,进入游戏大厅/游戏房间,也就是一个账号登录两次
那么,多开会造成什么问题呢?
例如:
当 浏览器1 与 服务器 建立 WebSocket 连接成功时,服务器会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1
而当 浏览器2 与 服务器 建立 WebSocket 连接成功时,服务器也会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session2
上述两次连接建立成功后,哈希表中存储的 key 是相同的,因此,后一次的 value(session2)会覆盖之前的 value(session1)
这种覆盖就会导致第一个浏览器的连接虽然未断开,但是服务器已经拿不到对应的 session 了,也就无法向这个浏览器推送数据
那么,应该如何禁止多开呢?
需要实现 账号登录成功之后,禁止该账号在其他地方再次登录,因此,在 WebSocket 连接建立之后,需要判断当前账号是否已经登录过(处于在线状态),若处于在线状态,则断开此次连接
@Autowired
private OnlineUserManager onlineUserManager;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 从 session 中获取用户信息
UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
// 用户是否登录
if (null == userInfo) {
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.noLogin())));
return;
}
// 判断用户是否处于在线状态
if (null != onlineUserManager.getFromHall(userInfo.getUserId())) {
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.repeatConnection())));
return;
}
// 将用户设置为在线状态
onlineUserManager.enterGameHall(userInfo.getUserId(), session);
// 日志打印
log.info("玩家 {} 进入游戏大厅", userInfo.getUserName());
}
在使用 WebSocket 的 sendMessage 方法发送数据时,先将需要返回的数据封装为 CommonResult 对象,再使用 JacksonUtil 的 writeValueAsString 方法将 CommonResult 对象转化为 JSON 字符串,然后再包装上一层 TextMessage(表示一个文本格式的 WebSocket 数据包),进行传输
之前在实现用户登录时,我们在 UserService 中定义 session 的 key 为 USER_INFO,我们将其导入进来:
import static com.example.gobang_system.service.UserService.USER_INFO;
此外,我们约定 code 为 402 时,用户尝试多开,在 CommonResult 中添加方法:
public static <T> CommonResult<T> repeatConnection() {
CommonResult result = new CommonResult();
result.code = 402;
result.errorMessage = "禁止多开游戏界面!";
return result;
}
连接建立成功后,我们就可以处理 开始/ 结束 匹配请求了
处理开始/结束匹配请求
开始/结束匹配请求在 handleTextMessage 中进行处理
handleTextMessage 实现:
1. 从 session 中拿到玩家信息
2. 解析客户端发送的请求
3. 判断请求类型,若是 START,则将玩家加入到匹配队列中;若是 STOP,则将玩家从匹配队列中删除
为了方便对用户匹配状态的管理,我们可以创建一个枚举类 MatchStatusEnum:
@AllArgsConstructor
@Getter
public enum MatchStatusEnum {
START(1, "开始匹配"),
STOP(2, "停止匹配"),
SUCCESS(3, "匹配成功");
private final Integer code;
private final String message;
public static MatchStatusEnum forName(String name) {
for (MatchStatusEnum matchStatus : MatchStatusEnum.values()) {
if (matchStatus.name().equalsIgnoreCase(name)) {
return matchStatus;
}
}
return null;
}
}
此外,我们还需要一个 匹配器(Matcher),来处理匹配的具体逻辑
@Autowired
private Matcher matcher;
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 处理匹配请求和停止匹配请求
UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
// 用户是否登录
if (null == userInfo) {
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.noLogin())));
return;
}
// 获取用户端发送的数据
String payload = message.getPayload();
// 将 JSON 字符串转化为 java 对象
MatchParam matchParam = JacksonUtil.readValue(payload, MatchParam.class);
if (MatchStatusEnum.START.name().equalsIgnoreCase(matchParam.getMessage())) {
// 开始匹配
matcher.addUserInfo(userInfo);
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(new MatchResult(MatchStatusEnum.START.name())))));
} else if (MatchStatusEnum.STOP.name().equalsIgnoreCase(matchParam.getMessage())) {
// 结束匹配
matcher.removeUserInfo(userInfo);
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(new MatchResult(MatchStatusEnum.STOP.name())))));
} else {
session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.fail(400, "错误的匹配信息"))));
}
}
接下来,我们就来实现匹配器
Matcher
在 Matcher 中创建三个队列(队列中存储 UserInfo 对象),分别表示不同段位的玩家(约定 score < 2000 一档;2000 <= score < 3000 一档,3000 <= score 一档)
提供 add 方法,供 MatcherHandler 调用,用于将玩家加入匹配队列
提供 remover 方法,供 MatcherHandler 调用,用于将玩家移除匹配队列
@Component
@Slf4j
public class Matcher {
@Autowired
private OnlineUserManager onlineUserManager;
public static final int HIGH_SCORE = 2000;
public static final int VERY_HIGH_SCORE = 3000;
// 创建三个队列,分别用于匹配不同天梯分数的玩家
private Queue<UserInfo> normalQueue = new LinkedList<>(); // score < 2000
private Queue<UserInfo> highQueue = new LinkedList<>(); // 2000 <= score < 3000
private Queue<UserInfo> veryHighQueue = new LinkedList<>(); // 3000 <= score
/**
* 将玩家放入匹配队列
* @param userInfo
*/
public void addUserInfo(UserInfo userInfo) {
// 参数校验
if (null == userInfo) {
return;
}
// 放入对应队列进行匹配
if (userInfo.getScore() < HIGH_SCORE) {
offer(normalQueue, userInfo);
} else if (userInfo.getUserId() >= HIGH_SCORE
&& userInfo.getUserId() < VERY_HIGH_SCORE) {
offer(highQueue, userInfo);
} else {
offer(veryHighQueue, userInfo);
}
}
/**
* 将玩家从匹配队列中移除
* @param userInfo
*/
public void removeUserInfo(UserInfo userInfo) {
// 参数校验
if (null == userInfo) {
return;
}
// 从对应队列中移除玩家
if (userInfo.getScore() < HIGH_SCORE) {
remove(normalQueue, userInfo);
} else if (userInfo.getUserId() >= HIGH_SCORE
&& userInfo.getUserId() < VERY_HIGH_SCORE) {
remove(highQueue, userInfo);
} else {
remove(veryHighQueue, userInfo);
}
}
}
注意,由于是在 多线程情况 下进行 添加元素 和 移除元素 操作,因此在将玩家加入匹配队列和将玩家从匹配队列移除时,都需要对其操作进行加锁(直接对队列进行加锁即可)
此外,当有玩家加入到队列中时,需要唤醒对应线程从而进行匹配
offer:
/**
* 将玩家放入匹配队列中
* @param queue
* @param userInfo
*/
private void offer(Queue<UserInfo> queue, UserInfo userInfo) {
try {
synchronized (queue) {
// 将玩家添加到队列中
queue.offer(userInfo);
// 唤醒线程进行匹配
queue.notify();
}
} catch (Exception e) {
log.warn("向队列 {} 中添加玩家 {} 异常, e: ", queue.getClass().getName(),
userInfo.getUserName(), e);
}
}
remove:
/**
* 从队列中移除玩家信息
* @param queue
* @param userInfo
*/
private void remove (Queue<UserInfo> queue, UserInfo userInfo) {
try {
synchronized (queue) {
// 将玩家从队列中移除
queue.remove(userInfo);
}
} catch (Exception e) {
log.warn("从队列 {} 中移除玩家 {} 异常, e: ", queue.getClass().getName(),
userInfo.getUserName(), e);
}
}
在 Matcher 的构造方法中,创建三个线程,用来扫描对应队列,将每个队列中的头两个元素取出来,匹配到一组:
public Matcher() {
// 创建扫描线程,进行匹配
Thread normalThread = new Thread(() -> {
while (true) {
handlerMatch(normalQueue);
}
});
Thread highThread = new Thread(() -> {
while (true) {
handlerMatch(highQueue);
}
});
Thread veryHighThread = new Thread(() -> {
while (true) {
handlerMatch(veryHighQueue);
}
});
// 启动线程
normalThread.start();
highThread.start();
veryHighThread.start();
}
实现 handlerMatch:
由于handlerMatch 在单独的线程中调用,也需要考虑到访问队列的线程安全问题,因此,也需要对其进行加锁
在入口处使用 wait 进行等待,直到队列中存在两个以上的元素,唤醒线程消费队列
private void handlerMatch(Queue<UserInfo> queue) {
try {
synchronized (queue) {
// 若队列中为空 或 队列中只有一个玩家信息,阻塞等待
while (queue.size() <= 1) {
queue.wait();
}
// 取出两个玩家进行匹配
UserInfo user1 = queue.poll();
UserInfo user2 = queue.poll();
}
} catch (InterruptedException e) {
log.warn("Matcher 被提前唤醒", e);
} catch (Exception e) {
log.error("Matcher 处理异常", e);
}
}
若队列中两个玩家匹配成功,此时需要获取玩家对应的 session,从而通知两个玩家匹配成功
但在通知之前,需要判断两个玩家是否都在线:
理论上来,匹配队列中的玩家一定是在线状态(前面的逻辑中进行了处理,当玩家断开连接时就将玩家从匹配队列中移除了),但这里再进行一次判断,避免前面的逻辑出现问题时带来的严重后果
1. 若两个玩家都已下线,此次匹配结束
2. 若一个玩家下线,则将另一个玩家放回匹配队列中重新进行匹配
3. session1 = session2,也就是得到的两个 session 相同,说明同一个玩家两次进入匹配队列,此时也需要将玩家放回匹配队列中
上述 session1 = session2 的情况理论上也不会出现,在之前的逻辑中,当用户下线时,就将其从匹配队列中移除,且禁止了用户多开,在此处再次进行校验,也是为了避免前面的逻辑出现问题时带来的严重后果
// 判断两个玩家当前是否都在线
if (null == session1 && null == session2) {
// 两个玩家都已下线
return;
}
if (null == session1) {
// 玩家1 已下线, 将 玩家2 重新放回匹配队列
queue.offer(user2);
log.info("玩家 {} 重新进行匹配", user2.getUserName());
return;
}
if (null == session2) {
// 玩家2 已下线, 将 玩家1 重新放回匹配队列
queue.offer(user1);
log.info("玩家 {} 重新进行匹配", user1.getUserName());
return;
}
if (session1 == session2) {
// 两个 session 相同,同一个玩家两次进入匹配队列
queue.offer(user1);
return;
}
若两个不同的玩家都在线,此时需要将这两个玩家放入同一个游戏房间(后续实现),并向匹配成功的两个玩家发送响应数据:
// TODO 为上述匹配成功的两个玩家创建游戏房间
// 通知玩家匹配成功
session1.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(convertToMatchSuccessResult(user2)))));
session2.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(convertToMatchSuccessResult(user1)))));
}
构造匹配成功响应类型:
private MatchResult convertToMatchSuccessResult(UserInfo rivalInfo) {
// 参数校验
if (null == rivalInfo) {
return null;
}
// 构造 MatchResult
MatchResult.Rival rival = new MatchResult.Rival();
rival.setName(rivalInfo.getUserName());
rival.setScore(rivalInfo.getScore());
MatchResult result = new MatchResult();
result.setMatchMessage(MatchStatusEnum.SUCCESS.name());
result.setRival(rival);
// 返回
return result;
}
处理连接关闭
afterConnectionClosed 连接断开时会将玩家从 onlineUserManager 中移除
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("匹配连接断开, code: {}, reason: {}",
status.getCode(), status.getReason());
// 玩家下线
logoutFromHall(session);
}
当连接出现异常时,也需要将玩家从 onlineUserManager 中移除,因此,我们在 logoutFromHall() 方法中实现对应逻辑
private void logoutFromHall(WebSocketSession session) {
try {
if (null == session) {
return;
}
UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
if (null == userInfo) {
return;
}
onlineUserManager.exitGameHall(userInfo.getUserId());
matcher.removeUserInfo(userInfo);
log.info("玩家 {} 从游戏大厅退出", userInfo.getUserName());
} catch (Exception e) {
log.warn("玩家 {} 退出游戏大厅时发生异常e: ", e);
}
}
在 连接建立成功 时,我们对用户的在线状态进行了判定:若玩家已经登录过了,此时就不能再进行登录了,返回对应响应,客户端接收到消息时,就会关闭 WebSocket 连接
而在 WebSocket 连接关闭过程中,会触发 afterConnectionClosed,从而调用 logoutFromHall 方法,但在方法中,会调用 onlineUserManager.exitGameHall(userInfo.getUserId()) 方法,将玩家从 游戏大厅 中删除,但是
当 浏览器1 与 服务器 建立 WebSocket 连接成功时,服务器会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1
而当 浏览器2 与 服务器 建立 WebSocket 连接成功时,由于我们判定其为多开情况,因此未在 OnlineUserManager 保存键值对 userId: 1, WebSocketSession: Session2
但是从 session1 与 session2 中获取到的 userId 都为 1
此时,在 断开 浏览器2 与 服务器 之间的连接时,就会将 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1 删除,此时也就出现了异常
因此,在断开连接时,需要进行进一步处理,从而保证在断开多开连接时,不影响原有连接:
获取 OnlineUserManager 中保存的 session 信息(onlineSession),判断其与当前需要断开的 session 是否相同,若相同,则移除对应 session 信息,若不同,则不进行移除
private void logoutFromHall(WebSocketSession session) {
try {
if (null == session) {
return;
}
UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
if (null == userInfo) {
return;
}
// 存储的 session
WebSocketSession onlineSession = onlineUserManager.getFromHall(userInfo.getUserId());
// 判断获取到的 session 信息 与 onlineUserManager 中存储的 session 是否相同
// 避免关闭多开时将玩家信息错误删除
if (session == onlineSession) {
// 将玩家从游戏大厅移除
onlineUserManager.exitGameHall(userInfo.getUserId());
}
// 若玩家正在进行匹配,而 WebSocket 连接断开
// 需要将其从匹配队列中移除
matcher.removeUserInfo(userInfo);
log.info("玩家 {} 从游戏大厅退出", userInfo.getUserName());
} catch (Exception e) {
log.warn("玩家 {} 退出游戏大厅时发生异常e: ", e);
}
}
处理连接异常
连接异常时与连接关闭时的处理逻辑基本相同:
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 打印错误信息
log.error("匹配过程中出现异常: ", exception);
logoutFromHall(session);
}
创建游戏房间
接着,我们需要继续完成匹配成功时,为两个玩家创建游戏房间相关逻辑
我们创建 Room 类,表示游戏房间,游戏房间需要包含:
1. 房间 ID,房间 ID 必须是唯一值,作为房间的唯一身份标识,因此,可以使用 UUID 作为房间ID
2. 对弈玩家双方信息(user1 和 user2)
3. 记录先手方 ID
4. 使用一个二维数组,作为对弈的棋盘
@Data
public class Room {
private static final int MAX_ROW = 15;
private static final int MAX_COL = 15;
/**
* 房间 id
*/
private String roomId;
/**
* 玩家1
*/
private UserInfo user1;
/**
* 玩家2
*/
private UserInfo user2;
/**
* 先手玩家 id
*/
private Long whiteUserId;
/**
* 棋盘
*/
private int[][] board = new int[MAX_ROW][MAX_COL];
public Room() {
// 使用 uuid 作为房间唯一标识
roomId = UUID.randomUUID().toString();
}
}
每当两个玩家匹配成功时,都会创建一个游戏房间,即 Room 对象会存在很多
因此,我们需要创建一个管理器来管理所有的 Room
可以使用一个 Hash 表来保存所有的房间对象,key 为 roomId,value 为 Room 对象,方便通过 房间 id 找到对应房间信息
再使用一个 Hash 表来保存 userId -> roomId 的映射,方便根据玩家 id 查找对应房间
@Component
public class RoomManager {
/**
* key: roomId
* value: Room 对象
*/
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
/**
* key: userId
* value: roomId
* 方便根据玩家查询对应房间
*/
private ConcurrentHashMap<Long, String> userIdToRoomId = new ConcurrentHashMap<>();
}
提供对应的增、删、查方法:
@Component
public class RoomManager {
/**
* key: roomId
* value: Room 对象
*/
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
/**
* key: userId
* value: roomId
* 方便根据玩家查询对应房间
*/
private ConcurrentHashMap<Long, String> userIdToRoomId = new ConcurrentHashMap<>();
/**
* 创建游戏房间
* @param room
* @param userId1
* @param userId2
*/
public void add(Room room, Long userId1, Long userId2) {
rooms.put(room.getRoomId(), room);
userIdToRoomId.put(userId1, room.getRoomId());
userIdToRoomId.put(userId2, room.getRoomId());
}
/**
* 删除游戏房间
* @param roomId
* @param userId1
* @param userId2
*/
public void remove(String roomId, Long userId1, Long userId2) {
rooms.remove(roomId);
userIdToRoomId.remove(userId1);
userIdToRoomId.remove(userId2);
}
/**
* 通过房间号获取游戏房间
* @param roomId
* @return
*/
public Room getRoomByRoomId(String roomId) {
return rooms.get(roomId);
}
/**
* 通过玩家 id 获取游戏房间
* @param userId
* @return
*/
public Room getRoomByUserId(Long userId) {
String roomId = userIdToRoomId.get(userId);
if (null == roomId) {
return null;
}
return rooms.get(roomId);
}
}
在 Matcher 中注入 RoomManager:
@Autowired
private RoomManager roomManager;
完善匹配逻辑:
// 为上述匹配成功的两个玩家创建游戏房间
Room room = new Room();
roomManager.add(room, user1.getUserId(), user2.getUserId());
修改前端逻辑
在前面,对于 code != 200 的情况,我们直接让页面跳转到登录页面,在这里,我们对其进行更进一步的处理:
code = 401:跳转到登录页面
code = 402:提示用户多开
code 为其他值:打印异常信息
webSocket.onmessage = function(e) {
// 处理服务器返回的响应数据
let resp = JSON.parse(e.data);
console.log(resp);
let matchButton = document.querySelector("#match-button");
if (resp.code != 200) {
if (resp.code == 401) {
// 用户未登录
} else if (resp.code == 402) {
alert("检测到当前账号游戏多开!请检查登录情况!");
} else {
alert("异常情况:" + resp.errorMessage);
}
location.replace("/login.html");
return;
}
if (resp.data.matchMessage == 'START') {
console.log("成功进入匹配队列");
matchButton.innerHTML = "匹配中...(点击停止)";
} else if (resp.data.matchMessage == 'STOP') {
console.log("结束匹配");
matchButton.innerHTML = "开始匹配";
} else if (resp.data.matchMessage == 'SUCCESS') {
// 成功匹配到对手, 进入游戏房间
console.log(resp.data.rival);
alert("匹配到对手:" + resp.data.rival.name + " 分数:" + resp.data.rival.score);
location.replace("/game_room.html");
} else {
console.log("接收到非法响应!" + resp.data);
}
}
验证匹配功能
实现完成后,运行程序,验证匹配功能是否正常
使用两个浏览器(或是无痕式窗口),登录两个账号:
点击开始匹配,观察打印信息:
结束匹配:
再新开一个窗口,尝试登录上述其中一个账号:
点击确定,跳转到登录页面:
两个玩家都点击开始匹配:
匹配成功,显示对手相关信息,点击确定,跳转到游戏房间页面:
至此,我们的匹配功能就基本实现完成了