免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

基于WebSocket的多人实时协作绘画平台设计与实现

基于WebSocket的多人实时协作绘画平台设计与实现 简介在前后端分离架构中实时通信是构建在线协作类应用的核心技术。WebSocket作为全双工长连接协议凭借低延迟和双向通信优势已成为白板协作、共享画布、在线批注等场景的首选方案。本文从实时通信基础原理出发讲解如何利用SpringBoot后端与Vue前端搭建多人实时绘画平台涵盖WebSocket连接管理、消息协议设计、Canvas矢量笔迹同步、历史画布恢复、断线重连与心跳保活等关键环节。通过实际项目案例展示从开发到Nginx部署的完整链路并总结常见问题与排查技巧帮助开发者快速掌握实时协作类应用的工程化实现思路。 多人协作绘画最怕什么画着画着别人看不到你的笔迹或者说一句话要等半秒才显示那种体验基本就废了。我最近在做一个基于SpringBootVueWebSocket的多人实时在线协作绘画平台今天把整套设计思路和源码实现细节从头到尾捋一遍。适合正在做实时交互类项目的同学参考尤其是前后端分离架构下WebSocket的落地场景——用来做协作白板、共享画布、在线批注之类的功能完全可以直接抄作业。这个项目本身不复杂核心就一句话用WebSocket把多个浏览器客户端的绘画事件实时同步到服务端再由服务端广播给其他人。但真正写起来你会发现连接管理、消息协议、画布同步、断线重连、心跳保活每一个节点都有坑。我会把关键代码、参数选择、踩过的坑全部分享出来。1. 项目需求拆解与技术选型1.1 核心需求从“单人画板”到“多人同步画板”先看需求。单机版绘画板很好做无非是Canvas监听鼠标事件画完之后生成base64图片。但多人协作意味着两个核心变化第一每个操作都是消息。画了一笔不是一个纯本地行为而是一次事件广播。其他用户收到后在自己的画布上重放。所以必须有一个稳定的实时通信通道。第二状态要一致。新加入的人要能看到此前别人画的所有内容否则他打开页面就是一片空白。因此还需要一个“历史画布数据”的恢复机制比如保存所有笔迹坐标或者定期生成快照。基于这两点我选型如下能力模块技术方案选型理由后端基础框架SpringBoot 2.7.x生态成熟WebSocket集成简单团队上手快前端框架Vue 3 Vite组合式API写实时交互更清爽Vite开发调试体验好实时通信原生WebSocketSpring WebSocket模块不引入STOMP是因为本文案场景只有“广播”一种消息模式原生协议更轻量定制空间大数据库MySQL存用户和房间只需要房间维度的元信息不需要存储全部笔迹降低复杂度画布实现HTML5 Canvas 矢量笔迹数据直接传base64图片带宽压力大矢量坐标重绘更平滑为什么不用轮询或SSE轮询延迟高SSE是单向的无法满足双向实时通信。WebSocket在低延迟、双向通信上都有天然优势。如果想深入对比可以看WebSocket是长连接全双工连接建立后不需要反复握手对高频绘画事件来说这是最合适的协议。1.2 项目结构前后端分离模块怎么划分整个项目我保持了典型的前后端分离结构collaborative-drawing/ ├── backend/ │ ├── src/main/java/com/drawing/ │ │ ├── config/ // WebSocket配置、跨域配置 │ │ ├── controller/ // 房间创建、用户进入等HTTP接口 │ │ ├── model/ // 消息实体、房间实体 │ │ ├── handler/ // WebSocket处理器 │ │ └── service/ // 房间管理服务 │ └── src/main/resources/ └── frontend/ ├── src/ │ ├── api/ // HTTP接口封装 │ ├── components/ // 画布组件、颜色选择器等 │ ├── views/ // 首页、绘画页 │ ├── store/ // Pinia状态管理 │ └── utils/ // WebSocket封装这个结构有几个好处后端只需要管好“连接”和“转发”具体的画布逻辑全部放前端服务端不关心你的画笔是什么颜色、粗度是多少它只负责把每个事件原封不动地派发给房间内其他人。这样职责边界非常clear后续如果要扩展聊天、白板标注等能力只需要加消息类型。2. 后端实时通信核心实现2.1 加入依赖与WebSocket配置SpringBoot引入WebSocket非常方便先在pom.xml加入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency然后写一个配置类注册WebSocket端点Configuration public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(drawingHandler(), /draw) .setAllowedOrigins(*); } Bean public WebSocketHandler drawingHandler() { return new DrawingHandler(); } }这里要注意setAllowedOrigins(*)在开发环境确实方便但生产环境必须显式指定域名否则会被跨域风险和安全扫描盯上。如果前端端口是5173那就写setAllowedOrigins(http://localhost:5173)。2.2 消息协议设计一个JSON搞定所有事件多人协作绘画需要同步的事件类型不多但设计协议时要有前瞻性。我定义了一个统一的消息模型public class Message { private String type; // 消息类型join, draw, clear, history, pong... private String roomId; // 房间ID private String userId; // 用户ID private Object data; // 数据体可能是笔迹坐标、清屏指令等 }前端发送的消息都是这样一个结构后端只做校验和转发。data字段用Object接收再通过Jackson转成对应的对象这样不同消息类型可以携带不同的数据体同时又不需要为每种消息写一堆类。常见的消息类型如下消息类型方向data内容join客户端 → 服务端房间信息draw客户端 → 服务端 → 其他客户端笔迹点数组 [{x, y}, ...]clear客户端 → 服务端 → 其他客户端无history服务端 → 新加入客户端历史笔迹数据pong客户端 → 服务端心跳回复draw消息中我传的是“一段笔画”的数据而不是单个点。这样有几个好处第一减少消息数量用户鼠标按下到抬起之间攒一批点一次性发送网络压力小很多第二接收端可以一次性重绘避免频繁重绘导致的闪烁。2.3 连接管理与房间广播核心处理器是DrawingHandler继承TextWebSocketHandler重写三个方法Component public class DrawingHandler extends TextWebSocketHandler { // 房间 - 该房间所有会话 private final MapString, ListWebSocketSession roomSessions new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 从URL参数中拿到roomId比如 ws://localhost:8080/draw?roomroom1userIduser1 String room session.getAttributes().get(roomId).toString(); roomSessions.computeIfAbsent(room, k - new CopyOnWriteArrayList()).add(session); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 解析JSON转发给同房间其他人 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String room session.getAttributes().get(roomId).toString(); ListWebSocketSession sessions roomSessions.get(room); if (sessions ! null) { sessions.remove(session); } } }房间ID我从URL参数解析在握手拦截器中放入session attributes。这样每个连接进来就能知道自己属于哪个房间不用在消息里反复携带房间ID也方便定向广播。广播逻辑简单粗暴但有效private void broadcastToRoom(String roomId, String userId, String payload) { ListWebSocketSession sessions roomSessions.get(roomId); if (sessions null || sessions.isEmpty()) { return; } for (WebSocketSession s : sessions) { if (s.isOpen() !userId.equals(s.getAttributes().get(userId))) { s.sendMessage(new TextMessage(payload)); } } }这里注意要排除发送者自己否则前端的处理逻辑会重复画两次。前端自己绘制的笔迹由本地Canvas直接绘制不需要再走一遍网络回包。2.4 历史画布恢复新用户不白屏新用户加入一个已经画了很多内容的房间如果不做任何处理他只能看到之后产生的笔迹。我采用了一个简单的方案后端内存中维护每个房间的“历史笔迹列表”用户加入时一次性推给他。private final MapString, ListObject roomHistory new ConcurrentHashMap(); // 在handleTextMessage中收到draw消息时 ListObject history roomHistory.computeIfAbsent(roomId, k - new ArrayList()); history.add(drawData); // 在afterConnectionEstablished中发送history消息 session.sendMessage(new TextMessage(historyJson));这个方案适合教学项目和中小规模场景内存里放一段时间内的笔迹数量大了以后再做滑动窗口比如只保留最近500条。更专业的做法是存Redis或者直接落库但那样IO成本高还需要考虑时序问题反而把项目搞复杂了。3. 前端绘画交互与WebSocket集成3.1 Canvas画笔实现从鼠标事件到矢量坐标前端我用了Vue 3组合式API Canvas 2D。画笔逻辑很直接canvas idboard width800 height600/canvasconst canvas ref(null); const ctx ref(null); let drawing false; let currentPoints []; function onMouseDown(e) { drawing true; currentPoints []; const { x, y } getPos(e); ctx.value.beginPath(); ctx.value.moveTo(x, y); currentPoints.push({ x, y }); } function onMouseMove(e) { if (!drawing) return; const { x, y } getPos(e); ctx.value.lineTo(x, y); ctx.value.stroke(); currentPoints.push({ x, y }); } function onMouseUp(e) { if (!drawing) return; drawing false; // 把这一笔的坐标数组发给服务端 ws.send(JSON.stringify({ type: draw, roomId: currentRoomId, userId: currentUserId, data: currentPoints })); currentPoints []; }getPos需要计算鼠标相对于Canvas左上角的坐标不能用e.clientX直接用因为Canvas可能不在视口左上角function getPos(e) { const rect canvas.value.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }这个过程有几处容易出问题的地方stroke()每次调用都会从beginPath的位置重画效率低一点但对实时绘画影响不大简单直接最稳妥。另外如果用高分辨率屏还需要处理Canvas的缩放比例否则画出来是模糊的const dpr window.devicePixelRatio || 1; canvas.value.width width * dpr; canvas.value.height height * dpr; canvas.value.style.width width px; canvas.value.style.height height px; ctx.value.scale(dpr, dpr);这块不处理在Retina屏上画出来的线条就会发虚。我当时排查了很久才发现是这个问题。3.2 远程笔迹重放收到数据怎么画收到draw消息后前端需要把别人的笔迹画出来。注意要从对方坐标数组的第一个点开始连接function handleDraw(data) { const points data; if (points.length 0) return; ctx.value.beginPath(); ctx.value.moveTo(points[0].x, points[0].y); for (let i 1; i points.length; i) { ctx.value.lineTo(points[i].x, points[i].y); } ctx.value.stroke(); }有人会问为什么不用lineCap如果默认因为Canvas中stroke是整个路径一次性处理的所以跨多个点的折线会自动连接不会有锯齿。还有一个细节画笔颜色和粗细必须跟着消息一起传。每个人可能选了不同的颜色如果不传新用户就会用默认颜色画别人的笔迹。我在draw消息的data里加了一个对象data: { points: currentPoints, color: currentColor, size: currentSize }后端把整个data原样广播前端重绘时先设置strokeStyle和lineWidth再画路径。这样每个人的画笔状态就是独立的。3.3 WebSocket封装自动重连与心跳保活前端WebSocket不能裸写必须封装一个带重连机制的类。我在utils/ws.js里做了这样的封装let socket null; let retryCount 0; let heartBeatTimer null; export function connectWebSocket(roomId, userId, handlers) { const protocol location.protocol https: ? wss : ws; const url ${protocol}://${location.host}/draw?room${roomId}userId${userId}; socket new WebSocket(url); socket.onopen () { retryCount 0; startHeartBeat(); }; socket.onmessage (event) { const msg JSON.parse(event.data); if (msg.type history) { handlers.onHistory(msg.data); } else if (msg.type draw) { handlers.onDraw(msg.data); } else if (msg.type clear) { handlers.onClear(); } }; socket.onclose () { stopHeartBeat(); if (retryCount 5) { retryCount; setTimeout(() connectWebSocket(roomId, userId, handlers), 1000 * retryCount); } }; socket.onerror (err) { console.error(WebSocket error:, err); // 某些情况下error后会自动close所以不用在这里重连 }; } function startHeartBeat() { heartBeatTimer setInterval(() { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify({ type: ping })); } }, 30000); }后端收到ping消息后简单回一个pong或者直接忽略因为WebSocket协议本身有心跳机制但浏览器端JS无法直接控制协议层的心跳报文所以应用层心跳是必要的。如果不做心跳连接在一段时间空闲后会被Nginx或云服务商的网关断开。我之前被这个问题坑过一次画板放着不动几分钟再画就没反应了就是因为连接已被静默断开前端还浑然不知。加了心跳之后连接稳定性显著提升。断线重连的间隔用退避策略第一次1秒第二次2秒最多5次避免短时间频繁重连打爆服务端。断线期间用户画的本地笔迹不会丢但无法同步给其他人这一点在UI上可以给个提示“连接已断开正在重连”我当时是加了一个状态角标。4. 原型系统集成与前后端部署实践4.1 SpringBoot CORS与WebSocket拦截器细节前后端分离后HTTP接口会面临跨域问题。SpringBoot的处理方式很常规Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(*); } }但WebSocket的握手请求不受这个CORS配置管控需要在WebSocket握手阶段处理。在WebSocketConfig里调用setAllowedOrigins即可前面已经提过。如果是Nginx反代还需要在Nginx配置中允许Upgrade相关的请求头这个我们放到后面排查部分说。线程模型方面SpringBoot默认内嵌Tomcat会对WebSocket连接做阻塞式IO处理Tomcat 8.5以上版本已经支持Java WebSocket 1.1的异步处理。我这里没有做复杂的并发控制因为每段笔迹的消息体就几百字节单机几百个并发连接完全没问题。但如果要做集群部署就需要引入消息中间件进行跨节点广播了这就是另一个层面的架构问题了。4.2 前端Vue组件化工具栏、颜色选择器和画布分离前端不能把所有逻辑塞在一个页面里至少要拆成工具栏组件、画布组件和连接状态组件。我的页面结构如下DrawingBoard.vue ├── ToolBar.vue // 画笔颜色、粗细、清除画布按钮 └── BoardCanvas.vue // Canvas画布 鼠标事件 重绘逻辑ToolBar中用Pinia来保存当前颜色和粗细BoardCanvas读取这些状态工具栏上“清除”按钮触发一个clear消息前端收到后清空画布同时后端也清空该房间的历史笔迹防止别人加入时又看到已清除的内容function clearBoard() { ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height); ws.send(JSON.stringify({ type: clear, roomId: currentRoomId, userId: currentUserId })); }后端收到clear后把对应房间的历史列表清空再向其他人广播clear。颜色选择器我用的是input typecolor胜在原生、零依赖。粗细滑块用input typerange这两个配合起来就能满足基础绘图需求。如果想更专业可以上slider预设笔刷但核心逻辑不变。4.3 使用Nginx代理WebSocket的配置示例如果前端构建后部署到Nginx后端单独跑在8080端口那么Nginx配置需要把HTTP请求和WebSocket升级请求都代理到后端。这里给出完整的配置片段server { listen 80; server_name drawing.example.com; # 前端静态资源 location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } # 后端HTTP接口 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # WebSocket升级 location /draw { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }关键就是proxy_set_header Upgrade $http_upgrade和Connection upgrade这两行。没有它们浏览器握手请求会被Nginx当作普通HTTP请求处理返回400或504。proxy_read_timeout默认60秒如果不改大WebSocket长时间空闲会被Nginx主动断开心跳可以部分解决但直接把超时调大更省心。4.4 从开发到生产Docker部署注意事项我习惯把后端和前端分别打Docker镜像用docker-compose一键拉起。后端Dockerfile非常简单FROM maven:3.8-openjdk-11 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM openjdk:11-jre-slim WORKDIR /app COPY --frombuild /app/target/drawing.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]前端Dockerfile更简单构建完的静态文件放到Nginx镜像里FROM node:18 AS build WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:stable-alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80注意前端在构建时要配置环境变量把VITE_WS_BASE指向ws://域名/draw不要写死localhost。我在connectWebSocket里用location.host来动态拼接这样就可以直接适配不同域名不需要改代码。5. 常见问题与排查技巧实录5.1 连接建立后立即断开状态码1006这是我在开发中遇到最多的问题。前端显示WebSocket连接失败状态码1006表示连接异常关闭通常不是浏览器主动关闭而是服务端或代理层把连接断了。排查步骤先检查后端日志看握手是否成功afterConnectionEstablished有没有被调用。如果没被调用检查端点地址是否匹配客户端请求的路径与registry.addHandler中的路径是否一致。如果后端有权限拦截或过滤器检查是否把WebSocket握手请求拦掉了比如Shiro、Spring Security会默认拦截所有请求。如果在Nginx后面检查Upgrade和Connection配置。最后看端口是否被防火墙拦截netstat -an | grep 8080看一下监听状态。有一个容易踩的坑在SpringBoot中如果同时引入了spring-boot-starter-securityWebSocket握手会默认走认证流程导致401。解决方案是在Security配置中放行WebSocket端点http.authorizeRequests() .antMatchers(/draw, /api/**).permitAll() ...或者干脆在WebSocket握手拦截器中做自定义认证用Token参数校验身份。5.2 多人同时画画面互相覆盖或者错乱这个问题一般不是网络问题而是画笔状态没有隔离。比如用户A设置了红色画笔他发的消息里带了color但用户B收到后没有先设置strokeStyle就直接画导致红色画完后再画自己的黑色结果两个人的笔迹颜色混在一起。解决思路远端重绘时每次都重新设置颜色和粗细不要依赖上一次的状态。同时在draw消息的数据体里把color和size放在points旁边。一次完整的消息结构如下{ type: draw, roomId: room1, userId: user1, data: { points: [{x: 10, y: 20}, {x: 11, y: 22}], color: #ff0000, size: 3 } }5.3 历史消息推送时机问题新用户加入时后端在afterConnectionEstablished里发送history消息。但有一个并发问题如果用户加入的瞬间正好有人正在画一笔那这一笔可能已经写入历史列表而前端还没收到history消息就收到了draw消息导致顺序错乱——先画了最新一笔然后又被history整个重绘覆盖最新一笔反而不见了。我的解决办法是前端收到history后先清空画布再执行历史重绘在连接建立之后的一小段时间内收到的draw消息先缓存起来等history处理完再批量执行。也可以更简单地在后端广播时加一个序号前端按序号排序但这会增加协议复杂度。对Demo项目来说用“缓存后处理”的方式足够了。let pendingDraws []; socket.onmessage (event) { const msg JSON.parse(event.data); if (msg.type history) { historyLoaded true; clearCanvas(); redrawHistory(msg.data); pendingDraws.forEach(draw handleDraw(draw)); pendingDraws []; } else if (msg.type draw) { if (!historyLoaded) { pendingDraws.push(msg.data); } else { handleDraw(msg.data); } } };5.4 线程安全与内存泄漏roomSessions和roomHistory我用了ConcurrentHashMap内层的List用了CopyOnWriteArrayList保证并发修改和遍历不冲突。但这只是单机场景。如果连接不关闭历史列表会无限增长形成内存泄漏。因此我简单加了一个上限if (history.size() 500) { history.subList(0, history.size() - 500).clear(); }这种方式很粗暴却能保证内存不会无限膨胀。你也可以把历史数据持久化到Redis或数据库每次新用户加入时从数据库读取。那个方案会重很多但对团队协作产品来说更靠谱。5.5 白屏问题一定检查Canvas宽高设置很多新手会把Canvas的宽高写死在HTML标签里比如canvas width800 height600/canvas这在静态页面没问题但如果Canvas放在一个响应式布局中或者在对话框/弹窗里显示实际显示尺寸往往会被CSS缩放而内部绘图分辨率和显示尺寸不匹配画出来的线会偏移和模糊。最稳妥做法是在mounted里通过容器尺寸动态设置Canvas的width和height同时处理devicePixelRatio然后监听窗口大小变化重设尺寸并重绘历史数据。这部分的代码比较多但在协作绘画项目中属于基本功。5.6 消息体过大导致连接卡死如果鼠标快速移动mouseMove事件触发频率会很高。我试过把每个点都实时发送结果消息队列积压画布卡成PPT。后来改为“一笔一笔发”只在鼠标抬起时发送整段笔迹。这样一个笔画最多十几个或几十个点消息体几十字节到几百字节完全在可接受范围。如果要更精细的实时效果比如看到对方毛笔笔锋的实时轨迹可以增加定时批量发送每50ms发送一次增量点这样既有实时性又不会太频繁。我的项目没有做这么细因为一笔一画的方式对普通白板场景已经够了。6. 项目扩展与个人经验总结这个平台的骨架搭建起来之后后续扩展空间非常大。我整理了几个可以继续深入的方向更多工具类型矩形、圆形、直线、文字输入等等只需要扩展消息类型的data结构。实时在线状态通过join和leave消息维护用户列表显示当前房间有哪些人。Undo/Redo后端维护操作栈广播undo指令接收端回退一笔。笔迹同步优化用差分同步算法只发送变化区域或者用二进制协议protobuf替代JSON提升大数据量场景下的性能。服务端集群化多个实例间通过Redis Pub/Sub或MQ做消息转发保证跨实例房间广播的一致性。根据我个人几次做实时协作项目的体会WebSocket本身并不难难的是消息协议设计和异常恢复机制。这个项目里我踩得最深的坑有两个一个是Nginx代理配置缺失导致外网连接不稳定另一个是历史消息和实时消息的顺序问题。如果你也打算写类似功能建议先从这两个点入手设计能少走不少弯路——先把一次典型的多人会话流程画清楚再写代码后面会省很多事。本文还有配套的精品资源点击获取
返回列表