WebSocket聊天室实战:Java后端与jQuery前端实时通信全解析

发布时间:2026/10/4 5:34:23
WebSocket聊天室实战:Java后端与jQuery前端实时通信全解析
简介WebSocket聊天室是一份基于JavaScript、jQuery与Java的实时通讯应用源码适合学习Java Web与前端交互的开发者。项目覆盖多人聊天、私人对话及在线客服场景通过WebSocket实现低延迟双向通信并包含用户登录验证、在线用户列表、消息定向广播等后端逻辑。压缩包共23个文件主要包含Java源文件、HTML页面、XML配置、properties配置以及编译生成的class文件整体大小仅43KB轻量易读目录结构按照src、WebContent、build等标准布局组织。目前已有116人学习下载。借助该项目可以清晰理解WebSocket协议握手与数据帧传输掌握jQuery简化DOM操作与Ajax请求的方式学习Tomcat环境下WebSocket端点的搭建、连接管理、异常重连以及可扩展架构设计。同时登录界面、聊天室主页面、私聊窗口和客服模块为完整课设实践提供了清晰范本适合作为实时通信项目开发与调试的入门参考。1. WebSocket聊天室Java 后端与 jQuery 前端搭出的实时通信最小闭环WebSocket 聊天室是很多 Java 工程师从「请求-响应」跨进「长连接」的第一个项目。它解决的问题很具体用 HTTP 轮询做即时消息要么延迟大到体验崩盘要么请求量把服务器打成黑匣子而 WebSocket 一条连接建立后服务端能主动把消息推给浏览器延迟和链路开销都降下来了。标题里的 JavaScript/JQuery/Java 正好是三层Java 负责握手与消息分发JavaScript 管连接生命周期jQuery 做消息流渲染和事件绑定。这个项目适合学完 Spring 基础、想跑通一个全栈练手场景的人也适合内部要快速搭客服、公告推送或直播弹幕服务的人。按这套方案走开发到部署一天能做完但如果你不知道心跳周期、Nginx 超时和 Session 清理几个参数的联动线上会反复断线重连。下面先把骨架立住。2. Spring Boot 实现 WebSocket 服务端从握手到消息分发要写好的两个类2.1 为什么选 Spring WebSocket 而不是 ServerEndpoint 或 NettyWebSocket 聊天室后端常见的实现有三条路Java 原生 JSR-356 的 ServerEndpoint、Spring 的 WebSocketHandler、直接上 Netty。我的建议是聊天室这种业务逻辑不复杂、但需要和 Spring 容器打交道的项目直接用 spring-boot-starter-websocket。原因有三点。第一ServerEndpoint 的类生命周期由 WebSocket 容器管理Spring 的 Autowired 在里面经常注入为 null。想用 Service 还得写一个 ApplicationContextAware 的静态辅助类去取 Bean绕一圈不说排查问题还难受。第二Netty 性能确实上限高但聊天室的瓶颈通常不在单机吞吐而在业务协议设计和连接管理上了 Netty 反而要把线程模型、编解码器、背压全学一遍一周都未必能稳定跑通。第三Spring WebSocket 天然吃到了容器红利前端握手路径、拦截器、消息 Jackson 序列化都能复用单机聊天室几千在线完全够用。引入依赖只需要在 pom 里加一行Spring Boot 会自动装配好 WebSocket 容器dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency我一般不会单独写握手拦截器。聊天室的鉴权放在 query 参数或者 cookie 里都行在 WebSocket 建立前通过 HandshakeInterceptor 做一次校验比在 handler 里收到第一条消息再判断要干净。后面第 3 章会说前端怎么把用户身份带上来。2.2 手写 ChatWebSocketHandlersession 管理、消息分发与心跳响应注册 WebSocket 路由的核心是 WebSocketConfigurer。这里有个关键点handler 不能在里面 new。很多老教程写new ChatWebSocketHandler()看着没问题但 handler 里只要注入了任何 Spring 管理的 Service运行时就 NPE。正确做法是让 handler 本身成为一个 Spring Bean构造器把依赖传进去Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatWebSocketHandler chatHandler; public WebSocketConfig(ChatWebSocketHandler chatHandler) { this.chatHandler chatHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) .setAllowedOrigins(https://chat.example.com); } }setAllowedOrigins 必须写实际部署域名不能图省事写*。当服务端要用 Cookie 做会话保持时浏览器对带凭证的 WebSocket 握手会严格执行同源策略写*反而让握手被浏览器直接拦掉。核心的聊天 Handler 继承 TextWebSocketHandler重写连接建立、文本消息、连接关闭三个方法。这是我项目里的骨架Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final MapString, WebSocketSession SESSIONS new ConcurrentHashMap(); private final MessageService messageService; private final ObjectMapper objectMapper new ObjectMapper(); public ChatWebSocketHandler(MessageService messageService) { this.messageService messageService; } Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId parseUserId(session.getUri()); // 同一用户重复登录时先踢掉旧连接避免 Map 里被覆盖后旧连接还占着资源 WebSocketSession old SESSIONS.replace(userId, session); if (old ! null old.isOpen()) { old.sendMessage(new TextMessage({\type\:\kick_old\,\content\:\账号在其他地方登录\})); old.close(CloseStatus.POLICY_VIOLATION); } broadcastMessage(MessageFactory.system(user_online, userId)); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode node objectMapper.readTree(message.getPayload()); String type node.path(type).asText(); if (heartbeat.equals(type)) { session.sendMessage(new TextMessage({\type\:\heartbeat_ack\})); return; } if (chat.equals(type)) { messageService.dispatch(node, session); } // 其他未知 type 丢弃不做默认转发 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { String userId parseUserId(session.getUri()); SESSIONS.remove(userId); broadcastMessage(MessageFactory.system(user_offline, userId)); } private void broadcastMessage(String payload) { TextMessage tm new TextMessage(payload); SESSIONS.values().forEach(s - { if (!s.isOpen()) { SESSIONS.remove(parseUserId(s.getUri())); return; } try { s.sendMessage(tm); } catch (IOException e) { SESSIONS.remove(parseUserId(s.getUri())); } }); } private String parseUserId(URI uri) { String query uri.getQuery(); if (query null || !query.contains(userId)) { return ; } return query.split(userId)[1]; } }这套代码的逻辑链是这样的所有在线连接放在一个 ConcurrentHashMap 里key 是 userIdvalue 是 WebSocketSession。文本消息进来先转成 JsonNode再按 type 分流。heartbeat 直接回 ack不落库、不广播chat 才交给 MessageService 做业务分发。广播的时候必须判断 isOpen因为客户端可能已经断网但服务端还没收到关闭事件直接 sendMessage 会抛 IOException捕获后顺手把失效连接清掉。parseUserId 这里我用的 split生产上建议用 Spring 的 UriComponentsBuilder 解析参数能避免 userId 里带特殊字符时把 query 拆坏。这个细节在线上踩过一次后面第 5 章避坑部分还会提。2.3 关于连接生命周期必须调对的三组参数WebSocket 聊天室跑在 Servlet 容器里Spring Boot 默认用的是 Tomcat有三个参数直接影响长连接能活多久。不调的话本地开发基本没事一上服务器就频繁断线。server: port: 8080 tomcat: websocket: max-text-message-size: 8192 max-session-idle-timeout: 180000max-text-message-size 默认 8192 字节一条中文消息通常是几十到几百字节纯文本聊天够用。但如果你要支持图片 base64、粘贴大段文本就要往上调我一般调到 1MB。max-session-idle-timeout 默认是 200000 毫秒左右问题不大但如果你自己有更严格的心跳策略可以按业务压到 180000 毫秒。注意单位是毫秒不是秒有同事把 180000 写成 1800000 毫秒30 分钟结果连接一直不回收。第三组参数在 Nginx很多团队用 Nginx 做反向代理但忘了给 WebSocket 开隧道。下面的 location 块是标准写法location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 120s; proxy_send_timeout 120s; }proxy_read_timeout 默认 60 秒意思是 60 秒内 Nginx 和后端之间没有任何数据传输就断开。WebSocket 连接建立后是空闲的如果没有心跳消息60 秒必断。所以心跳周期必须先于超时参数定下来一般心跳 30 秒一次Nginx 超时设 120 秒留足两倍余量。第 5 章的避坑部分会讲这三者的联动关系。3. 前端 JavaScript/jQuery 接入连接、心跳、重连一次写对3.1 最小可用的浏览器客户端原生 WebSocket jQuery 渲染前端的核心结论先说清楚WebSocket 是浏览器原生 APIjQuery 不提供 WebSocket 实现它的价值在 DOM 操作和跨浏览器兼容。所以正确分工是 JavaScript 负责连接生命周期jQuery 负责消息渲染和表单取值两者别混。script srchttps://cdn.example.com/jquery-3.6.0.min.js/script input typetext namenickname placeholder昵称 button idloginBtn进入聊天室/button ul idmsgList/ul script var ws null; var retryDelay 1000; function connect() { var nickname $(input[namenickname]).val(); if (!nickname) return; var protocol location.protocol https: ? wss:// : ws://; // jQuery 里按 name 取对象用 input[namexxx]比 id 选择器更适合表单 ws new WebSocket(protocol location.host /ws/chat?userId encodeURIComponent(nickname)); ws.onopen function () { retryDelay 1000; $(#msgList).append(li连接成功/li); }; ws.onmessage function (evt) { var msg JSON.parse(evt.data); // 先判断类型再走分支避免脏数据进来时页面报错 if (typeof msg.type ! string) return; if (msg.type chat) { $(#msgList).append(lib msg.from :/b msg.content /li); } else if (msg.type user_online || msg.type user_offline) { updateOnlineList(msg); } }; ws.onclose function () { scheduleReconnect(); }; } $(#loginBtn).on(click, connect); /script这里有两个细节值得说。第一连接地址里的 userId 必须 encodeURIComponent否则昵称里有中文或空格会把 query 参数拆断后端 parse 出来的 userId 就是截断的。第二onmessage 里先 typeof msg.type 判一下类型再走分支——这是我在 JavaScript 判断数据类型上踩出来的习惯。WebSocket 服务端发过来的 JSON 有时字段会被序列化成数字或 null比如 type 字段要是被后端写成 1、2 这种数字typeof 判断立刻能拦下来不会让后续逻辑在错误类型上继续跑。jQuery 在这个页面里只捡了最简单的活取 input 值、append 消息、绑定点击事件。这就够了不要在它上面堆业务。3.2 心跳机制实现为什么 30 秒一次超时判定放在哪WebSocket 连接建立之后如果长时间没有数据往来中间链路Nginx、防火墙、运营商 NAT会认为连接已经死了悄悄把它断掉。客户端自己不知道直到下次发消息才发现 send 失败。所以聊天室必须有心跳机制实现。常见的做法是客户端定时发一条业务心跳消息服务端回 ack客户端用「连续几次没收到 ack」来判定连接失活。var heartbeatTimer null; var missedAck 0; function startHeartbeat() { stopHeartbeat(); heartbeatTimer setInterval(function () { if (ws ws.readyState WebSocket.OPEN) { ws.send({type:heartbeat}); missedAck; if (missedAck 3) { ws.close(); // 触发 onclose走重连流程 } } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } } // 在 onmessage 的分支里加一段 if (msg.type heartbeat_ack) { missedAck 0; return; }心跳间隔选 30 秒是通行的工程取值。太短比如 5 秒消息量虽然小但会在服务端刷出大量无意义的 wakeup太长比如 5 分钟中间链路很容易在间隔里掐断连接Nginx 的 60 秒默认超时就卡在那里。30 秒加 3 次重试的容错Nginx 超时设 120 秒正好形成两倍以上的余量。missedAck 连续 3 次没收到 ack 就主动 ws.close()比等服务端踢要快用户感知到的是快速重连而不是长时间卡死。注意心跳消息不要和业务消息混在一个地方处理。后端在 handleTextMessage 里先判断 type属于 heartbeat 的分支直接 return不能走消息入库或广播逻辑。WebSocket 协议层本身也有 ping/pong 帧但浏览器原生 API 不让 JavaScript 直接发协议控制帧所以业务层 JSON 心跳是唯一的选择。服务端可以不用额外起线程做心跳检测靠 Tomcat 的 max-session-idle-timeout 兜底就行客户端主动断开后 afterConnectionClosed 会触发清理。3.3 断线重连指数退避和重登的幂等问题ws 的 onclose 触发后不能立刻重连。如果服务器正在重启或者网络在抖动客户端会像毛刺一样反复撞上来把服务端连接数瞬间打满。常见做法是指数退避第一次等 1 秒第二次 2 秒第三次 4 秒封顶 30 秒。function scheduleReconnect() { setTimeout(function () { retryDelay Math.min(retryDelay * 2, 30000); connect(); }, retryDelay); }connect 的 onopen 里把 retryDelay 重置回 1000这样网络恢复后第一次重连就成功后续不需要再等待。重连还有一个幂等坑用户断线期间发的消息和重连后的系统通知可能会重复。我一般会给聊天消息加一个 msgId前端用 Set 存最近收到的 100 个 msgIdonmessage 里先判断重复再渲染。这比后端要求每条消息只推一次要简单得多因为多实例部署时消息从不同节点广播顺序和重复率本来就没有强保证。页面关闭时要主动清理定时器和连接否则用户关掉标签页后浏览器虽然会断开 socket但心跳定时器可能还在跑window.addEventListener(beforeunload, function () { stopHeartbeat(); if (ws) { ws.close(1000, page closed); } });这段代码能显著减少服务端 afterConnectionClosed 延迟触发导致的 Map 残留。第 5 章会说残留连接怎么排查。4. 聊天室业务落地消息协议、上下线通知与在线列表处理4.1 消息协议设计五个字段如何撑起聊天、人与系统通知把 WebSocket 当成一个双向的字节管道它本身不关心业务。聊天室的数据结构化全靠 JSON 协议。消息类型我用字符串 type 而不是数字枚举调试的时候看日志一眼能懂不用去翻协议文档。整套协议只需要五类type方向用途chat双向用户聊天消息system服务端→客户端上下线通知、踢人提示heartbeat客户端→服务端保活探测heartbeat_ack服务端→客户端心跳确认kick_old服务端→客户端同账号重复登录时踢旧连接一条聊天消息的完整 JSON 长这样{ type: chat, from: u_10086, to: all, content: 你们现在用的什么消息队列, msgId: 1700123456789-10086, timestamp: 1700123456789 }from 和 to 都存用户 ID不存昵称。原因有几个昵称是可变数据用户改昵称后历史消息里的昵称不该跟着变昵称里可能带着引号、HTML 标签直接拼进 JSON 容易出 XSS 或解析错误。前端展示时通过一个 Map 把 userId 映射成昵称收不到映射时先显示 ID再异步拉取用户信息。timestamp 用毫秒时间戳而不是格式化字符串前端new Date(Number(msg.timestamp))渲染时注意 Number 转换。很多人在 JavaScript 里拿字符串当数字用时间差算出来是 NaN。这里又是那个老问题判断数据类型要先于业务逻辑。用 typeof 先确认 timestamp 是 number再做格式化。消息长度要和服务端的 max-text-message-size 对齐。我自定义协议时会在后端做一层校验content 超过 5000 字直接拒绝避免客户端拼出一个超长 JSON 把容器参数打爆。这个校验写在 MessageService.dispatch 里不写在 WebSocketHandler 里保证 handler 只做连接管理业务规则全部下沉。4.2 上下线通知与在线列表用广播代替轮询在线用户列表是聊天室最常见的附属功能。新手容易做成前端每 5 秒拉一次/online/list这等于把 WebSocket 的实时优势又丢回轮询。正确做法是连接建立后服务端广播一条 user_online 系统消息连接关闭时广播 user_offline前端自己维护一个 Map。var onlineUsers new Map(); function updateOnlineList(msg) { var uid msg.from; if (msg.type user_online) { onlineUsers.set(uid, uid); } else if (msg.type user_offline) { onlineUsers.delete(uid); } // 如果消息体里带了 nickname 字段先用它更新 if (msg.content typeof msg.content.nickname string) { onlineUsers.set(uid, msg.content.nickname); } renderOnlineList(); } function renderOnlineList() { var html ; onlineUsers.forEach(function (nickname, uid) { html li>String raw new String(message.getPayload().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);这行代码不解决任何问题只是用来确认字节流没有被错误解码。真正改的是 MySQL 连接串加?characterEncodingutf8以及前端页面meta charsetutf-8。当初我把时间浪费在给 Nginx 加 charset_module 上纯属瞎猜。5.5 连接数只涨不降afterConnectionClosed 没清理 Map现象服务端在线人数监控持续上涨几天后内存接近堆上限重启后恢复过两周又涨回去。原因afterConnectionClosed 里没有把当前 session 从 SESSIONS 移除。客户端刷新页面时旧连接没关干净新连接又进来同一个 userId 的旧 session 还留在 Map 里。僵尸连接不发消息也不触发回调垃圾回收收不掉 WebSocketSession 里的 Socket 对象。解决每一条连接关闭路径都要走到 cleanup。afterConnectionClosed 里必须 SESSIONS.removebroadcastMessage 里如果发现 session.isOpen() 为 false也应顺手 remove。更稳妥的是连接建立时处理同账号覆盖把旧连接主动 close 掉。代码在 2.2 节的 afterConnectionEstablished 里已经写了用 SESSIONS.replace 拿到旧 session发一条 kick_old 再关闭。这样同一账号多端登录也不会无限增长。6. 验证与压测用 500 个并发连接证明你的聊天室能扛住线上压力聊天室写完后不能只打开两个浏览器窗口互发消息就宣布完成。你需要模拟至少几百个客户端同时建连、同时发消息、同时收广播才能看出连接管理和线程模型的问题。我常用的验证脚本是 Python 的 websockets 库几百行代码就能压出效果# stress_ws.py 验证 500 个并发连接的建立与消息收发 import asyncio import json import websockets async def one_client(uid): uri fws://127.0.0.1:8080/ws/chat?userId{uid} async with websockets.connect(uri) as ws: await ws.send(json.dumps({type: chat, content: fhello from {uid}})) async for message in ws: data json.loads(message) if data.get(type) chat: return True async def main(): sem asyncio.Semaphore(100) # 同时在建连的客户端数别把客户机自己打垮 async def limited(uid): async with sem: try: await one_client(uid) except Exception as e: print(f{uid} failed: {e}) await asyncio.gather(*[limited(i) for i in range(500)]) if __name__ __main__: asyncio.run(main())脚本的核心是限流。500 个客户端如果同时发起建连客户机自身的文件描述符可能先耗尽。Semaphore(100) 把并发建连窗口控制在 100避免压测工具自己成为瓶颈。跑之前先看系统限制ulimit -n如果输出是 1024把文件描述符上限调到 65535 再跑。不然 500 个连接直接失败在操作系统层跟服务端一点关系没有。跑完脚本观察两个指标连接建立成功率以及服务端是否出现异常关闭日志。再用 watch 命令盯一下 TCP 连接数watch -n 1 ss -s | grep -i tcp我最早做这类项目时注意力全放在消息样式和动画上上线一个月后进程被僵尸连接拖到频繁 GC才发现心跳、超时、Session 清理才是聊天室的骨架。现在每换一个部署环境我都会先把 Nginx 超时、Tomcat idle、前端心跳周期三个值摆在一起对一遍再跑一次上面的脚本。这套顺序帮我少踩了很多坑希望帮到你。本文还有配套的精品资源点击获取