ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SpringBoot+Vue的多人实时协作绘画平台:WebSocket消息同步与画布渲染实战

基于SpringBoot+Vue的多人实时协作绘画平台:WebSocket消息同步与画布渲染实战 简介基于SpringBootVueWebSocket构建的多人实时在线协作绘画平台完整设计源码面向熟悉Java后端与JavaScript前端的Web开发者尤其适合需要实现实时通信、协同编辑与共享白板场景的实战学习。项目采用前后端分离架构后端以18个Java源文件实现用户认证、状态同步与绘画操作广播等核心逻辑前端由Vue及辅助JS/HTML文件构成互动界面并通过WebSocket保障多端画布实时一致。资源共40个文件除前后端代码外还包含XML/JSON等配置、架构设计文档、项目说明及演示视频整体压缩包16.92MB目录结构完整清晰。已有369人学习下载可用于参考多人协作系统的线程管理、消息推送与前端状态同步方案也可作为毕业设计或工程实践的原型基础。1. 多人实时在线协作绘画平台核心不是画布是消息同步说一个反直觉的结论多人实时在线协作绘画平台最难的从来不是画布绘制而是消息同步。这个基于SpringBoot、Vue和WebSocket搭建的多人实时在线协作绘画平台设计源码把白板绘画的实时同步完整跑通了——房间里每个人落下的每一笔其它成员都能在几百毫秒内看到并渲染出来。它解决的是三个具体问题多人的画笔指令怎么在WebSocket通道里有秩序地广播、断线重连后画布怎么保持一致、前端怎么用Canvas把远端指令渲染得不闪烁不串笔。适合两类人想往自己项目里加实时协作功能的Java后端以及刚入门Vue、想找一个连接前端与后端的完整实战项目的读者。2. WebSocket会话层设计连接管理、心跳参数与消息路由2.1 会话存储模型为什么不能直接用Map存Session先看一个最常见的错误做法把所有的WebSocket Session扔进一个全局ConcurrentHashMap收到消息后遍历查找。这样写在小规模demo里确实能跑但一旦拆成多人房间模式就麻烦了——你想给某个房间广播时得遍历全部连接逐个比对roomId连接数过百后延迟和CPU占用都会肉眼可见地涨。这个源码里的做法是两层结构外层按roomId分桶内层存该房间的所有Session。这样广播时直接按roomId取集合不用全量扫描。下面是ConnectionManager的核心代码public class ConnectionManager { // 外层房间 - 该房间所有会话 private static final ConcurrentHashMapString, CopyOnWriteArraySetSession ROOM_SESSIONS new ConcurrentHashMap(); public static void add(String roomId, Session session) { ROOM_SESSIONS.computeIfAbsent(roomId, k - new CopyOnWriteArraySet()).add(session); } public static void remove(String roomId, Session session) { CopyOnWriteArraySetSession sessions ROOM_SESSIONS.get(roomId); if (sessions ! null) { sessions.remove(session); if (sessions.isEmpty()) { ROOM_SESSIONS.remove(roomId); } } } public static SetSession getRoomSessions(String roomId) { CopyOnWriteArraySetSession sessions ROOM_SESSIONS.get(roomId); return sessions null ? Collections.emptySet() : sessions; } }这里有两个选型理由要讲清楚。第一内层用CopyOnWriteArraySet而不是HashSet广播时正在遍历房间成员列表同时恰好有一个用户加入或退出普通HashSet会直接抛ConcurrentModificationExceptionCopyOnWriteArraySet读的时候拿到的是不可变快照遍历过程绝对安全。代价是写操作会复制整个数组但连接的加入和离开频率很低这个代价可以忽略。第二add方法用computeIfAbsent初始化Set保证多个线程同时往新房间加人时不会出现“两个Set互相覆盖”的竞态这是并发编程里很经典的一个坑。2.2 心跳机制时间参数怎么定、失效连接怎么清理WebSocket本身有TCP保活但浏览器到服务端之间往往隔着Nginx、负载均衡或云厂商的网关这些中间设备会对空闲连接做回收。表现就是前端页面上连接状态还是OPEN但消息实际已经发不过去了。所以业务层心跳必须做这是血泪经验。心跳参数我建议这样定前端每30秒发一条ping服务端收到后回pong并记录最后一次收到消息的时间服务端每10秒扫一次超过90秒没收到任何消息的连接直接判定为死链并关闭。90秒这个阈值不是随便拍的必须是心跳间隔的整数倍以上给网络抖动和GC停顿留余量否则用户切后台几十秒回来就被踢下线体验很差。OnMessage public void onMessage(String message, Session session) { DrawMessage msg DrawMessage.fromJson(message); // 心跳消息不走业务路由只更新时间戳 if (ping.equals(msg.getType())) { session.getUserProperties().put(lastHeartbeat, System.currentTimeMillis()); sendTo(session, {\type\:\pong\}); return; } // 业务消息draw / erase / clear / undo messageRouter.route(msg); } // 定时清理任务每 10 秒执行一次 Scheduled(fixedRate 10000) public void sweep() { long now System.currentTimeMillis(); ROOM_SESSIONS.forEach((roomId, sessions) - sessions.forEach(session - { Long last (Long) session.getUserProperties().get(lastHeartbeat); if (last null || now - last 90_000) { session.close(); } })); }另外记得给Session设置一个兜底的空闲超时session.setMaxIdleTimeout(120_000)这样即使定时任务哪天没跑容器层的超时也能把僵尸连接收掉。这里有个细节Scheduled默认单线程如果项目里已有别的定时任务心跳清扫线程可能会被长任务卡住建议用Scheduled的线程池配置单独隔离。2.3 消息路由把JSON请求分发到对应房间消息到达服务端后不能原地广播要经过一个路由层统一处理。路由层做三件事反序列化、按type分发、把消息送到对应房间。这个分层看起来很基础但省掉了后续在OnMessage里堆if-else的麻烦也让广播逻辑可以单独测试。Component public class MessageRouter { public void route(DrawMessage msg) { switch (msg.getType()) { case join - handleJoin(msg); case draw - broadcastToRoom(msg.getRoomId(), msg); case erase - broadcastToRoom(msg.getRoomId(), msg); case undo - broadcastToRoom(msg.getRoomId(), msg); default - log.warn(未知指令类型: {}, msg.getType()); } } private void broadcastToRoom(String roomId, DrawMessage msg) { ConnectionManager.getRoomSessions(roomId).forEach(session - { try { session.getBasicRemote().sendText(msg.toJson()); } catch (IOException e) { // 单个会话发送失败不能影响其他人 log.error(广播失败session{}, err{}, session.getId(), e.getMessage()); } }); } }在广播逻辑里有三个设计点值得记一下。第一发送失败只记录日志、绝不抛异常否则一个用户断网会把整条广播链路打断其余正常用户全部收不到消息。第二用getBasicRemote()同步发送而不是getAsyncRemote()画布指令要求到达顺序与发送顺序一致异步发送在高并发下可能出现后发的消息先到前端画布顺序就乱了。第三不要在BroadCast前做异步化改造除非你明确知道自己在做什么——异步线程池会打乱消息顺序这是新手最容易踩的坑。提示拿到这个源码先别急着改功能把ConnectionManager和MessageRouter两个类通读一遍大部分人如果能说清楚这两个类为什么这么设计后面调bug会快很多。3. 画布指令协议JSON消息格式与数据结构是协作的基石3.1 指令类型与消息载荷先定协议再写代码不管多少人同时画WebSocket上传输的都是指令流。指令协议设计得好不好直接决定多人协作的体验和后续扩展空间。这个源码里定义了一套完整的指令类型我整理成了一张表type含义关键载荷字段join用户进入房间roomId, userId, userNamedraw画一笔笔画roomId, userId, msgId, toolType, color, lineWidth, points[]erase擦除roomId, userId, msgId, points[]undo撤销上一步roomId, userId, msgId, stepIdclear清空画布roomId, userId, msgIdping / pong心跳无业务字段一条完整的draw消息长这样{ type: draw, roomId: room-001, userId: u-10086, msgId: c3f2a1b8-7d4e-4f2a-9b1c-2d3e4f5a6b7c, toolType: brush, color: #ff5722, lineWidth: 4, points: [ {x: 120, y: 340}, {x: 121, y: 342}, {x: 124, y: 347} ] }这里有一个关键选择points一次带一整个数组而不是一个点一条消息。如果每移动一个像素就发一条消息一条笔画50个点就是50条消息广播压力直接乘以房间人数Tomcat的WebSocket线程会先被打满。而按笔画粒度发送一次广播搞定一整条线网络往返从50次降为1次。代价是远端渲染会有一点延迟但在一帧内画完一条线用户基本感知不到。3.2 服务端消息模型与消息去重msgId不能省服务端收到JSON后反序列化成两个模型外层是消息信封内层是画布动作数据。msgId和时间戳就是在这个环节被解析出来的。msgId用UUID生成前端每发一条指令生成一次服务端用它做去重。为什么必须去重WebSocket本身是可靠的但断线重连后前端会把没收到ack的指令重发一遍如果服务端不去重同一条笔画会被画两次画布上出现一条叠影黑线。public class DrawMessage { private String type; private String roomId; private String userId; private String msgId; // 全局唯一用于去重 private long timestamp; // 客户端毫秒时间戳 private CanvasAction data; public static DrawMessage fromJson(String json) { return JsonUtils.parse(json, DrawMessage.class); } } public class CanvasAction { private String toolType; private String color; private Integer lineWidth; private ListCanvasPoint points; }去重集合要按房间维度维护不能全局一个Set否则一个房间的msgId会误伤另一个房间。水位控制也不能省private final ConcurrentHashMapString, SetString roomMsgIds new ConcurrentHashMap(); public boolean isDuplicate(String roomId, String msgId) { SetString ids roomMsgIds.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()); if (ids.size() 5000) { ids.clear(); // 水位保护防止内存持续增长 } return !ids.add(msgId); }5000条是一个经验值覆盖最近几分钟的绘画操作足够支撑断线重连的补偿需求。如果不做水位保护一个活跃房间一天会产生几十万条消息ID内存占用会一路涨上去。3.3 画笔轨迹数据从Canvas鼠标事件到WebSocket消息前端这边的逻辑是三段式mousedown开始收集点mousemove往数组里push点mouseup把整条轨迹封装成消息发出。注意每次事件都要判断当前工具是画笔还是橡皮擦把toolType写进消息服务端和远端前端才能区分是画线还是擦除。// CanvasBoard.vue 核心绘制逻辑 handleMouseDown(e) { this.isDrawing true this.currentPoints [] this.currentPoints.push(this.toCanvasPosition(e)) }, handleMouseMove(e) { if (!this.isDrawing) return const point this.toCanvasPosition(e) this.currentPoints.push(point) this.drawLocalSegment(point) // 本地立即绘制不等服务端广播 }, handleMouseUp() { if (!this.isDrawing) return const payload { type: this.currentTool eraser ? erase : draw, roomId: this.roomId, userId: this.userId, msgId: this.generateUuid(), toolType: this.currentTool, color: this.currentColor, lineWidth: this.currentTool eraser ? this.eraserWidth : this.currentLineWidth, points: this.currentPoints } this.ws.send(JSON.stringify(payload)) this.isDrawing false this.currentPoints [] }逻辑说明本地立即绘制保证手感跟手广播回来的相同消息会被前端按msgId去重不会画两次。toCanvasPosition把鼠标的client坐标转换成canvas坐标这一步要考虑canvas的CSS缩放比例和内部像素比例的差异不然画出来的线会偏移。lineWidth在前端控制橡皮擦的宽度一般比画笔大两到三倍。服务端不校验宽度上限协作场景下更应关注消息类型白名单而不是在数值上做严格限制。4. SpringBoot服务端广播与冲突处理多人同时作画的顺序问题4.1 广播的线程安全JUC集合与发送失败隔离多人同时作画意味着不同用户的draw消息几乎同时到达服务端。OnMessage默认在Tomcat的WebSocket线程池里执行每个连接在同一时刻只有一个线程在跑但不同连接之间的线程是并行的。也就是说同一个房间可能同时有多个线程在往不同的Session发送消息这时候集合的并发安全就至关重要。前文用的CopyOnWriteArraySet解决的是遍历时修改的冲突这节还要补一个约束发送失败必须隔离。看4.3那段代码try-catch包住sendText捕获IOException后只记日志。很多人会在这里写throw new RuntimeException结果就是一条消息发失败整个OnMessage方法退出后续消息全部积压。这个源码的写法是对的单点失败不影响整体广播。另外要注意广播不占用HTTP线程池。WebSocket消息处理走的是容器独立的IO线程和server.tomcat.max-threads里的HTTP线程不是一回事所以不要通过调大max-threads来解决广播慢的问题真正该看的是WebSocket消息线程的繁忙程度。4.2 消息顺序保证房间级可重入锁与版本号多人同时画怎样保证A的线不会插到B的线中间导致前端错乱这里的核心不是全局强一致而是房间内“广播顺序一致性”。我的做法是在房间维度加一把可重入锁锁对象存在ConcurrentHashMap里不同房间各锁各的互不干扰private final ConcurrentHashMapString, ReentrantLock roomLocks new ConcurrentHashMap(); public void route(DrawMessage msg) { ReentrantLock lock roomLocks.computeIfAbsent(msg.getRoomId(), k - new ReentrantLock()); lock.lock(); try { // 同一房间的广播串行执行保证消息顺序 broadcastToRoom(msg.getRoomId(), msg); } finally { lock.unlock(); } }逻辑说明这把锁只保护“广播动作”不保护任何绘制逻辑绘制发生在每个前端本地。它解决的是多个客户端消息交错到达时服务端对同一个房间的发送顺序保持一致。代价是同一房间的广播退化成串行但绘画场景消息量远低于聊天场景完全够用。为什么不引入分布式锁因为Session会话是进程内的广播也是进程内的分布式锁解决的是多实例问题——如果这个项目以后要水平扩容到多个SpringBoot实例那确实需要用Redis发布订阅或MQ来跨节点广播但单机阶段加分布式锁除了把延迟从毫秒拖到几十毫秒没有任何收益。4.3 关键配置与SpringBoot项目结构几个参数一次说清拿到源码先看项目结构这个包的划分很标准config目录放WebSocket配置类ws目录放端点、连接管理和消息路由model目录放消息模型。前端单独一个vue工程src/utils里封装了WebSocket客户端src/components里是画布组件。这个结构本身就值得抄后续加功能知道往哪放。再来看application.yml里和WebSocket强相关的配置server: tomcat: max-threads: 200 accept-count: 100 spring: application: name: collab-draw mvc: pathmatch: matching-strategy: ant_path_matcher参数说明max-threads是Tomcat处理HTTP请求的线程数虽然不影响WebSocket消息线程但会影响握手连接时的并发能力200够一个小型协作平台用人多了再往上调accept-count是排队上限超过它会直接拒绝新连接所以这个值不能太小。matching-strategyant_path_matcher这一条非常关键——如果你的SpringBoot版本是2.6及以上不配这一项ServerEndpoint的路径注册在部分容器下会失败具体表现就是握手404这个坑在第五章排查记录里专门讲。Session级别的参数也别漏session.setMaxIdleTimeout(120_000); // 空闲超时兜底 session.setMaxBinaryMessageBufferSize(64 * 1024); // 二进制消息缓冲如果要支撑上千个连接getBasicRemote()逐条发送的方式可以改造成阻塞队列加批量flush一次把多条指令拼成一条批量消息发出去吞吐能提升一个量级但会牺牲单条指令的实时性。这个取舍要按业务来聊天可以批量绘画不建议。提示改配置前先确认SpringBoot的版本。高版本SpringBoot对路径匹配、静态资源、WebSocket原生支持都有行为变化先看版本再查文档能省半天排查时间。5. Vue前端接入与画布渲染常见问题排查与性能取舍5.1 Canvas 2D渲染与Vue响应式解耦Canvas绘制有一个和Vue响应式直接冲突的点canvas的context、绘制中的临时点数组、远端消息队列这些都不能放进data里。如果放进data每次mousemove触发数组push都会走一遍响应式依赖收集画布会卡到没法用。正确做法是data里只放color、lineWidth、toolType这些UI状态canvas实例和context挂在组件实例上用普通属性存。export default { data() { return { currentColor: #000000, currentLineWidth: 4, currentTool: brush } }, mounted() { this.canvas this.$refs.board this.ctx this.canvas.getContext(2d) this.remoteQueue [] // 远端消息队列不进响应式 this.renderTimer null this.connectWebSocket() } }远端消息队列用普通属性而不是data原因在于WebSocket消息高频到达时数组每push一次都会触发虚拟DOM diff。每秒几十次的数组变更Vue渲染性能会被白白消耗在画布之外的无关更新上。这个源码里把远端消息先放进队列用requestAnimationFrame统一消费一帧只重绘一次确保高频广播下的渲染稳定性。5.2 Vue路由传递房间参数刷新丢失与打包部署房间页面走动态路由/room/:roomId。URL上带参数有一个隐藏的好处就是刷新后路由参数还在不存在参数丢失问题。真正要处理的是两个坑一是路由传参会丢失对象类型从this.$route.params里取出来的一定是字符串转数字或原始值要注意二是history模式下刷新404。const routes [ { path: /, component: HomeView }, { path: /room/:roomId, name: Room, component: RoomView } ]前端打包放进SpringBoot的static目录后直接访问/room/xxx会404因为SpringMVC在静态目录里找不到这个路径对应的文件。这时需要加一个转发Controller把前端路由的路径转发到index.html由前端路由接管Controller public class SpaForwardController { GetMapping(value {/room/**, /}) public String forward() { return forward:/index.html; } }注意这个转发路径要精确不要写成/**否则把/api/**也拦了接口全部404。限到/room/**和/既能覆盖前端路由入口又不干扰后端接口。5.3 断线重连与未发送消息补偿WebSocket断线是常态Wi-Fi切换、代理超时、服务端重启都会触发。前端要做三件事检测断开、按指数退避重连、补偿未确认消息。connectWebSocket() { this.ws new WebSocket(ws://${location.host}/draw/${this.roomId}?userId${this.userId}) this.ws.onclose () { this.reconnectDelay Math.min(this.reconnectDelay * 2, 30000) setTimeout(() this.connectWebSocket(), this.reconnectDelay) } this.ws.onopen () { this.reconnectDelay 1000 this.flushPendingMessages() } }重连间隔从1秒开始每次翻倍最大30秒。指数退避是为了避免服务端恢复期间所有断开的客户端同时重连造成雪崩。flushPendingMessages把断开期间用户画的线条按顺序重发服务端的msgId去重会保证这些重发消息不会被画两次。补偿消息要做上限控制比如最多保留最近100条超过就丢弃并提醒用户画布可能不同步不能无限重发。5.4 典型案例排查记录握手404、线条叠影、路由404下面五条都是从实际运行中踩出来的按“现象 → 原因 → 解决”写遇到同样的症状可以直接对照。现象一SpringBoot启动正常但浏览器连ws://localhost:8080/draw/room-001一直握手404。 原因SpringBoot 2.6及以上默认路径匹配策略从AntPathMatcher换成了PathPatternParserServerEndpoint在这种组合下注册路径对不上。 解决application.yml里显式设置spring.mvc.pathmatch.matching-strategyant_path_matcher重启后握手恢复。这是整个项目最容易翻车的一处拿到源码先确认这一项。现象二两个浏览器同时画A画的线B能收到B画的线A收不到但B本地能看到。 原因宣讲A的接收逻辑被本地消息去重误伤或者服务端broadcastToRoom里的roomId传到的是null消息被路由到了默认房间。 解决在MessageRouter里加日志打印接收到的roomId和广播目标的roomId前端检查远端消息是否错误地进了本地去重Set远端消息和本地消息应该分别判重。现象三刷新页面后之前所有人画的内容全没了。 原因服务端没有做画布快照也没有缓存最近指令刷新等于新建连接画布自然是空的。 解决两个方案一是服务端按房间缓存最近N条指令重连时回放二是在服务端维护一个房间画布状态JSONjoin时全量下发。这个源码建议走方案一实现简单且内存可控。现象四前端打包放进SpringBoot静态目录后主页能打开但/room/xxx直接404。 原因history模式路由需要后端配合转发没有配SpaForwardController。 解决加转发Controller把/room/转发到index.html注意别拦截/api/。现象五运行一段时间后服务端报OutOfDirectMemoryError。 原因WebSocket的二进制消息缓冲区分配的是堆外内存每条消息默认64K缓冲连接数多了之后堆外内存被打满。 解决调小setMaxBinaryMessageBufferSize或排查是否有人用二进制通道传大图改成文本通道加Base64传输。6. 验证与进阶多人场景下的消息回放与本地压测技巧6.1 用Python脚本模拟多个并发客户端浏览器开几个tab只能验证功能通不通要看广播有没有丢消息还得靠脚本压。我用Python的websocket-client写过一段很轻量的压测脚本模拟5个用户同时向同一个房间发送绘画指令import json, time, uuid, threading from websocket import create_connection def send_batches(client_id, room_id): ws create_connection(fws://localhost:8080/draw/{room_id}?userId{client_id}) for i in range(20): ws.send(json.dumps({ type: draw, roomId: room_id, userId: fu-{client_id}, msgId: str(uuid.uuid4()), toolType: brush, color: #ff5722, lineWidth: 4, points: [{x: i, y: i}, {x: i1, y: i2}] })) time.sleep(0.1) ws.close() threads [threading.Thread(targetsend_batches, args(i, room-001)) for i in range(5)] [t.start() for t in threads] [t.join() for t in threads] print(all clients finished)跑完看两点一是服务端日志里有没有发送失败二是另开一个客户端统计收到的draw消息条数应该等于5个客户端发送总数。我第一次压这个项目5个客户端并发200条消息丢了7条排查半天发现是心跳线程和广播线程互相抢占资源把广播链路打断了。从那以后我每次搭实时协作项目都强制先画一张消息时序图把心跳、业务指令、ack、重连补偿四类消息的流向标清楚再动代码。时序图画清楚了并发问题能提前挡掉一大半。希望帮到你。本文还有配套的精品资源点击获取
返回列表