
简介这是一款面向计算机相关专业在校学生、初学者及课程设计者的轻量级在线聊天室实战项目基于SpringBoot与WebSocket构建解决传统JSPXML方案维护性差、技术栈陈旧等问题适用于毕设、课设、作业演示及全栈技能进阶学习。资源包共115个文件含21个核心Java后端类涵盖WebSocket配置、消息处理与用户管理、7个前端JS交互逻辑、4个CSS样式文件含bootstrap.css、sweetalert.css等响应式与提示组件、2个HTML页面Thymeleaf模板、2个配置文件application.yml与properties及1个初始化SQL脚本整体仅1.59MB结构清晰、去冗余、注解驱动便于快速理解与二次开发。已有171人下载学习项目源自高分毕设答辩均分96分所有代码经实测运行无误并附完整README说明文档提供从环境搭建、登录注册、实时消息推送到群聊/私聊的全流程实现细节与典型GIF操作演示助力开发者掌握SpringBoot整合WebSocket的核心实践模式。 手头正好有一个小巧的 SpringBoot WebSocket 在线聊天室项目源码和说明文档都齐全。当初从零搭起来花了三天跑通以后发现很多同学卡在同样的几个坑上握手阶段怎么带用户认证、Session 管理为什么要用 Map、Nginx 代理 WebSocket 为什么会 502、连接莫名断开报 1006 怎么排查。这些点我全部踩过一遍这篇就把整个项目的设计思路、核心代码、部署方案和排障记录完整拆给你照着抄就能出一个轻量级聊天室。1. 项目概述与整体设计方案1.1 为什么选 SpringBoot 原生 WebSocket 而不是 Netty 或 STOMP这个聊天室起名“轻量级”本质上就是拒绝重框架。一开始我也纠结过要不要上 Netty后来想到核心需求就是几十人规模的在线群聊不需要百万级长连接没必要引入 Netty 那种复杂的 Reactor 模型和 Pipeline 链。SpringBoot 内置的spring-boot-starter-websocket打包后不到 2MB启动一个内嵌 Tomcat 就能跑对毕设、个人项目、快速原型完全够用。再看 STOMP 协议它是基于 WebSocket 的消息子协议Spring 也支持MessageMapping那套注解开发但要配置 Broker、目的地前缀、心跳间隔还要理解订阅链路学习成本和代码量反而比原生 API 高。对于“就是要把消息从一个浏览器推到另一个浏览器”这个场景原生 WebSocket 的三板斧——握手拦截器、WebSocketHandler、WebSocketSession——直来直去反而干净利落。技术选型最终定下来是这样SpringBoot 2.7 原生javax.websocket相关依赖 前端原生 WebSocket API不用任何消息中间件不用 Redis 做分布式Session 直接存在 JVM 内存里。这套方案的上限大概能支撑单机几百个在线连接再往上就该考虑集群和 Redis 了但那是下一篇的故事。1.2 需求拆解与功能清单做项目前先列需求我给自己定的是“纯聊天不花哨”。最终落地的功能点如下用户输入昵称进入聊天室后端分配一个颜色标识不用账号密码登录聊天室广播消息所有在线用户实时收到展示在线用户列表有人进入或离开时系统自动广播提示支持私聊功能消息格式里带上目标用户 ID前端页面用纯 HTML JavaScript 实现不引入 Vue / React 等框架后端提供两个接口一个用于用户进入聊天室时获取历史消息可选一个用于 WebSocket 连接握手时的参数校验这个功能清单最核心的价值在于它覆盖了 WebSocket 开发中 90% 的常见操作——连接建立、参数传递、Session 管理、消息广播、点对点发送、连接关闭清理。把这些跑通你就能举一反三写出更复杂的即时通讯系统。1.3 目录结构与工程搭建工程结构按 SpringBoot 标准分包ChatEndpoint是 WebSocket 入口ChatSessionManager管 SessionMessage是消息体WebSocketConfig负责把 Endpoint 注册到 Spring 容器。前端只有一个chat.html静态资源直接放resources/static下面。chat-room/ ├── pom.xml └── src/main/ ├── java/com/example/chatrooom/ │ ├── ChatRoomApplication.java │ ├── config/WebSocketConfig.java │ ├── endpoint/ChatEndpoint.java │ ├── manager/ChatSessionManager.java │ ├── model/Message.java │ └── interceptor/AuthHandshakeInterceptor.java └── resources/ ├── application.yml └── static/chat.htmlpom.xml里依赖极简就spring-boot-starter-web和spring-boot-starter-websocket两个核心依赖。这里有个重要提醒SpringBoot 2.x 用javax.websocket包下的 APISpringBoot 3.x 换了jakarta.websocket命名空间包名不一样代码几乎要逐行改。建议做这个项目直接用 SpringBoot 2.7.x教程和踩坑资料都最全避开学 SpringBoot 3 时的命名空间地狱。2. 核心接口与关键原理解读2.1 WebSocket 握手机制与 HTTP 的升级请求WebSocket 能实现全双工通信核心在于握手阶段通过 HTTP 协议完成“协议升级”。浏览器发一个带Upgrade: websocket头的 HTTP 请求服务端处理完回101 Switching Protocols后续双方就在这条 TCP 连接上自由收发帧Frame数据。这个机制决定了 WebSocket 开发的第一个重点握手阶段是携带用户信息的唯一机会。因为一旦连接建立HTTP 的请求头就没了服务端拿不到 Cookie 之外的任何信息。实际项目里常见的做法是让前端在连接 URL 上拼接参数比如ws://localhost:8080/chat?username测试用户后端在握手时把这个参数读出来保存后续整个连接生命周期都用它标识用户。我在AuthHandshakeInterceptor里就是这么做用户认证的public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest (ServletServerHttpRequest) request; String username servletRequest.getServletRequest().getParameter(username); if (username null || username.trim().isEmpty()) { return false; // 拒绝握手 } attributes.put(username, username); // 把用户信息放进会话属性 } return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的回调一般不需要实现 } }attributes这个 Map 是关键它是握手阶段和WebSocketSession之间的数据通道握手时放进去的内容在ChatEndpoint的afterConnectionEstablished里通过session.getAttributes()拿到。2.2 SpringBoot 原生 WebSocket 的注册方式与配置类SpringBoot 集成原生 WebSocket 有两种写法一种是服务端实现ServerEndpoint(/chat)注解并使用ServerEndpointExporter另一种是继承TextWebSocketHandler并通过WebSocketConfigurer注册。我选了第二种因为WebSocketConfigurer可以顺手注册拦截器和自定义WebSocketHandler代码组织更优雅调试时链路也清楚。配置类WebSocketConfig的关键代码Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatEndpoint(), /chat) .addInterceptors(authHandshakeInterceptor()) .setAllowedOrigins(*); } Bean public WebSocketHandler chatEndpoint() { return new ChatEndpoint(); } Bean public HandshakeInterceptor authHandshakeInterceptor() { return new AuthHandshakeInterceptor(); } }setAllowedOrigins(*)是开发阶段的配置允许任何来源的跨域连接部署时建议改成具体域名。很多同学遇到“前端一直连接不上但后端也没报错”的问题八成就是这里没放行跨域浏览器控制台里会出现Origin is not allowed之类的提示。2.3 WebSocket 的 Session 管理为什么不用 ConcurrentHashMap 就行整个项目中最重要的类是ChatSessionManager。它是一个单例管理器维护所有在线用户的连接会话。核心数据结构是两个ConcurrentHashMap一个以 userId 为 key 存用户信息一个以 userId 为 key 存WebSocketSession。Component public class ChatSessionManager { private final MapString, WebSocketSession sessionMap new ConcurrentHashMap(); private final MapString, String usernameMap new ConcurrentHashMap(); public void addSession(String userId, WebSocketSession session) { sessionMap.put(userId, session); } public void removeSession(String userId) { sessionMap.remove(userId); usernameMap.remove(userId); } public WebSocketSession getSession(String userId) { return sessionMap.get(userId); } public CollectionWebSocketSession getAllSessions() { return sessionMap.values(); } public int getOnlineCount() { return sessionMap.size(); } }有人会问为什么不用CopyOnWriteArraySet存所有 Session然后遍历发送因为私聊功能需要精准找到“某个用户”的 Session用 Set 就得遍历效率低且代码绕。以 userId 为 key 的 Map 是点对点通信的天然选择取 Session 的时间复杂度是 O(1)。这里有个极其容易踩的坑WebSocketSession 不是线程安全的。如果两个线程同时对同一个 Session 调用sendMessage可能出现消息交错甚至异常。我处理的方式是把发送操作包在synchronized代码块里以每个 Session 的 ID 作为锁的粒度避免全局锁影响性能。public void sendMessageToUser(String userId, String message) throws IOException { WebSocketSession session sessionMap.get(userId); if (session ! null session.isOpen()) { synchronized (session.getId().intern()) { session.sendMessage(new TextMessage(message)); } } }2.4 消息协议设计用 JSON 的 type 字段区分消息类型消息对象Message是前后端通信的唯一契约设计得是否清晰直接决定聊天室能扩展出多少功能。我用一个统一的 JSON 结构通过type字段区分不同场景{ type: chat, fromUserId: u001, fromUsername: 小明, toUserId: u002, content: 你好, timestamp: 1690000000000 }type目前有四种取值chat表示普通聊天消息system表示系统通知有人进入/离开online表示在线用户列表更新private表示私聊消息。后端解析时用ObjectMapper转成 JsonNode再取出type字段做分发public void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode node objectMapper.readTree(message.getPayload()); String type node.get(type).asText(); switch (type) { case chat: broadcastChatMessage(node, session); break; case private: sendPrivateMessage(node); break; default: // 忽略未知类型 break; } }消息协议的设计是很多教程容易忽略的地方但恰恰是最值得花时间的部分。一个字段位没说清楚的错误协议会让你在扩展功能时痛不欲生。参考成熟协议的做法前期的 type 分派就能让代码结构保持整洁。3. 完整实现从后端到前端的全套代码3.1 后端核心类ChatEndpoint 的完整实现ChatEndpoint是整个聊天室的心脏它继承TextWebSocketHandler重写连接建立、收到消息、连接断开三个生命周期方法。完整代码如下Component public class ChatEndpoint extends TextWebSocketHandler { private final ObjectMapper objectMapper new ObjectMapper(); private final ChatSessionManager sessionManager new ChatSessionManager(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 从握手拦截器放入的 attributes 中拿用户信息 String username (String) session.getAttributes().get(username); String userId UUID.randomUUID().toString().substring(0, 8); // 保存 Session 和用户信息 sessionManager.addSession(userId, session); // 给当前用户发送一条连接成功的消息附带自己的 userId MapString, Object welcome new HashMap(); welcome.put(type, system); welcome.put(content, 欢迎进入聊天室你的ID是 userId); welcome.put(fromUsername, 系统); welcome.put(myUserId, userId); session.sendMessage(new TextMessage(objectMapper.writeValueAsString(welcome))); // 广播系统消息xxx 进入了聊天室 broadcastSystemMessage(username 进入了聊天室); // 广播在线用户列表 broadcastOnlineUsers(); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode node objectMapper.readTree(message.getPayload()); String type node.get(type).asText(); String fromUserId node.get(fromUserId).asText(); String fromUsername node.get(fromUsername).asText(); switch (type) { case chat: String content node.get(content).asText(); // 构造广播消息 MapString, Object chatMsg new HashMap(); chatMsg.put(type, chat); chatMsg.put(fromUserId, fromUserId); chatMsg.put(fromUsername, fromUsername); chatMsg.put(content, content); chatMsg.put(timestamp, System.currentTimeMillis()); broadcastMessage(objectMapper.writeValueAsString(chatMsg)); break; case private: String toUserId node.get(toUserId).asText(); String privateContent node.get(content).asText(); MapString, Object privateMsg new HashMap(); privateMsg.put(type, private); privateMsg.put(fromUserId, fromUserId); privateMsg.put(fromUsername, fromUsername); privateMsg.put(toUserId, toUserId); privateMsg.put(content, privateContent); privateMsg.put(timestamp, System.currentTimeMillis()); String msgJson objectMapper.writeValueAsString(privateMsg); // 发送给接收方 sessionManager.sendMessageToUser(toUserId, msgJson); // 也发一份给发送方自己用于回显 sessionManager.sendMessageToUser(fromUserId, msgJson); break; default: break; } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String username (String) session.getAttributes().get(username); // 找出对应的 userId 并移除 for (Map.EntryString, WebSocketSession entry : sessionManager.getAllSessionsWithId().entrySet()) { if (entry.getValue().equals(session)) { sessionManager.removeSession(entry.getKey()); break; } } broadcastSystemMessage(username 离开了聊天室); broadcastOnlineUsers(); } private void broadcastSystemMessage(String content) throws Exception { MapString, Object sysMsg new HashMap(); sysMsg.put(type, system); sysMsg.put(content, content); sysMsg.put(fromUsername, 系统); sysMsg.put(timestamp, System.currentTimeMillis()); broadcastMessage(objectMapper.writeValueAsString(sysMsg)); } private void broadcastOnlineUsers() throws Exception { MapString, Object onlineMsg new HashMap(); onlineMsg.put(type, online); onlineMsg.put(onlineCount, sessionManager.getOnlineCount()); onlineMsg.put(users, sessionManager.getAllUsernames()); broadcastMessage(objectMapper.writeValueAsString(onlineMsg)); } private void broadcastMessage(String messageJson) throws Exception { for (WebSocketSession s : sessionManager.getAllSessions()) { if (s.isOpen()) { synchronized (s.getId().intern()) { s.sendMessage(new TextMessage(messageJson)); } } } } }这个实现里藏着几个实用细节UUID.randomUUID().toString().substring(0, 8)生成短 ID 方便展示连接关闭时通过遍历 Session 找到对应的 userId这是 Map 反向查找的常见处理synchronized (s.getId().intern())把锁的粒度控制在会话级别避免不同用户之间的发消息互相阻塞。3.2 前端页面一次完整的 WebSocket 生命周期演示前端chat.html用原生 JS 实现浏览器 WebSocket API 本身就自带onopen、onmessage、onclose、onerror四个回调正好对应连接的生命周期。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title轻量级在线聊天室/title style /* 篇幅原因省略完整样式在源码包里 */ body { font-family: Microsoft YaHei, sans-serif; margin: 0; padding: 20px; background: #f5f7fa; } .chat-container { max-width: 800px; margin: 0 auto; background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); } .header { padding: 20px; background: #4a90d9; color: #fff; border-radius: 8px 8px 0 0; } /* ... */ /style /head body div classchat-container div classheader h2轻量级在线聊天室/h2 div input idusername placeholder输入昵称 stylepadding: 8px; width: 200px; button idconnectBtn stylepadding: 8px 16px;连接/button span idonlineCount stylemargin-left: 20px;在线人数: 0/span /div /div div idmessages styleheight: 400px; overflow-y: auto; padding: 20px;/div div stylepadding: 20px; border-top: 1px solid #eee; display: flex; gap: 10px; input idmsgInput placeholder输入消息用户ID 可私聊 styleflex: 1; padding: 10px; disabled button idsendBtn stylepadding: 10px 20px; disabled发送/button /div /div script let ws null; let myUserId null; const usernameInput document.getElementById(username); const connectBtn document.getElementById(connectBtn); const msgInput document.getElementById(msgInput); const sendBtn document.getElementById(sendBtn); const messagesDiv document.getElementById(messages); const onlineCountSpan document.getElementById(onlineCount); connectBtn.addEventListener(click, () { const username usernameInput.value.trim(); if (!username) { alert(请输入昵称); return; } // 关键把用户名拼在 WebSocket URL 上 ws new WebSocket(ws://${location.host}/chat?username${encodeURIComponent(username)}); bindEvents(); }); function bindEvents() { ws.onopen () { msgInput.disabled false; sendBtn.disabled false; }; ws.onmessage (event) { const data JSON.parse(event.data); switch (data.type) { case system: if (data.myUserId) { myUserId data.myUserId; } appendMessage(【${data.fromUsername}】 ${data.content}, system); break; case chat: appendMessage(【${data.fromUsername}】 ${data.content}, normal); break; case private: appendMessage(【${data.fromUsername} 私聊你】 ${data.content}, private); break; case online: onlineCountSpan.textContent 在线人数: ${data.onlineCount}; break; } }; ws.onclose () { msgInput.disabled true; sendBtn.disabled true; }; ws.onerror (error) { console.error(WebSocket 错误, error); }; } sendBtn.addEventListener(click, sendMessage); msgInput.addEventListener(keypress, (e) { if (e.key Enter) sendMessage(); }); function sendMessage() { const content msgInput.value.trim(); if (!content) return; // 解析 用户ID 私聊指令 const privateMatch content.match(/^(\w)\s(.)$/); let payload {}; if (privateMatch) { payload { type: private, fromUserId: myUserId, fromUsername: usernameInput.value.trim(), toUserId: privateMatch[1], content: privateMatch[2] }; } else { payload { type: chat, fromUserId: myUserId, fromUsername: usernameInput.value.trim(), content: content }; } ws.send(JSON.stringify(payload)); msgInput.value ; } function appendMessage(text, type) { const div document.createElement(div); div.textContent text; if (type system) { div.style.color #888; div.style.fontSize 14px; } else if (type private) { div.style.color #e67e22; } messagesDiv.appendChild(div); messagesDiv.scrollTop messagesDiv.scrollHeight; } /script /body /html前端代码有两个实用技巧值得注意。第一个是encodeURIComponent(username)用户昵称里如果有中文、空格、特殊字符不编码会导致 URL 不合法甚至连接失败这个细节我在帮人调 bug 时经常发现他们漏掉。第二个是私聊指令的解析/^(\w)\s(.)$/这个正则匹配用户ID 消息内容的格式简单但实用用户不需要额外点击头像就能发起私聊。3.3 代码演示开两个浏览器窗口模拟多用户启动 SpringBoot 项目后浏览器访问http://localhost:8080/chat.html开两个标签页分别输入昵称“小明”和“小红”。第一个标签页连接后页面会显示“系统欢迎进入聊天室你的ID是 3f2a1b8c”。第二个标签页连接后第一个标签页立刻会收到“小明 进入了聊天室”的系统消息同时在线人数从 1 变成 2。小明输入“大家好”点击发送两个页面都会显示“【小明】 大家好”。小明输入“3f2a1b8c 小红你好”小红页面出现“【小明 私聊你】 小红你好”而小明的页面也会回显这条私聊消息。实时性基本是零延迟因为 WebSocket 的消息从服务端推送到客户端没有 HTTP 轮询那种 2-3 秒的延迟感。4. 部署配置与 Nginx 代理 WebSocket 的完整指南4.1 本地运行与 JAR 包配置开发调试时直接mvn spring-boot:run即可。生产部署建议打成可执行 JARmvn clean package -DskipTests nohup java -jar chat-room-0.0.1-SNAPSHOT.jar --server.port8080 chat.log 21 application.yml里我做了基础配置内嵌 Tomcat 的 WebSocket 缓冲区调大了一些防止传长消息时被断连server: port: 8080 spring: application: name: chat-room # 内嵌 Tomcat 对 WebSocket 的支持配置 server.tomcat.websocket: buffer-size: 8192这个buffer-size不是必须的但如果你在传图片 Base64 或稍长文本时遇到莫名其妙的连接断开可以优先检查这个值。4.2 Nginx 代理 WebSocket 的配置模板部署到服务器后不能让用户直接访问 8080 端口通常用 Nginx 做反向代理。WebSocket 和普通 HTTP 在 Nginx 配置上的核心区别在于需要显式声明Upgrade和Connection头否则 Nginx 不会把 101 切换协议的状态码正确转发给客户端。完整的 Nginx 配置反向代理配置如下map $http_upgrade $connection_upgrade { default upgrade; close; } server { listen 80; server_name chat.example.com; # HTTP 接口代理用于访问聊天室页面 location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # WebSocket 代理 location /chat { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 60s; proxy_send_timeout 60s; } }map指令的作用是把请求头里的Upgrade值映射到Connection头。如果客户端发的是Upgrade: websocket则转发Connection: Upgrade如果是普通 HTTP 请求没有 Upgrade 头就设成close。这个处理能同时兼容 HTTP 和 WebSocket 走同一个 location。proxy_read_timeout 60s是经常被忽略的一个参数。Nginx 默认的 read 超时是 60 秒如果 WebSocket 连接在 60 秒内没有任何数据流动Nginx 会主动切断连接导致前端出现 1006 非正常关闭。要么把超时时间调大比如3600s要么让前端写心跳包定期 ping 服务端我的项目里用了后者30 秒发一次心跳保证连接永远处于活跃状态。4.3 前端连接地址的兼容写法本地调试和线上部署的 WebSocket 连接地址是不一样的。本地是ws://localhost:8080/chat线上是ws://chat.example.com/chat。如果前端代码直接写死地址每次切换环境都要改代码很蠢。我推荐的做法是用location.host动态拼地址同时支持 HTTPS 下的wss://const wsProtocol location.protocol https: ? wss:// : ws://; const ws new WebSocket(${wsProtocol}${location.host}/chat?username${encodeURIComponent(username)});这样前端代码在本地、测试、生产环境通用不用改一行代码。注意如果用wss://Nginx 需要配 SSL 证书同时proxy_set_header X-Forwarded-Proto $scheme;也可以加上方便后端识别请求来源协议。5. 常见问题与排查技巧实录5.1 连接状态码 1006 与 1000 的区别WebSocket 关闭状态码里1000 是正常关闭1006 是异常关闭。我遇到过最多的就是前端报WebSocket connection to ws://... failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED或者 1006。这个问题要按下面顺序排查第一确认后端进程还在端口有没有被占用。netstat -tlnp | grep 8080看端口监听状态。第二确认请求是不是到了后端。在ChatEndpoint的afterConnectionEstablished方法第一行打日志或断点。如果后端没反应大概率是 Nginx 配置没有正确转发 Upgrade 头按上面的模板检查。第三确认握手拦截器的返回值。beforeHandshake返回false时服务端会拒绝连接前端收到403或直接 1006。我当时就犯过这个错——校验用户名时不小心把参数名拼错导致所有连接都被拦截。第四检查浏览器控制台。打开 F12点击 Network找到chat那条 WebSocket 请求看 Status Code 是不是 101。如果显示 200 但连接失败说明 Nginx 把 WebSocket 升级请求当成普通 HTTP 处理了。5.2 “发送消息没有反应”与“消息丢失”的排查消息发出去但别人看不到这个问题的根源通常在三个地方。第一前端ws.send()的 JSON 格式和后端解析字段对不上——比如前端发的是toUser后端取的是toUserId取出来就是 null。第二后端广播时抛了异常我踩过的一个具体坑是objectMapper.writeValueAsString对含有特殊字符的消息比如 emoji偶尔出错后来统一改用String.format或保证消息按 JSON 格式构造就好了。第三Session 已经关闭但还在广播列表里。sessionMap里的过期 Session 没有清理干净sendMessage时抛IOException我当时没有对单连接异常做 try-catch导致一个用户断线广播循环中断后面的人都收不到消息。所以广播循环里的每一条发送都要包 try-catch不要让单个连接的问题拖垮全局。这是我实战中印象最深的一次故障根因。5.3 SpringBoot 版本太高引发的兼容问题热搜词里出现了“springboot 版本太高”和“springboot 4 源码”这两个说法其实指向同一类问题SpringBoot 版本升级带来的 API 变动。如果你 Starter 用的是 SpringBoot 3.2 以上版本javax.websocket依赖已经移除了改成jakarta.websocket代码里的import全部要改。更坑的是SpringBoot 3 对WebSocketConfigurer的实现机制有调整某些老教程里的写法直接编译不过。我的建议非常明确聊天室这种教学型项目固定用 SpringBoot 2.7.xJava 8 就够了。Java 17 用户用 SpringBoot 3.2.x 就得同步改造代码但即便如此也建议等到你完全理解了 WebSocket 底层原理后再迁移否则报错时新旧 API 混在一起排查难度会翻倍。以下是我的核心依赖完整版parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency /dependencies5.4 断线重连机制让前端自动恢复连接网络波动、服务端重启、Nginx 超时都会导致 WebSocket 连接断开。用户刷新页面能恢复但体验很差。我在前端加了一个断线重连机制当onclose触发时如果用户之前成功连接过则每隔 3 秒尝试重新连接一次。let reconnectAttempts 0; const maxReconnectAttempts 10; ws.onclose () { msgInput.disabled true; sendBtn.disabled true; if (reconnectAttempts maxReconnectAttempts) { setTimeout(() { reconnectAttempts; ws new WebSocket(ws://${location.host}/chat?username${encodeURIComponent(usernameInput.value.trim())}); bindEvents(); }, 3000); } };这个逻辑里要控制重连次数否则服务端挂了之后前端会无限地发起无效连接。10 次重连后放弃提示用户手动刷新是比较稳妥的方案。5.5 WebSocket 鉴权别把 Token 放在 URL 里我在做这个项目时同步整理了前后端安全对接的规范。很多人直接在 URL 里拼 Tokenws://localhost:8080/chat?tokenxxx。问题在于 WebSocket URL 会出现在 Nginx 日志、浏览器历史、代理日志等位置明文 Token 很容易泄露。更安全的做法是在握手时用 Cookie 或 Authorization 头传递 Token。beforeHandshake方法里可以读请求头String token servletRequest.getServletRequest().getHeader(Authorization);但浏览器原生 WebSocket API 不能自定义 Header所以实际项目中大多用 Cookie 保存会话标识后端从手请求中读取 Cookie 来鉴权。如果你只是玩票性质URL 拼参也能凑合但如果是公司项目或毕设答辩建议把鉴权方式升级成 Cookie Session 或 JWT 方案这个点做好了能在答辩时加分。6. 扩展方向与架构升级参考6.1 点对点聊天、消息持久化与 Redis 集成当前项目把所有消息都放在内存里重启就丢。如果要做成能长期运行的产品第一个要补的是消息持久化。最简单的做法是用 Spring Data JPA 把消息存进 MySQL收到消息时先存库再广播。查询历史消息时可以提供一个 HTTP 接口用户进入聊天室时加载最近 50 条这样聊天室就有“记忆”了。如果用户量上来想要横向扩展单机内存的 Session Map 就不够了。常规做法是引入 Redis Pub/Sub 做消息广播每个应用实例把消息发布到 Redis 频道所有实例订阅同一频道并推送给各自负责的客户端。再进一步可以用 Redis 的 Hash 结构存储 Session 与用户的关系这样即使某台机器宕机也能根据在线状态把用户连接重新路由到其他实例。6.2 在线状态与心跳机制的进阶设计基础版聊天室用WebSocketSession.isOpen()判断连接是否存活但 TCP 连接在极端网络场景下会出现“假死”——客户端已经断网服务端却不知道Session 还是 open 状态。标准解法是心跳机制客户端每 30 秒发一个{type: ping}服务端收到后回一个{type: pong}如果服务端 90 秒没收到某个客户端的心跳就主动关闭该连接并清理 Session。实现心跳的代码在这个项目里也预留了位置handleTextMessage的 switch 里加一个pingcase 即可。6.3 用 Spring Security 做 WebSocket 授权如果项目要接登录系统Spring Security 对 WebSocket 有专门支持。核心思路是在握手前通过ChannelInterceptor拦截CONNECT命令判断用户是否已认证。这个方案和原生 WebSocket 的HandshakeInterceptor不同Spring Security 的拦截器走的是 STOMP 协议的CONNECT帧因此需要在配置类里启用 STOMP 端点支持。如果你不想引入 STOMP也可以直接在HandshakeInterceptor里检查 Spring Security 的SecurityContextHolder判断当前用户是否已登录逻辑更简单。我在实际项目中用的是后者对原生 WebSocket API 的侵入最小前端不用改动就能无缝接入认证逻辑。核心代码就是在beforeHandshake里加一句SecurityContextHolder.getContext().getAuthentication()判断不通过就返回false。7. 个人经验总结与源码使用建议这套轻量级聊天室跑通以后我对 WebSocket 的全链路理解比看十篇教程都深刻。框架本身不难难的是握手、Session 管理、代理配置、断线重连这些边缘细节。我把这些细节尽量都处理进了项目里比如 Session 线程安全、Nginx 的 Upgrade 头、心跳机制你拿到源码后可以逐个删掉这些功能点看看会出现什么问题这比按部就班地抄代码更能提升水平。使用源码时有三点提醒第一源码和文档在我的仓库里是配套的建议先读 README 跑起来再对照这篇博文一项项改第二注释我写得很详细但核心的ChatSessionManager和ChatEndpoint建议你手敲一遍这两个类的设计逻辑是 WebSocket 开发的通用套路第三项目自带前端页面只是演示用接你自己的项目时前端逻辑可以直接搬后端接口字段基本不用动。最后再分享一个小技巧WebSocket 开发时一定要善于利用浏览器的开发者工具。Network 面板里筛选WS类型可以实时查看每一条 WebSocket 消息的收发配合后端的日志和断点绝大多数问题半小时内能定位。遇到状态码 1006、连接被拒绝、消息不广播这类问题先看 Network 面板确认握手是否成功、消息是否发出、收发是否符合预期再决定从哪一层开始排查。养成这个习惯以后做任何长连接项目都会顺手很多。本文还有配套的精品资源点击获取