简介: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 或 Netty
WebSocket 聊天室后端常见的实现有三条路: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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>我一般不会单独写握手拦截器。聊天室的鉴权放在 query 参数或者 cookie 里都行,在 WebSocket 建立前通过 HandshakeInterceptor 做一次校验,比在 handler 里收到第一条消息再判断要干净。后面第 3 章会说前端怎么把用户身份带上来。
2.2 手写 ChatWebSocketHandler:session 管理、消息分发与心跳响应
注册 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 Map<String, 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 是 userId,value 是 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 是浏览器原生 API,jQuery 不提供 WebSocket 实现,它的价值在 DOM 操作和跨浏览器兼容。所以正确分工是 JavaScript 负责连接生命周期,jQuery 负责消息渲染和表单取值,两者别混。
<script src="https://cdn.example.com/jquery-3.6.0.min.js"></script> <input type="text" name="nickname" placeholder="昵称"> <button id="loginBtn">进入聊天室</button> <ul id="msgList"></ul> <script> var ws = null; var retryDelay = 1000; function connect() { var nickname = $('input[name="nickname"]').val(); if (!nickname) return; var protocol = location.protocol === 'https:' ? 'wss://' : 'ws://'; // jQuery 里按 name 取对象用 input[name='xxx'],比 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('<li><b>' + 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 个 msgId,onmessage 里先判断重复再渲染。这比后端要求每条消息只推一次要简单得多,因为多实例部署时消息从不同节点广播,顺序和重复率本来就没有强保证。
页面关闭时要主动清理定时器和连接,否则用户关掉标签页后,浏览器虽然会断开 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 连接串加?characterEncoding=utf8,以及前端页面<meta charset="utf-8">。当初我把时间浪费在给 Nginx 加 charset_module 上,纯属瞎猜。
5.5 连接数只涨不降:afterConnectionClosed 没清理 Map
现象:服务端在线人数监控持续上涨,几天后内存接近堆上限,重启后恢复,过两周又涨回去。
原因:afterConnectionClosed 里没有把当前 session 从 SESSIONS 移除。客户端刷新页面时旧连接没关干净,新连接又进来,同一个 userId 的旧 session 还留在 Map 里。僵尸连接不发消息也不触发回调,垃圾回收收不掉 WebSocketSession 里的 Socket 对象。
解决:每一条连接关闭路径都要走到 cleanup。afterConnectionClosed 里必须 SESSIONS.remove;broadcastMessage 里如果发现 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 = f"ws://127.0.0.1:8080/ws/chat?userId={uid}" async with websockets.connect(uri) as ws: await ws.send(json.dumps({"type": "chat", "content": f"hello 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、前端心跳周期三个值摆在一起对一遍,再跑一次上面的脚本。这套顺序帮我少踩了很多坑,希望帮到你。
本文还有配套的精品资源,点击获取