
简介这是一款面向计算机相关专业学生与初学者的轻量级在线聊天室实战项目基于SpringBoot与WebSocket构建聚焦Web实时通信核心能力训练适用于课程设计、毕业设计、自学进阶及项目演示。资源包共115个文件含21个Java后端逻辑文件、7个JavaScript前端交互脚本、4个CSS样式文件、2个HTML页面模板、1个SQL建表语句及1个application.yml配置文件辅以Thymeleaf模板与注解式开发摒弃JSP与XML配置显著提升可读性与可维护性压缩包仅1.59MB轻量易部署。已有172人学习下载项目源自高分毕设答辩平均分96分所有代码均经实机测试运行通过配套README说明清晰支持快速启动与功能验证。读者可直接运行体验完整登录、消息广播、用户在线状态等核心功能并基于现有结构拓展私聊、历史消息、用户权限等模块是理解SpringBoot整合WebSocket实践路径的优质入门范例。1. 为什么一个“轻量级在线聊天室”能成为 SpringBoot WebSocket 实战的黄金练手项目你可能已经写过十次 SpringBoot 的 CRUD也配过二十遍application.yml但真正卡住你晋升/转岗/接外包的从来不是“怎么启动一个服务”而是——当用户在浏览器里发一条消息后端怎么在毫秒级把这条消息推给另外 3 个正在看页面的人且不压垮服务器、不丢消息、不连错人、不被浏览器静默断开这就是轻量级在线聊天室要解决的真问题。它不是玩具 Demo而是把 SpringBoot 的 IOC 容器管理、WebSocket 的会话生命周期、HTTP 与 WS 协议切换、前端连接保活、消息广播范围控制、内存级会话映射这些能力拧在一起的最小闭环。它不依赖 Redis 或 MQ纯内存实现却暴露出所有高并发实时通信场景下的典型边界连接数暴涨时线程池打满、用户刷新页面后消息丢失、多标签页导致重复注册、心跳超时后重连逻辑失效……正因“轻量”才逼你亲手抠清每个环节——比如ServerEndpoint和MessageMapping的本质区别、StandardWebSocketSession与HandshakeInterceptor的协作时机、ConcurrentHashMap存 Session 时 key 用session.getId()还是session.getPrincipal().getName()才不会在登录态丢失时崩掉。如果你正卡在“WebSocket 能连上但收不到消息”“SpringBoot 整合 WebSocket 后 Tomcat 启动失败”“前端 ws:// 地址总报 404”这类玄学问题里这个项目就是你的后悔药。2. 从零搭起 WebSocket 基座SpringBoot 版本选型、依赖注入与端点注册2.1 SpringBoot 版本与 WebSocket 支持的隐性契约SpringBoot 对 WebSocket 的原生支持并非全版本平滑兼容。2.7.x 是分水岭2.6.x 及更早版本默认使用 Tomcat 9.x其 WebSocket 实现对ServerEndpoint注解的支持需手动开启tomcat.websocket.enabledtrue而 2.7.x 默认启用 Jakarta EE 9 规范javax.websocket.*包已废弃必须迁移到jakarta.websocket.*。若你用的是 3.x 版本如 3.2.0则必须确认spring-boot-starter-websocket依赖已声明且pom.xml中无残留javax.*包冲突。常见翻车点是IDEA 自动导入javax.websocket.Session编译通过但运行时报NoClassDefFoundError——因为 JVM 加载的是jakarta.websocket.Session。解决方案不是降级而是统一替换!-- pom.xml -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId !-- SpringBoot 3.x 必须显式指定否则可能拉取旧版 -- version3.2.0/version /dependency提示SpringBoot 3.x 要求 JDK 17且spring-boot-starter-web内置 Tomcat 10.1.x其 WebSocket 实现严格遵循 Jakarta EE 10。若项目需兼容老系统建议锁定 2.7.18LTS而非盲目追新。2.2 WebSocket 配置类不只是EnableWebSocket更是连接生命周期的守门人光加EnableWebSocket不够。它只开启配置开关真正的连接控制权在WebSocketConfigurer实现类手里。你需要重写registerWebSocketHandlers方法将自定义端点暴露给容器并植入拦截器Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { // 关键/ws/chat 是前端 new WebSocket(ws://localhost:8080/ws/chat) 的路径 registry.addHandler(chatWebSocketHandler(), /ws/chat) .setAllowedOrigins(*) // 生产环境必须限制为具体域名 .addInterceptors(new ChatHandshakeInterceptor()); // 握手前拦截校验登录态 } Bean public WebSocketHandler chatWebSocketHandler() { return new ChatWebSocketHandler(); } }这里ChatWebSocketHandler是核心处理器继承TextWebSocketHandler它决定了消息如何解析、会话如何存储、异常如何兜底。而ChatHandshakeInterceptor是你防止未登录用户直连/ws/chat的第一道防线——它在 TCP 握手完成、HTTP Upgrade 成功后、WebSocket 会话创建前执行可读取 Cookie 或 Header 中的 tokenpublic class ChatHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 从 Cookie 提取 JSESSIONID 或自定义 token String token extractTokenFromRequest(request); if (!isValidToken(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; // 拒绝握手 } // 将用户标识存入 attributes供后续 handler 使用 attributes.put(userId, getUserIdByToken(token)); return true; } private String extractTokenFromRequest(ServerHttpRequest request) { HttpHeaders headers request.getHeaders(); ListString authHeader headers.get(Authorization); if (authHeader ! null !authHeader.isEmpty()) { return authHeader.get(0).replace(Bearer , ); } // fallback to cookie HttpCookie cookie request.getCookies().getFirst(CHAT_TOKEN); return cookie ! null ? cookie.getValue() : null; } }注意attributes是MapString, Object它随 WebSocket 会话创建而传递给ChatWebSocketHandler的afterConnectionEstablished方法是跨拦截器与处理器传递上下文的唯一安全通道。别试图在beforeHandshake里往request或response写东西——此时 HTTP 响应头已部分发送写入无效。2.3 端点处理器用 TextWebSocketHandler 接住每一次连接与消息ChatWebSocketHandler不是Controller不能用MessageMapping。它是低层处理器直接操作WebSocketSessionComponent public class ChatWebSocketHandler extends TextWebSocketHandler { // 内存存储keysessionId, valueWebSocketSession private final MapString, WebSocketSession sessionStore new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 从握手拦截器传来的 userId Object userIdObj session.getAttributes().get(userId); String userId userIdObj ! null ? userIdObj.toString() : ANONYMOUS; // 记录日志并存 session System.out.println(【连接建立】用户 userId 加入Session ID: session.getId()); sessionStore.put(session.getId(), session); // 发送欢迎消息 sendMessage(session, 欢迎加入聊天室当前在线人数 sessionStore.size()); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); System.out.println(【收到消息】 session.getId() : payload); // 解析 JSON 消息简单版生产环境用 Jackson JSONObject json new JSONObject(payload); String type json.optString(type, chat); // chat, join, leave String content json.optString(content, ); String fromUser json.optString(from, ANONYMOUS); switch (type) { case chat: broadcastToOthers(session, fromUser, content); break; case join: // 用户主动声明加入可更新昵称等 break; } } private void broadcastToOthers(WebSocketSession excludeSession, String fromUser, String content) { String msg String.format([%s] %s, fromUser, content); for (WebSocketSession session : sessionStore.values()) { if (!session.getId().equals(excludeSession.getId())) { try { session.sendMessage(new TextMessage(msg)); } catch (IOException e) { // 会话已关闭清理内存 sessionStore.remove(session.getId()); System.err.println(【广播失败】Session session.getId() 已断开); } } } } private void sendMessage(WebSocketSession session, String msg) { try { session.sendMessage(new TextMessage(msg)); } catch (IOException e) { sessionStore.remove(session.getId()); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { System.out.println(【连接关闭】Session session.getId() 原因 status.getReason()); sessionStore.remove(session.getId()); } }这段代码的关键在于sessionStore必须是线程安全的ConcurrentHashMap因为多个线程不同用户的 WebSocket 消息会并发读写broadcastToOthers中的try-catch不是摆设——WebSocket 连接可能因网络抖动、浏览器关闭、心跳超时而无声断开session.sendMessage()会抛IOException此时必须立即remove否则内存泄漏afterConnectionClosed是清理入口但不能完全依赖它——某些异常断开如客户端强制 kill 进程不会触发此回调所以sendMessage的catch块才是最终防线。3. 前端连接与消息协议Vue3 Composition API 的健壮 WebSocket 封装3.1 WebSocket 连接封装不只是 new WebSocket()而是状态机 重连策略浏览器原生WebSocketAPI 极简但极脆弱。直接new WebSocket(ws://...)会导致页面刷新后连接丢失、网络恢复后不自动重连、onerror无法区分是 DNS 失败还是证书错误、readyState切换时序难把控。必须封装成带状态管理的 Class// src/utils/websocket.js export class ChatWebSocket { constructor(url, options {}) { this.url url; this.options { reconnectInterval: 3000, maxReconnectAttempts: 5, ...options }; this.ws null; this.reconnectTimer null; this.attemptCount 0; this.messageHandlers new Map(); // type - [handler] this.readyState WebSocket.CLOSED; // 0CLOSED, 1OPEN, 2CLOSING, 3CONNECTING } connect() { if (this.ws this.ws.readyState WebSocket.OPEN) return; this.ws new WebSocket(this.url); this.ws.onopen () { console.log(WebSocket 连接成功); this.readyState WebSocket.OPEN; this.attemptCount 0; this.emit(open); }; this.ws.onmessage (event) { const data JSON.parse(event.data); const handlers this.messageHandlers.get(data.type) || []; handlers.forEach(handler handler(data)); }; this.ws.onclose (event) { console.log(WebSocket 关闭code${event.code}, reason${event.reason}); this.readyState WebSocket.CLOSED; this.emit(close, event); this.scheduleReconnect(); }; this.ws.onerror (error) { console.error(WebSocket 错误:, error); this.emit(error, error); // 不在此处重连由 onclose 统一处理 }; } scheduleReconnect() { if (this.attemptCount this.options.maxReconnectAttempts) { console.warn(重连次数已达上限停止重连); return; } this.attemptCount; console.log(第 ${this.attemptCount} 次重连尝试...); this.reconnectTimer setTimeout(() { this.connect(); }, this.options.reconnectInterval); } send(type, payload {}) { if (this.ws this.ws.readyState WebSocket.OPEN) { const message { type, ...payload }; this.ws.send(JSON.stringify(message)); } else { console.warn(WebSocket 未连接消息发送失败:, type); } } on(type, handler) { if (!this.messageHandlers.has(type)) { this.messageHandlers.set(type, []); } this.messageHandlers.get(type).push(handler); } off(type, handler) { if (this.messageHandlers.has(type)) { const handlers this.messageHandlers.get(type); const index handlers.indexOf(handler); if (index -1) handlers.splice(index, 1); } } emit(event, data) { // 简单事件总线实际可用 mitt 或 Vue 的 provide/inject const listeners this[on${event.charAt(0).toUpperCase() event.slice(1)}]; if (listeners) listeners(data); } close() { if (this.ws) { this.ws.close(); clearTimeout(this.reconnectTimer); this.ws null; } } }注意onmessage中JSON.parse(event.data)是必须的——后端发来的是字符串前端必须解析才能按type分发。若后端发二进制此处需用event.data.arrayBuffer()但聊天室场景纯文本足够。3.2 Vue3 Composition API 集成用 reactive onUnmounted 实现响应式连接在 Vue 组件中使用!-- src/views/ChatRoom.vue -- script setup import { ref, reactive, onMounted, onUnmounted } from vue; import { ChatWebSocket } from /utils/websocket.js; const messages ref([]); const inputMsg ref(); const ws ref(null); // 初始化 WebSocket onMounted(() { ws.value new ChatWebSocket(ws://localhost:8080/ws/chat); // 监听服务端消息 ws.value.on(chat, (data) { messages.value.push({ from: data.from, content: data.content, time: new Date().toLocaleTimeString() }); }); ws.value.on(system, (data) { messages.value.push({ from: 系统, content: data.content, time: new Date().toLocaleTimeString(), isSystem: true }); }); ws.value.connect(); // 页面卸载时关闭连接 onUnmounted(() { ws.value?.close(); }); }); const sendMessage () { if (!inputMsg.value.trim()) return; ws.value.send(chat, { content: inputMsg.value }); inputMsg.value ; }; /script template div classchat-container div classmessages div v-for(msg, index) in messages :keyindex classmessage span classfrom{{ msg.from }}/span span classcontent{{ msg.content }}/span span classtime{{ msg.time }}/span /div /div div classinput-area input v-modelinputMsg keyup.entersendMessage placeholder输入消息... / button clicksendMessage发送/button /div /div /template关键点onUnmounted必须调用ws.close()否则标签页关闭后连接仍占用资源messages用ref而非reactive因为它是数组ref更语义清晰ws.value.on(chat)的回调函数在组件卸载后仍可能被触发如重连后收到旧消息需确保messages.value未被销毁——Vue3 的onUnmounted保证了这一点。3.3 消息协议设计用 type 字段驱动前后端协作后端ChatWebSocketHandler和前端ChatWebSocket共享一套轻量协议type说明示例 payloadchat普通聊天消息{ type: chat, content: 你好, from: 张三 }system系统通知{ type: system, content: 李四加入了聊天室 }heartbeat心跳包可选{ type: heartbeat }为什么不用 WebSocket 的 ping/pong因为浏览器不暴露 ping/pong 事件onmessage收不到。必须自定义心跳前端每 30 秒发heartbeat后端收到即回复heartbeat_ack若 60 秒内未收到任何消息则主动session.close()。这比依赖底层 TCP keepalive 更可控。4. 轻量级的代价与补救内存会话管理的三大避坑指南4.1 现象用户刷新页面后消息丢失新连接看不到历史记录原因ConcurrentHashMap存的是WebSocketSession而 Session 生命周期与浏览器 Tab 绑定。刷新页面 → 原 Session 关闭 → 新 Session 创建 → 内存中无历史消息。解决方案 A推荐引入内存级消息队列如ArrayDeque存最近 100 条新连接建立后立即推送缓存消息private final DequeString recentMessages new ArrayDeque(100); private void broadcastToOthers(...) { // ... 广播逻辑 recentMessages.offerLast(msg); if (recentMessages.size() 100) recentMessages.pollFirst(); } Override public void afterConnectionEstablished(WebSocketSession session) { // 发送欢迎消息后推送历史 recentMessages.forEach(msg - { try { session.sendMessage(new TextMessage(msg)); } catch (IOException ignored) {} }); // ... }方案 B用EventListener监听SessionConnectedEvent但 Spring Boot 3.x 中该事件已被移除不推荐。4.2 现象多标签页登录同一账号发消息时自己也收到一份原因sessionStore按session.getId()存储而同一用户开两个 Tab会生成两个独立 SessionbroadcastToOthers仅排除当前 Session未排除同用户其他 Session。解决改用userId作为广播排除维度// 在 afterConnectionEstablished 中将 userId 与 session 关联 String userId (String) session.getAttributes().get(userId); // 用 userId 为 key 的 Map 存 session 列表一个用户可能多个 session userSessions.computeIfAbsent(userId, k - new CopyOnWriteArrayList()).add(session); // broadcastToOthers 改为 private void broadcastToOthersByUserId(String excludeUserId, String fromUser, String content) { String msg String.format([%s] %s, fromUser, content); for (Map.EntryString, ListWebSocketSession entry : userSessions.entrySet()) { String userId entry.getKey(); if (userId.equals(excludeUserId)) continue; // 排除同用户所有 session for (WebSocketSession session : entry.getValue()) { try { session.sendMessage(new TextMessage(msg)); } catch (IOException e) { /* 清理 */ } } } }4.3 现象高并发下 Tomcat 线程池耗尽新连接拒绝原因WebSocket 连接本身不占 Servlet 线程但TextWebSocketHandler的handleTextMessage等回调在 Tomcat 的WebSocketExecutor线程池中执行默认大小为200。若消息处理逻辑含数据库查询或远程调用线程阻塞池子迅速耗尽。解决第一步确认是否真阻塞——加日志看handleTextMessage执行时间第二步若含 IO必须异步化Override protected void handleTextMessage(WebSocketSession session, TextMessage message) { // 提交到独立线程池避免阻塞 WebSocket 线程 messageExecutor.submit(() - { try { processMessage(session, message.getPayload()); } catch (Exception e) { log.error(消息处理异常, e); } }); } Bean public Executor messageExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(100); executor.setThreadNamePrefix(websocket-message-); executor.initialize(); return executor; }第三步调整 Tomcat WebSocket 线程池application.ymlserver: tomcat: threads: max: 500 # 提升最大线程数 spring: websocket: servlet: init-parameters: org.apache.tomcat.websocket.BUFFER_SIZE: 8192提示ConcurrentHashMap的computeIfAbsent是原子操作但CopyOnWriteArrayList的add不是——userSessions的 value 是List必须用线程安全集合。CopyOnWriteArrayList适合读多写少场景用户上线/下线频次远低于消息收发若写频繁改用Collections.synchronizedList(new ArrayList())。5. 文档说明与源码交付让项目真正“可交付”的三个硬核动作5.1 README.md 必须包含的五要素不是写给开发者是写给“接手者”一个合格的README.md不是功能列表而是降低接手成本的说明书。必须包含要素内容示例为什么重要环境要求JDK 17, Maven 3.8, Node.js 16避免“在我机器上好好的”陷阱明确最低版本快速启动1. 后端mvn spring-boot:run2. 前端cd frontend npm install npm run dev3. 访问 http://localhost:8080新人 3 分钟跑起来建立信心关键配置项application.yml 中修改br- server.port: 8080br- spring.web.resources.static-location: classpath:/static/防止因端口冲突或静态资源路径错位导致白屏API 端点清单WebSocket 端点ws://localhost:8080/ws/chatHTTP 接口如登录POST /api/login前端开发无需翻源码找地址常见问题Q连接报 404brA检查 application.yml 是否启用了 websockets且路径与前端 new WebSocket() 一致把你踩过的坑提前写成 FAQ注意README.md中禁止出现“详见源码”“自行调试”等甩锅话术。每一行都是为节省接手者 10 分钟而写。5.2 源码结构标准化让 IDE 能自动识别而不是靠猜一个被反复克隆的项目目录结构必须符合 Java/JS 社区直觉chat-room/ ├── backend/ # SpringBoot 项目根目录 │ ├── pom.xml # 依赖清晰无冗余插件 │ ├── src/main/java/com/example/chat/ │ │ ├── ChatApplication.java # 主启动类SpringBootApplication │ │ ├── config/ # WebSocketConfig 等配置类 │ │ ├── handler/ # ChatWebSocketHandler │ │ └── interceptor/ # ChatHandshakeInterceptor │ └── src/main/resources/application.yml ├── frontend/ # Vue3 项目 │ ├── package.json # scripts 包含 build/dev │ ├── vite.config.js # 明确代理 /ws/ 到 localhost:8080 │ └── src/ │ ├── utils/websocket.js # 封装类 │ └── views/ChatRoom.vue # 核心组件 └── docs/ ├── deployment.md # Docker 部署步骤可选 └── api-spec.md # WebSocket 消息协议全文type/字段/示例关键细节frontend/vite.config.js必须有代理配置解决跨域export default defineConfig({ server: { proxy: { /ws: { target: http://localhost:8080, changeOrigin: true, ws: true // 必须开启否则 WebSocket 代理失效 } } } })backend/src/main/resources/static/下放index.html作为欢迎页避免http://localhost:8080返回 404。5.3 文档说明的终极检验用“三分钟测试法”验证交付质量交付前找一位没碰过项目的同事给他三分钟不给任何口头指导只发README.md链接让他按文档操作目标打开浏览器看到聊天界面发送一条消息收到回显记录他卡在哪一步卡多久。如果他在 3 分钟内完成说明文档合格如果卡在“不知道 npm run dev 启什么”“找不到 index.html”说明文档缺失关键路径如果卡在“WebSocket 连接失败”说明application.yml配置或端点路径描述不清。文档的价值不在于写了多少字而在于让陌生人不问一句就跑通。我带过的实习生第一份任务就是给项目写README写不好就重写——因为这是工程师交付能力的起点。希望帮到你。本文还有配套的精品资源点击获取