1. 项目整体链路:Bot管理、匹配系统与WebSocket三者怎么衔接
1.1 这个项目现在做到哪一步了
这个系列做到第十二篇,项目已经从最基础的“Hello Spring Boot”走到现在这个节点:用户注册登录、JWT鉴权、后台管理这些能力都已经就位。现在要面对的问题很实际——怎么让普通用户真正用起来。
在Bot对战平台里,用户必须能创建自己的Bot、修改代码、删除旧的,然后才能进入匹配池参与对战。所以这一篇的核心工作有两块:第一,把前端页面Bot的CURD功能完整跑通;第二,为匹配系统准备通信基础,也就是集成WebSocket。这两块看似独立,实际上是一条链路上的两个环节。没有Bot管理,匹配系统不知道该拉取哪个Bot进入对局;没有WebSocket,匹配成功的结果无法实时推送到用户页面。
很多同学会问,Bot的CURD用普通HTTP接口就好,为什么还要专门集成WebSocket?因为对战平台的“匹配中—匹配成功”这个过程需要服务端主动推送,HTTP的请求响应模型在这里非常别扭。WebSocket能把服务端和客户端之间的通道变成双向的、长久的连接,匹配线程一旦发现两个玩家可以配对,就能立刻通知双方。所以这一篇的内容可以理解成:先把基础数据入口建好,再把实时通信管道铺通,匹配系统本身放在下一阶段细化。
1.2 Bot、匹配池、WebSocket三者的数据流
我习惯在动手编码前先把数据流画清楚。用户在前端填写Bot名称、描述、代码,点击保存后,后端把这个Bot写入数据库。随后用户进入匹配页,选中一个Bot,点击“开始匹配”,前端通过WebSocket发送一条start_match消息,后端取出这台Bot,构造一个匹配请求放进匹配池。匹配线程负责扫描匹配池,当两个用户满足配对条件时,后端调用WebSocketSession把match_found消息分别推给两个用户。
这里有一个关键点:Bot的CURD操作频率低、实时性要求不高,走HTTP完全足够;但匹配状态必须走WebSocket,因为匹配成功是后端在任意时刻主动发生的事件,前端无法预测何时发起请求。我们不可能让前端每秒钟轮询一次“我匹配到了吗”,那样既浪费资源延迟又高。所以这一篇里WebSocket承担的是“服务端主动推送”职责,Bot的HTTP接口负责“用户主动读写”职责,两者通过userId和botId关联起来。
2. 后端Bot CRUD接口:表结构、Service权限校验与统一返回
2.1 Bot表设计:需要哪些字段才够用
先设计表结构。我的bot表包含这些字段:id主键自增,user_id记录归属用户,title保存Bot名称,description保存描述,content保存Bot代码,rating保存评分,另外加上create_time和update_time。为什么要有rating?因为后面做匹配系统时,要对玩家Bot的实力进行匹配,如果现在不加这个字段,到时候还要改表结构,不如一次到位。
建表SQL我直接贴在下面,你可以复制到MySQL里执行:
CREATE TABLE bot ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL COMMENT '所属用户ID', title VARCHAR(100) NOT NULL COMMENT 'Bot名称', description VARCHAR(255) DEFAULT '' COMMENT 'Bot描述', content TEXT COMMENT 'Bot代码', rating INT DEFAULT 1500 COMMENT '初始评分', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有一个容易忽略的细节:content字段类型用TEXT,不要用VARCHAR(255)。用户写的Bot代码动辄几百行,255的容量完全不够。建立user_id索引也很关键,因为每次查询“当前用户的所有Bot”都靠这个字段过滤,如果没有索引,数据量大了以后查询会明显变慢。
2.2 实体类与MyBatis-Plus使用细节
Java实体类和表结构一一对应。我项目里用的是MyBatis-Plus,实体类加上@TableName("bot")和@TableId(type = IdType.AUTO)即可。时间字段用LocalDateTime承接,JSON序列化时在application.yml里统一配置格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8不配置的话,前端拿到的create_time可能是一长串数组或者时间戳,处理起来很痛苦。MyBatis-Plus的BaseMapper已经提供了insert、updateById、deleteById、selectList这些方法,Bot这种简单增删改查不需要手写XML或注解SQL。但你要理解,框架只帮你省SQL,不帮你省业务校验。真正容易出错的地方在于:哪些字段能由前端传入,哪些字段必须由后端赋值,这个边界必须清楚。
2.3 Controller层API设计:为什么统一用POST
这一版的Bot接口我全部用POST,没有刻意拆PUT/DELETE。原因是前端封装请求时统一传JSON对象,后端接收统一用@RequestBody,代码写起来最简洁。REST风格作为理想没问题,但在实际项目中,我宁愿牺牲一点“学院派规范”来换取接口的简单统一。
接口设计如下:
| 功能 | 路径 | 请求参数 | 说明 |
|---|---|---|---|
| 获取当前用户Bot列表 | POST /api/bot/list | 空 | 返回当前登录用户的所有Bot |
| 创建Bot | POST /api/bot/create | title,description,content | 新建一个Bot |
| 更新Bot | POST /api/bot/update | id,title,description,content | 修改自己的Bot |
| 删除Bot | POST /api/bot/remove | id | 删除自己的Bot |
Controller层的代码很薄,核心逻辑放在Service。创建Bot时,userId绝对不能从前端接收,而是从当前登录上下文里取:
@PostMapping("/create") public Resp<String> create(@RequestBody Bot bot) { User user = UserContext.getCurrentUser(); bot.setUserId(user.getId()); bot.setRating(1500); botService.save(bot); return Resp.success("创建成功"); }用户上下文从哪来?是JWT过滤器在解析token后放入ThreadLocal的。前端传userId容易导致越权问题,比如我登录A账号,传一个userId为2,就能给B账号创建Bot。这种接口安全漏洞在真实项目中非常常见,写代码时务必把这个口子堵住。
2.4 Service权限校验:修改/删除只允许操作自己的Bot
更新和删除比创建多一道“归属校验”。流程是先根据id从数据库查出Bot,再判断bot.getUserId()和当前登录用户是否一致。不一致直接抛业务异常,前端统一弹出“无权操作”的提示。
我踩过两个坑,都损失了不少调试时间。第一次是更新接口漏了校验,用A账号把B账号的Bot标题改了个遍,还好是测试环境。第二次是删除时直接removeById(id),没有先查一下记录是否存在,也没有校验归属,结果前端显示“删除成功”但数据库里根本没删掉数据。正确写法应该把这段逻辑抽成一个私有方法,先查询再校验再修改:
private Bot getOwnedBotById(Integer id) { Bot bot = botMapper.selectById(id); if (bot == null) { throw new BusinessException("Bot不存在"); } if (!bot.getUserId().equals(UserContext.getCurrentUser().getId())) { throw new BusinessException("无权操作该Bot"); } return bot; }这样三个接口都能复用。记住一个原则:凡是对资源进行修改删除,后端一定要做二次确认,不能信任前端传来的id。
3. 前端页面Bot CURD实操:列表、表单、删除确认
3.1 前端技术选型:为什么不写服务端模板而用Vue
到了Bot管理页面,我建议直接选择Vue或React这类前端框架,而不是继续用Thymeleaf套页面。原因很简单:这个页面的交互密度比较高,有列表渲染、弹窗复用、表单校验、WebSocket状态绑定,用服务端模板做非常别扭,代码很容易变成一堆拼接字符串和DOM操作。
我的前端页面用的是Vue 3加Element Plus。页面结构分为三块:顶部是标题和“创建Bot”按钮;中间是Bot列表表格,包含名称、描述、评分、创建时间和操作按钮;弹窗是新增和编辑共用的Dialog,内部有三个表单字段——标题、描述、代码。整体不算复杂,但涉及到的知识足够你理解前后端协作的基础模式。
为什么前后端分离也是这类项目的推荐路线?因为后续匹配结果推送、对局列表刷新这些功能,前端的状态管理会越来越多。与其在Spring Boot里塞一堆前端代码,不如让前端独立开发、独立部署,后端只提供接口和WebSocket。工程结构清晰,团队协作也方便。
3.2 列表加载:数据库查什么,前端就显示什么
页面一加载,前端调用POST /api/bot/list获取当前用户的所有Bot。这个接口返回的是一个数组,我建议先不做分页,因为单个用户创建的Bot数量一般不会很多,最多几十条,全部渲染没问题。等以后加上“商城式Bot分享”或“公共Bot库”再考虑分页也不迟。
前端拿到数组后,用el-table渲染。这里有一个体验细节:update_time字段如果后端已经格式化成yyyy-MM-dd HH:mm:ss,前端直接显示。如果后端返回的是时间戳,前端要用dayjs或moment格式化。不要在这里偷懒,直接显示一串数字会让页面看起来非常不专业。
操作列放两个按钮:“编辑”和“删除”。编辑按钮打开Dialog回填当前行数据;删除按钮弹出确认框。列表刷新时,我的习惯是保留当前滚动位置和选中状态,不要一刷新就跳回顶部。用户可能在列表中间操作,刷新后跳到顶部会让人很困惑。
3.3 创建和编辑:复用同一个Dialog的技巧
新建和编辑共用一个Dialog,用一个isEdit标记区分场景。Dialog内部是一个el-form,三个字段:title、description、content。代码文本域最少12行高度,否则Bot代码一长就很难编辑。
保存时,前端先做一次校验:标题和代码不能为空,描述可以为空。这个校验能过滤掉大部分无效请求,减轻后端压力。校验通过后,根据isEdit决定调/create还是/update接口,提交成功后关闭Dialog并调用列表刷新接口。
这里我踩过一个小坑:新建时表单是干净的,编辑时表单已经回填了旧数据,如果用户把标题清空并点保存,前端校验能拦住;但如果用户把描述清空,校验拦不住,后端也允许空字符串。这不是bug,但要考虑是否要提示用户。我的做法是描述允许为空,因为有些Bot确实不需要描述。
3.4 删除确认:前端二次确认加上后端归属校验
删除按钮必须加二次确认,这是防止误删的底线。Element Plus的ElMessageBox.confirm用起来很简单,用户点“确定”后才调删除接口。删除成功后,有两种刷新方式:一是从当前列表里移除那一行;二是重新调/list刷新全量列表。我推荐后者。
为什么推荐全量刷新?因为删除这个操作背后可能还有后端连带的清理逻辑(比如删除Bot关联的匹配记录),前端无法知道还有没有其他数据变化。重新拉一次列表,虽然多一次请求,但能保证页面显示和数据库完全一致。我在实际项目中见过只从列表移除记录、之后刷新页面记录又出现的诡异情况,就是删除接口没成功后前端误以为成功导致的。
4. 匹配系统准备:匹配池设计、队列实现与实时通信选型
4.1 匹配池里放什么:不是放Bot,而是放匹配请求
匹配系统的核心是一个“匹配池”。很多新手会直接把Bot对象放进匹配池,这不是最优的做法。因为一个用户可能创建多个Bot,用户在前端选中哪个Bot参与匹配,后端必须把这个关联关系记录下来。匹配池里真正应该放的是“匹配请求”,一条请求代表“某个用户用哪个Bot正在等待对局”。
我设计了一个MatchRequest对象,字段包括:
public class MatchRequest { private Integer userId; private Integer botId; private Integer rating; private Long createTime; }userId是玩家身份,botId是Bot身份,rating是用于后续匹配算法的评分,createTime用于超时处理。当匹配线程从队列里取出两个或多个请求后,根据评分差、段位区间拼接对局。后续如果要取消匹配,也能根据userId从池子里移除对应请求。这个设计是匹配系统最基础的地基,地基歪了,后面写什么都会别扭。
4.2 匹配队列的基本实现:安全、不阻塞主线程
匹配请求肯定不止一个线程在操作。前端通过WebSocket发送start_match消息后,后端某个线程负责把请求加入队列;另一边匹配线程一直扫描队列准备配对。如果队列用普通的ArrayList,两个线程同时修改会抛ConcurrentModificationException。
我推荐用ConcurrentLinkedQueue,这是一个线程安全的无界队列,入队和出队都是非阻塞的,非常适合这个场景:
private final ConcurrentLinkedQueue<MatchRequest> matchQueue = new ConcurrentLinkedQueue<>();匹配线程需要一个死循环,但循环里不能每毫秒都跑,否则CPU占用会很难看。我的策略是每500毫秒检查一次队列,如果队列长度大于等于2,就尝试取出请求进行匹配。这个延迟对用户几乎无感,却能大幅降低CPU开销。等匹配到了两人后,在同一个同步块里完成出队、发送WebSocket消息的动作,防止重复匹配。
4.3 为什么选WebSocket而不是HTTP轮询
匹配场景最典型的特征就是“服务端主动”。前端不知道什么时候匹配成功,也许是1秒后,也许是1分钟后。如果用HTTP轮询,前端每2秒请求一次“我匹配到了吗”,大部分请求都是空响应,既浪费带宽又增加服务器压力;匹配成功的那一刻,用户可能还要多等一个轮询周期才能看到结果。
WebSocket的思路完全不同。前端一旦打开WebSocket连接,就和服务端保持一条长期存在的双向通道。匹配线程发现配对成功后,后端直接通过这条通道把match_found推给用户。从匹配成功到用户收到通知,延迟就是匹配线程的扫描间隔,通常不到500毫秒。而且用户同时还能收到“已进入匹配队列”的即时回执,体验远好于轮询。这也是为什么这一篇必须先把WebSocket集成好,否则再好的匹配算法也送不到用户面前。
5. Spring Boot集成WebSocket:注册、握手鉴权与消息推送
5.1 引入依赖与YML配置:Spring Boot其实只要一个starter
Spring Boot集成WebSocket非常顺滑,第一步是引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>很多同学会疑惑,application.yml里要不要单独配一个WebSocket的配置项?其实Spring Boot的WebSocket不需要专门的连接配置,只需要配置类注册端点即可。如果你希望把端点路径、允许跨域地址做成可配置项,可以用自定义属性注入:
app: websocket: path: /api/websocket allowed-origins: "http://localhost:5173,http://localhost:3000"然后配置类里用@Value("${app.websocket.path}")注入。这样不同环境可以通过配置文件调整,不用改代码。
5.2 注册WebSocket端点与跨域处理
创建一个配置类实现WebSocketConfigurer,重写registerWebSocketHandlers。这里要注册处理器、拦截器和允许跨域来源:
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Value("${app.websocket.path:/api/websocket}") private String websocketPath; @Value("${app.websocket.allowed-origins:}") private String allowedOrigins; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MatchWebSocketHandler(), websocketPath) .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOrigins(allowedOrigins.split(",")); } }开发环境我通常把allowed-origins设为*,方便调试。但生产环境一定写死来源,否则任意第三方网站都能连上这个WebSocket接口,配合你后端推送逻辑,很容易被刷消息。如果你用的是Spring Boot 3.x,setAllowedOrigins("*")对带凭证的请求可能不生效,可以考虑setAllowedOriginPatterns("*"),但要清楚两者的差异。
5.3 握手拦截器:从URL参数解析JWT Token
浏览器WebSocket API有个限制:创建连接时不能像普通HTTP请求那样自定义请求头。所以最常见的鉴权方式是URL参数携带token:
let ws = new WebSocket("ws://localhost:8080/api/websocket?token=" + token);后端在握手阶段拦截请求,解析token,把userId放入attributes。等连接建立后,处理器就能从这个Map里拿到当前连接对应的用户身份:
public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String query = request.getURI().getQuery(); if (query == null || !query.contains("token=")) { return false; } String token = query.split("token=")[1]; Integer userId = JwtUtil.getUserId(token); if (userId == null) { return false; } attributes.put("userId", userId); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手成功后的回调,一般可为空 } }注意解析token时不能只拆一个split就完事,因为URL后面可能还有其他参数。更稳妥的做法是用UriComponentsBuilder拿到参数。这里token过期、签名错误、找不到用户id都直接返回false拒绝握手。我之前有一版解析失败时没拦截,结果前端页面显示连接已建立,后端却不知道这个连接属于谁,推送消息时全部落空,排查了整整半天。
5.4 消息处理器:收发消息与在线用户管理
核心处理器继承TextWebSocketHandler。定义一个线程安全的在线用户Map,key是userId,value是WebSocketSession:
private final ConcurrentHashMap<Integer, WebSocketSession> onlineSessions = new ConcurrentHashMap<>();连接建立时,从attributes里取出userId,存入Map;连接关闭时,从Map移除。handleTextMessage方法负责处理前端发来的消息。约定消息格式为JSON:
{"type": "start_match", "data": {"bot_id": 1}}解析出type后分派到不同的业务方法。匹配相关消息统一交给匹配服务处理,心跳消息直接回pong。注意不要在handleTextMessage里做太重的数据库操作,比如同步查询Bot代码并即时编译,这样会阻塞WebSocket的消息线程。建议把耗时操作丢进线程池或者消息队列,保持连接的轻快。
5.5 服务端主动推送:匹配结果如何发给指定用户
匹配成功时,需要向两个用户发送消息。由于服务端保存了onlineSessions,按userId就能找到对应的Session:
public void sendMessageToUser(Integer userId, String message) { WebSocketSession session = onlineSessions.get(userId); if (session == null || !session.isOpen()) { return; } try { session.sendMessage(new TextMessage(message)); } catch (Exception e) { onlineSessions.remove(userId); } }这里有个很重要的判断:发送前必须检查isOpen()。匹配线程和用户连接可能不在同一个生命周期里,玩家也许已经关闭了浏览器,但匹配线程刚扫描到他,此时Session是关闭状态,直接sendMessage会抛IllegalStateException。每次发送前检查状态,发送失败后移除用户,能避免僵尸连接堆积。
我还会给Session包一层ConcurrentWebSocketSessionDecorator,来避免多个线程同时往同一个Session发消息时出现的并发问题。匹配线程和高并发接口都有可能调用sentMessageToUser,虽然Java WebSocket规范说Session是线程安全的,但实际并发发送时偶尔会出现异常,包一层更安心。
5.6 心跳机制:半小时不掉线是这样做到的
WebSocket连接如果长时间没有消息往来,中间代理层可能会把它当作死连接关掉。所以必须加上心跳保活。我的方案是前端每隔30秒发一条ping:
setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ping" })); } }, 30000);后端收到ping后,回一条pong,不做任何业务处理。这条逻辑虽然简单,但能显著提升连接的稳定性。我遇到过最长的一次,用户挂着匹配页面一个多小时没有断开,就是因为心跳一直正常。如果没有心跳,连接可能在一个不固定的时间被中间设备切断,用户还浑然不知,等匹配成功才发现没收到推送,那就晚了。
6. 实战排查:跨域、Token、断线重连与线程安全
6.1 WebSocket握手一直404或失败:先检查端点与跨域
前端new WebSocket报404,90%是路径问题。后端的addHandler注册的是/api/websocket,前端连接地址就必须是ws://ip:8080/api/websocket,不能多一个/api后缀也不能少。另一个常见原因是跨域:浏览器控制台显示连接失败,但网络面板里能看到请求返回403,基本就是setAllowedOrigins没放行当前页面来源。
建议前端先用浏览器控制台打印ws.readyState,0表示正在连接,1表示连接成功,3表示已关闭。如果一直是0或2,那就要检查握手是否被拒。后端可以临时在AuthHandshakeInterceptor里加一行输出,打印收到的请求头,快速判断是否because跨域拦截。
6.2 Token放在URL参数里的安全习惯
用URL传JWT虽然方便,但存在安全风险:token可能被浏览器历史、服务器访问日志、反向代理日志记录下来。所以握手成功后,token不要打印到任何日志里。更好的做法是把token放在Sec-WebSocket-Protocol头里传递,后端解析子协议来鉴权,这样至少不直接暴露在URL中。但浏览器对子协议值的格式有一定限制,需要前后端协商好。
我个人的建议是:WebSocket使用的token最好用短期有效token,过期时间设置在15分钟左右。握手时就算被记录,攻击者拿到后也无法长期使用。用户每次重新进入匹配页面都会拿到新token,不影响体验。
6.3 断线重连:不能只靠一次new WebSocket
网络抖动、服务器发布、反向代理超时,都会导致WebSocket断开。如果前端只创建一次连接,断开后就再也没法自动恢复,用户体验非常差。我的做法是封装一个connectWebSocket()方法,在onclose回调里判断是否用户主动关闭,如果不是就延迟3秒重连:
let ws = null; let manualClose = false; function connectWebSocket() { manualClose = false; ws = new WebSocket("ws://localhost:8080/api/websocket?token=" + getToken()); ws.onclose = function() { if (!manualClose) { setTimeout(connectWebSocket, 3000); } }; }重连成功后,前端要判断之前有没有正在进行的匹配请求,如果有就重新发送一条start_match,让服务端把用户再次加入匹配队列。要注意在重连前关闭旧连接,否则服务端Map里可能同时存在两个同一个userId的Session,推送消息时不知道该发哪个,还会导致两条重复消息。
6.4 匹配线程操作WebSocket时的并发问题
匹配线程和其他线程同时操作匹配队列和在线Session,最容易出现两个并发问题。第一个是ConcurrentModificationException,解决办法是使用ConcurrentLinkedQueue或对遍历加锁。第二个是重复匹配:匹配线程第一次扫描发现A和B都符合条件,但还没发消息;下一次扫描又看到了A和B,于是又给两人各发一遍匹配成功。这会导致对局状态非常混乱。
我的解决办法是把“出队”和“发送匹配结果”放在同一个同步块里:
synchronized (matchQueue) { MatchRequest p1 = matchQueue.poll(); MatchRequest p2 = matchQueue.poll(); if (p1 != null && p2 != null) { sendMatchFound(p1, p2); sendMatchFound(p2, p1); } }这样同一个队列内不会有其他线程在发送期间插入重复匹配。同步锁在用户量很大的时候会成为热点,但这个阶段的用户规模完全够用。重点是先把正确性做扎实,后面的优化(比如分段锁、桶队列)再根据监控数据来做。
6.5 前端WebSocket消息乱码和收到空数据
刚开始联调时,前端经常收到{"type":"match_found","data":{}}这种空数据,多半是后端构造JSON时出了问题。不要手动拼接字符串,而是用JSONObject构建结构,保证data字段有值再put进去。除了序列化,前端还有一个高频问题:onmessage里拿到的event.data是字符串,必须用JSON.parse()转成对象再访问字段。我见过不少同学直接拿event.data.type,半天都取不到值,就是因为没有先parse。
如果发现有乱码,检查两处:一是后端是否在发送前设置了TextMessage为UTF-8编码,二是浏览器是否以application/octet-stream收到了Blob数据而不是文本数据。解决办法是前端在创建WebSocket时只传字符串消息,后端也只发文本消息,这个问题就不会出现。
最后再分享一个我自己的体会:这一篇已经完成了Bot管理的CURD和WebSocket基础通道,但真正联调之前,一定要先做一个最小闭环验证。我建议先把匹配逻辑简化为“任意两个人点击开始匹配,立刻返回匹配成功”,用两个浏览器窗口同时测,确认推送正常后再加入评分差、段位区间这些业务规则。我先通匹配算法和WebSocket一起调,结果问题混在一起,折腾了一天才定位到是线程安全问题。先通通道,再优化逻辑,这条经验能帮你省下大量时间。匹配系统的核心策略,下一篇再展开聊。