目录
[controller 层接口设计](#controller 层接口设计)
[service 层接口设计](#service 层接口设计)
在实现用户匹配模块时需要使用到 WebSocket
当玩家发送请求时,服务器返回开始匹配响应:


两个玩家匹配成功后,服务器推送 匹配成功消息:

WebSocket 相关知识可参考:WebSocket_websocket csdn-CSDN博客
时序图

我们来理解一下匹配过程:
用户点击 开始匹配按钮后,发送开始匹配请求给服务器
服务器对用户信息进行校验,校验通过后,将用户放入匹配队列进行匹配,并返回开始匹配响应
服务器为用户匹配到对手后,推送匹配成功消息给用户
若用户在匹配过程中点击停止匹配,服务器则将用户从匹配队列移除,并返回停止匹配响应
约定前后端交互接口
前后端约定的交互接口,也是基于 WebSocket的
[请求] ws://127.0.0.1:8080/findMatch
java
{
"message": "START"/ "STOP" // 开始/结束匹配
}
在通过 WebSocket传输请求数据时,数据中可以不必带有用户身份信息
当前用户的身份信息,在登录完成后,就自动保存到 HttpSession 中了,而在 WebSocket 中,可以拿到之前登录时保存的 HttpSession信息
[响应]
java
{
"code": 200,
"data": {
"matchMessage": "START" / "STOP",
"rival": null
},
"errorMessage": ""
}
客户端向服务器发送匹配请求后,服务器立即返回匹配响应,表示已经开始 / 结束匹配
由于此时并未匹配到对手,因此 rival为空
匹配到对手后,服务器主动推送信息:
java
{
"code": 200,
"data": {
"matchMessage": "SUCCESS",
"rival": {"name": 'lisi', "score": 1000}
},
"errorMessage": ""
}
我们首先来实现前端页面
前端页面

前端页面的内容比较简单: 一个 div 用于显示用户信息,一个 button 用于进行匹配
game_hall.html
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
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
[响应]
java
{
"code": 200,
"data": {
"userId": 1,
"userName": "zhangsan",
"score": 1000,
"totalCount": 0,
"winCount": 0
},
"errorMessage": ""
}
由于登录时将相关用户信息存储到 session 中了,因此,可以直接从 HttpSession中获取用户信息,不必传递相关参数
controller 层接口设计
返回的响应类型:
java
@Data
public class UserInfoResult implements Serializable {
/**
* 用户 id
*/
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 天梯分数
*/
private Long score;
/**
* 总场数
*/
private Long totalCount;
/**
* 获胜场次
*/
private Long winCount;
}
controller 接口主要完成的功能是:
打印日志
从 request 中获取 session
调用 service 层方法进行业务逻辑处理
构造响应并返回
若从 session 中获取用户信息失败,表明当前用户需要重新登录,因此,我们可以在 CommonResult 中添加 noLogin方法,用于处理用户未登录的情况
java
public static <T> CommonResult<T> noLogin() {
CommonResult result = new CommonResult();
result.code = 401;
result.errorMessage = "用户未登录";
return result;
}
getUserInfo:
java
/**
* 从 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:
java
@Data
public class UserInfoDTO implements Serializable {
/**
* 用户 id
*/
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 天梯分数
*/
private Long score;
/**
* 总场数
*/
private Long totalCount;
/**
* 获胜场次
*/
private Long winCount;
}
类型转化:
java
/**
* 将 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;
}
添加错误码:
java
public interface ControllerErrorCodeConstants {
// ---------------------- 用户模块错误码 ----------------------
ErrorCode REGISTER_ERROR = new ErrorCode(100, "注册失败");
ErrorCode LOGIN_ERROR = new ErrorCode(101, "登录失败");
ErrorCode GET_USER_INFO_ERROR = new ErrorCode(102, "获取用户信息失败");
}
service 层接口设计
定义业务接口:
java
/**
* 获取用户信息
* @param session
* @return
*/
UserInfoDTO getUserInfo(HttpSession session);
**getUserInfo()**要实现的逻辑:
校验 session 是否为 null
从 session 中获取用户信息
构造响应并返回
java
@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
前端请求
javascript
<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()**需要完成的功能:
判断连接是否正常
判断当前是 开始匹配 还是 停止匹配
若是开始匹配,则发送开始匹配请求
若是停止匹配,则发送停止匹配请求
javascript
// 为匹配按钮添加点击事件
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实例,并挂载回调函数:
javascript
// 初始化 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 ,表示服务器已经匹配到对手,显示对手相关信息,跳转到游戏房间页面
javascript
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 请求的入口类:
java
@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:
java
@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 ,分别对 游戏大厅用户在线状态 和 游戏房间用户在线状态进行管理
java
@Component
public class OnlineUserManager {
/**
* 游戏大厅用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> hallMap = new ConcurrentHashMap<>();
/**
* 游戏房间用户在线状态
*/
private ConcurrentHashMap<Long, WebSocketSession> roomMap = new ConcurrentHashMap<>();
}
而 OnlineUserManager需要提供的主要功能有:
当玩家建立好 WebSocket 连接时(进入游戏大厅 / 游戏房间),将键值对加入到OnlineUserManager 中
当玩家断开 WebSocket 连接时(离开游戏大厅 / 游戏房间),将键值对从 OnlineUserManager 中删除
在玩家连接建立好之后,能够随时通过 userId 来查询到对应的会话,以便向客户端返回数据
java
@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);
}
}
创建请求/响应对象
根据约定的前后端交互接口,来创建对应的请求/响应对象:
请求:
java
@Data
public class MatchParam implements Serializable {
/**
* 匹配信息
*/
private String message;
}
响应:
java
@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)需要处理的业务逻辑:
从 session 中获取登录时存储的用户信息(UserInfo)
使用 OnlineUserManager 来管理用户状态
判断当前用户是否已经在线
设置玩家的上线状态
其中,在设置玩家的上线状态之前,需要先判定用户是否已经在线,从而防止用户多开
什么是多开?
一个用户,同时打开多个浏览器,同时进行登录,进入游戏大厅/游戏房间,也就是一个账号登录两次
那么,多开会造成什么问题呢?
例如:

当 浏览器1 与 服务器 建立 WebSocket 连接成功时,服务器会在 OnlineUserManager 中保存键值对userId: 1, WebSocketSession: Session1
而当 浏览器2 与 服务器 建立 WebSocket 连接成功时,服务器也会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session2

上述两次连接建立成功后,哈希表中存储的 key 是相同的,因此,后一次的 value(session2)会覆盖之前的 value(session1)
这种覆盖就会导致第一个浏览器的连接虽然未断开,但是服务器已经拿不到对应的 session 了,也就无法向这个浏览器推送数据
那么,应该如何禁止多开呢?
需要实现 账号登录成功之后,禁止该账号在其他地方再次登录,因此,在 WebSocket 连接建立之后,需要判断当前账号是否已经登录过(处于在线状态),若处于在线状态,则断开此次连接
java
@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,我们将其导入进来:
java
import static com.example.gobang_system.service.UserService.USER_INFO;
此外,我们约定 code 为 402 时,用户尝试多开,在 CommonResult中添加方法:
java
public static <T> CommonResult<T> repeatConnection() {
CommonResult result = new CommonResult();
result.code = 402;
result.errorMessage = "禁止多开游戏界面!";
return result;
}
连接建立成功后,我们就可以处理 开始/ 结束 匹配请求了
处理开始/结束匹配请求
开始/结束匹配请求在 handleTextMessage中进行处理
handleTextMessage 实现:
从 session 中拿到玩家信息
解析客户端发送的请求
判断请求类型,若是 START ,则将玩家加入到匹配队列中;若是 STOP,则将玩家从匹配队列中删除
为了方便对用户匹配状态的管理,我们可以创建一个枚举类 MatchStatusEnum:
java
@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),来处理匹配的具体逻辑
java
@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调用,用于将玩家移除匹配队列
java
@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:
java
/**
* 将玩家放入匹配队列中
* @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:
java
/**
* 从队列中移除玩家信息
* @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的构造方法中,创建三个线程,用来扫描对应队列,将每个队列中的头两个元素取出来,匹配到一组:
java
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进行等待,直到队列中存在两个以上的元素,唤醒线程消费队列
java
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,从而通知两个玩家匹配成功
但在通知之前,需要判断两个玩家是否都在线:
理论上来,匹配队列中的玩家一定是在线状态(前面的逻辑中进行了处理,当玩家断开连接时就将玩家从匹配队列中移除了),但这里再进行一次判断,避免前面的逻辑出现问题时带来的严重后果
若两个玩家都已下线,此次匹配结束
若一个玩家下线,则将另一个玩家放回匹配队列中重新进行匹配
session1 = session2,也就是得到的两个 session 相同,说明同一个玩家两次进入匹配队列,此时也需要将玩家放回匹配队列中
上述 session1 = session2 的情况理论上也不会出现,在之前的逻辑中,当用户下线时,就将其从匹配队列中移除,且禁止了用户多开,在此处再次进行校验,也是为了避免前面的逻辑出现问题时带来的严重后果
java
// 判断两个玩家当前是否都在线
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;
}
若两个不同的玩家都在线,此时需要将这两个玩家放入同一个游戏房间(后续实现),并向匹配成功的两个玩家发送响应数据:
java
// TODO 为上述匹配成功的两个玩家创建游戏房间
// 通知玩家匹配成功
session1.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(convertToMatchSuccessResult(user2)))));
session2.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
CommonResult.success(convertToMatchSuccessResult(user1)))));
}
构造匹配成功响应类型:
java
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中移除
java
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("匹配连接断开, code: {}, reason: {}",
status.getCode(), status.getReason());
// 玩家下线
logoutFromHall(session);
}
当连接出现异常时,也需要将玩家从 onlineUserManager 中移除,因此,我们在**logoutFromHall()**方法中实现对应逻辑
java
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 信息,若不同,则不进行移除
java
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);
}
}
处理连接异常
连接异常时与连接关闭时的处理逻辑基本相同:
java
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 打印错误信息
log.error("匹配过程中出现异常: ", exception);
logoutFromHall(session);
}
创建游戏房间
接着,我们需要继续完成匹配成功时,为两个玩家创建游戏房间相关逻辑

我们创建 Room 类,表示游戏房间,游戏房间需要包含:
房间 ID,房间 ID 必须是唯一值,作为房间的唯一身份标识,因此,可以使用 UUID 作为房间ID
对弈玩家双方信息(user1 和 user2)
记录先手方 ID
使用一个二维数组,作为对弈的棋盘
java
@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 查找对应房间
java
@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<>();
}
提供对应的增、删、查方法:
java
@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:
java
@Autowired
private RoomManager roomManager;
完善匹配逻辑:
java
// 为上述匹配成功的两个玩家创建游戏房间
Room room = new Room();
roomManager.add(room, user1.getUserId(), user2.getUserId());
修改前端逻辑
在前面,对于 code != 200 的情况,我们直接让页面跳转到登录页面,在这里,我们对其进行更进一步的处理:
code = 401:跳转到登录页面
code = 402:提示用户多开
code 为其他值:打印异常信息
javascript
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);
}
}
验证匹配功能
实现完成后,运行程序,验证匹配功能是否正常
使用两个浏览器(或是无痕式窗口),登录两个账号:


点击开始匹配,观察打印信息:

结束匹配:

再新开一个窗口,尝试登录上述其中一个账号:

点击确定,跳转到登录页面:

两个玩家都点击开始匹配:


匹配成功,显示对手相关信息,点击确定,跳转到游戏房间页面:
至此,我们的匹配功能就基本实现完成了