Spring Boot+WebSocket简易聊天室源码:从握手到断线重连设计
发布时间:2026/9/16 15:12:03 锦皓数字建站

简介这是一套基于Spring Boot与WebSocket开发的简易聊天室源码主要面向具备Java Web基础、希望学习实时双向通信的开发者也可用于课程设计或毕设参考。压缩包内含1093个文件整体大小约5.42MB。其中图片资源最为丰富631个PNG、369个GIF、50个JPG覆盖聊天表情、界面背景与装饰元素15个CSS和9个JS负责页面响应式布局与交互动效11个Java文件实现消息推送、会话保持、记录存储等后端核心逻辑辅助的xml、properties、readme等文件让导入配置更加清晰。目前已有436人学习。研读该源码不仅可以完整了解Spring Boot如何整合WebSocket完成即时通讯还能掌握自定义消息帧、心跳检测、简化版用户接入等实践细节前端界面中的表情面板、历史消息加载和移动端适配同样值得借鉴。不论用作个人练手还是小组作业都能获得从页面到后端的整体工程思路。1. 先分清这个聊天室项目里 Spring Boot 和 WebSocket 各干什么一个基于 Spring Boot WebSocket 的简易聊天室标题里包含“设计源码”意味着你要交付的不只是一段能跑的代码而是一套能让别人看懂“连接怎么建立、消息怎么流转、断线怎么恢复”的最小实现。Spring Boot 负责把 WebSocket 端点注册进应用生命周期、管理连接会话、处理业务消息WebSocket 负责全双工通信而“简易”两个字决定了它不需要引入消息中间件内存级广播就能满足需求。我见过不少人在这个标题上踩的第一个坑把 Spring Boot 当成聊天室业务本身把连接、心跳、重连逻辑全塞进 Controller最后代码只能在本地演示一上服务器就断线。反直觉的结论是一个能拿去面试的简易聊天室源码难点不在“发消息”而在连接生命周期、消息协议设计和断线重连这三个地方。下面按从选型到落地的顺序把一套可复制的方案讲清楚。适合正在做课程设计、写技术博客或者要应付 WebSocket 面试题的 Java 后端工程师。2. 选型与端点落地Spring Boot 集成 WebSocket 的两种路径2.1 为什么聊天室必须用 WebSocket而不是轮询和 SSEHTTP 是半双工协议要做聊天室这种双向实时通信最简单粗暴的做法是轮询客户端每隔几秒拉一次。问题很明显消息延迟随间隔变大服务器压力随用户数增长。而且轮询的 HTTP 请求头每次都在重复传输1 万个在线用户、5 秒一轮等于不停制造无效请求。SSEServer-Sent Events解决了服务端推送但它是单向的客户端发消息还得走 HTTP。聊天室里“你一言我一语”是强双向交互SSE 做出来要维护两套通道不划算。WebSocket 一次握手后直接升级为长连接客户端和服务端都能主动发数据头部开销小延迟可以做到毫秒级。这也是“为什么不用轮询和 SSE”这个 WebSocket 面试题里最高频的第一问。2.2 路径一使用 ServerEndpoint 原生端点并注册到 Spring BootSpring Boot 内嵌 Tomcat 时直接写jakarta.websocket的ServerEndpoint是最省事的做法。注意 Spring Boot 3.x 开始包名从javax.websocket换成jakarta.websocket网上大量旧文章直接粘代码会被编译期卡住。下面是一个能直接放进项目的房间版聊天室端点骨架。package com.example.chatroom.ws; import jakarta.websocket.*; import jakarta.websocket.server.PathParam; import jakarta.websocket.server.ServerEndpoint; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; Component ServerEndpoint(/chat/{roomId}) public class ChatEndpoint { private static final Logger log LoggerFactory.getLogger(ChatEndpoint.class); // roomId - 该房间所有在线 Session private static final MapString, SetSession ROOM_SESSION_MAP new ConcurrentHashMap(); OnOpen public void onOpen(Session session, PathParam(roomId) String roomId) { SetSession sessions ROOM_SESSION_MAP.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()); sessions.add(session); log.info(ws connected, sessionId{}, roomId{}, session.getId(), roomId); } OnMessage public void onMessage(String message, Session session, PathParam(roomId) String roomId) { // 完整实现里这里要解析 JSON按 type 分发到不同逻辑 broadcast(roomId, message); } OnClose public void onClose(Session session, PathParam(roomId) String roomId) { SetSession sessions ROOM_SESSION_MAP.get(roomId); if (sessions ! null) { sessions.remove(session); } log.info(ws closed, sessionId{}, roomId{}, session.getId(), roomId); } OnError public void onError(Session session, Throwable error) { log.error(ws error, sessionId{}, session.getId(), error); } private void broadcast(String roomId, String message) { SetSession sessions ROOM_SESSION_MAP.get(roomId); if (sessions null) { return; } for (Session s : sessions) { if (s.isOpen()) { s.getAsyncRemote().sendText(message); } } } }这段代码能跑起来的关键是Component让 Spring 管理ChatEndpoint再配合ServerEndpointExporter把端点注册进去。如果你没有写下面这个配置类Spring Boot 不会识别ServerEndpoint连接会一直 404。package com.example.chatroom.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; Configuration public class WebSocketConfig { Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }这里的要点有三个。第一ServerEndpoint的实例不是单例每个 WebSocket 连接都会触发一次新建所以上面用静态Map保存房间 Session 是惯用做法不要尝试把 Session 存进一个普通实例字段。第二computeIfAbsent配合ConcurrentHashMap.newKeySet()能避免并发加入时丢 Session。第三getAsyncRemote().sendText()是非阻塞发送简易聊天室够用但面向几十万消息一天的业务时要考虑发送队列和背压。2.3 路径二使用 Spring WebSocket 的 WebSocketHandler 和握手拦截器如果你不想引入 WebSocket 原生注解而是希望跟上 Spring MVC 的拦截器、安全体系整合常见做法是走 Spring WebSocket。核心是实现TextWebSocketHandler并在配置类里注册端点。package com.example.chatroom.ws; import org.springframework.stereotype.Component; import org.springframework.web.socket.*; import org.springframework.web.socket.handler.TextWebSocketHandler; Component public class ChatHandler extends TextWebSocketHandler { Override public void afterConnectionEstablished(WebSocketSession session) { // 握手成功后attributes 中是握手拦截器放进去的用户信息 Object userId session.getAttributes().get(userId); // 把 session 加到房间管理容器 } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); // 解析 JSON按类型路由到广播或私聊逻辑 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { // 正常关闭和异常关闭都会走这里status.getCode() 可以拿 1006 等状态码 } }注册端点的配置如下注意这里多了一个握手拦截器后面第 4 章会单独讲鉴权先把它挂在端点上package com.example.chatroom.config; import com.example.chatroom.ws.ChatHandler; import com.example.chatroom.ws.TokenHandshakeInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final ChatHandler chatHandler; public WebSocketConfig(ChatHandler chatHandler) { this.chatHandler chatHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) .addInterceptors(new TokenHandshakeInterceptor()) .setAllowedOrigins(*); } }WebSocketHandler是单例所以不能在ChatHandler里用实例字段存单个 Session必须另建连接管理容器。addInterceptors做的是握手阶段的前置校验setAllowedOrigins(*)表示允许所有来源简易聊天室可以这么写生产环境建议收紧成具体域名否则别人可以在他的网页里连你的端点。2.4 两条路径的取舍简易聊天室到底选哪个对比项ServerEndpoint 原生端点Spring WebSocket Handler端点注册需要 ServerEndpointExporter实现 WebSocketConfigurer鉴权位置自定义 Configurator 重写 modifyHandshakeHandshakeInterceptor和 Spring 生态贴近连接管理每个连接一个实例Session 用静态容器Handler 单例Session 自建容器消息收发getAsyncRemote().sendText()session.sendMessage(TextMessage)与 Spring Security 整合需要手动把认证信息传进去可以直接复用 SecurityContext 思路代码量少闭环短略多结构更正式我一般会这么分课程设计、源码演示、面试讲解用ServerEndpoint因为它把连接模型暴露得最直接企业内网 IM、需要和后端权限体系紧密集成的项目用WebSocketHandler。两种方式都能做广播、群组和设置用户属性区别在于你愿意为多少仪式感买单。3. 消息协议与会话管理把“发消息”变成可调试的代码3.1 上、下行 JSON 消息结构怎么设计WebSocket 传输的是文本帧聊天室源码里最容易出现的问题是直接把用户的原始字符串广播出去没有协议层。没有协议意味着私聊、系统通知、踢人这些逻辑将来全要推倒重来。简易聊天室也至少要约定一个包含 type 和 roomId 的 JSON 结构客户端上行消息长这样{ type: CHAT, roomId: room-1001, from: 张三, content: 大家好, ts: 1712345678901 }服务端向外广播时建议追加一个 msgId用于客户端去重和日志追踪{ type: CHAT, roomId: room-1001, from: 张三, content: 大家好, ts: 1712345678901, msgId: uuid-xxxxxx }type 字段用大写枚举值常见有CHAT、SYSTEM、JOIN、LEAVE、ERROR、PING、PONG。ts 用毫秒时间戳不要用yyyy-MM-dd HH:mm:ss字符串因为前端要排序、算延迟字符串比较既慢又容易踩时区坑。msgId 服务端用 UUID 生成客户端收到重复消息时直接丢弃。3.2 Session 容器用 ConcurrentHashMap 管理房间维度不管是哪条集成路径源码里都要有一个专门管理连接集合的类不要散落在 Handler 里。下面是一个最小版本的房间管理容器package com.example.chatroom.ws; import org.springframework.web.socket.WebSocketSession; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class RoomSessionManager { private final ConcurrentHashMapString, SetWebSocketSession rooms new ConcurrentHashMap(); public void join(String roomId, WebSocketSession session) { rooms.computeIfAbsent(roomId, k - ConcurrentHashMap.newKeySet()).add(session); } public void leave(String roomId, WebSocketSession session) { SetWebSocketSession sessions rooms.get(roomId); if (sessions ! null) { sessions.remove(session); } } public SetWebSocketSession onlineSessions(String roomId) { return rooms.getOrDefault(roomId, Set.of()); } public int onlineCount(String roomId) { SetWebSocketSession sessions rooms.get(roomId); return sessions null ? 0 : sessions.size(); } }这里用了ConcurrentHashMap.newKeySet()返回的是一个并发安全的 Set比手动加锁简单。computeIfAbsent保证同一个 roomId 的 Set 只初始化一次。广播时遍历返回值是个快照但 session 本身可能已经关闭所以发送前要判断isOpen()发送异常要单独捕获。3.3 广播与顺序性同步发送还是异步发送简易聊天室最常见的广播写法是同步遍历代码直观消息顺序有保证。注意这里说的顺序是指同一个服务端线程发出的顺序而不是所有客户端的全局顺序。如果要严格全局有序需要引入消息序号生成器简易场景不划算。private void broadcast(SetWebSocketSession sessions, String message) { TextMessage out new TextMessage(message); for (WebSocketSession session : sessions) { if (session.isOpen()) { try { session.sendMessage(out); } catch (Exception e) { // 单个连接失败不能影响其他用户也不能让广播线程中断 log.warn(broadcast failed, sessionId{}, session.getId(), e); } } } }参数要点是TextMessage实例可以复用sendMessage是阻塞调用一个慢客户端会拖慢整个房间的广播循环。在线人数几十人没问题几百人同时说话时就要把同步发送换成每连接一个发送队列。ServerEndpoint路径下的getAsyncRemote()本质也是异步但要注意回调里仍然要处理发送失败。3.4 源码里最常见的三个扩展点广播、群组、属性设置拿到一份聊天室源码想要体现“我不只是会抄”就从这三个位置改。第一是广播把广播从“全房间”改成“按群组”需要维护 userId - roomId 映射再在 RoomSessionManager 里加一个findCommonRooms接口。第二是群组增加群 ID 字段消息协议里带上targetGroupId广播时按群组找 Session 集合。第三是设置用户属性客户端连接时带上 nickname、avatar握手阶段放进 session attributes消息里不再重复传 from。这三个点正好对应热搜里“可以广播、群组、设置属性等”的框架诉求面试时能讲清楚任意一个都比背一段聊天室代码更能说明源码是你读过的。4. 心跳、断线重连与生产参数聊天室上线前必调的 WebSocket 参数4.1 H5 能连、打成 App 连不上先查这三处“WebSocket 运行到 H5 可以连接打包为 App 连接不了”是很典型的排查场景问题基本不在 Spring Boot 代码里。第一Android 9 开始默认禁止明文流量如果你的地址是ws://而不是wss://需要在 manifest 里打开明文流量或者换 HTTPS 传输。第二原生 App 没申请网络权限连接直接失败这属于入门级但发生率不低。第三服务端配置了来源限制浏览器请求带 OriginApp 的 WebSocket 库往往不带或者带自定义头被握手阶段拒绝表现为 WebSocket handshake 返回 401/403。4.2 onclose code 1006 与服务端主动断开前端日志里最常见的异常是[websocket] onclose, code: 1006, reason: , reconnect: true。1006 的含义是连接非正常关闭也就是 TCP 层断了但没有收到正常 Close 帧。Spring Boot 服务端重启、代理层空闲超时、客户端切网、服务器主动 kill 连接都会触发 1006。而后面的 reason 为空是正常的不是你的代码漏写了 reason。要减少 1006 导致的用户掉线第一道防线是心跳。WebSocket 协议本身没有强制心跳常见做法是客户端每 30 秒发一条 PING服务端回 PONG同时记录最后活跃时间超时连接主动关闭。客户端侧的心跳示意如下const HEARTBEAT_INTERVAL 30000; let heartbeatTimer null; function startHeartbeat(ws) { heartbeatTimer setInterval(() { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: PING })); } }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer null; } }服务端收到 PING 时不需要广播只更新活跃时间并回 PONG。需要注意OnMessage的方法会被频繁调用心跳消息要放在业务分发的最前面否则一条心跳也去走 JSON 解析和广播逻辑相当于白耗 CPU。服务端的兜底是定时扫描超时连接Scheduled(fixedDelay 10000) public void checkIdleSessions() { long now System.currentTimeMillis(); lastActiveMap.entrySet().removeIf(entry - { String sessionId entry.getKey(); if (now - entry.getValue() 90000) { WebSocketSession session sessionMap.get(sessionId); if (session ! null session.isOpen()) { try { session.close(CloseStatus.GOING_AWAY); } catch (IOException e) { log.warn(close idle session failed, sessionId{}, sessionId, e); } } return true; } return false; }); }这里的lastActiveMap可以是ConcurrentHashMapString, Longkey 是 sessionIdvalue 是最后一次活跃时间。Scheduled需要启动类加EnableScheduling。关闭时用GOING_AWAY客户端收到后能识别出这是服务端主动断开从而走重连逻辑。客户端看到 1006 时正确的处理是退避重连不要 1 秒一次疯狂重连打爆服务端。4.3 鉴权不要写在 onOpen 里握手阶段才是正确位置WebSocket 简历里最容易被追问的问题token 怎么带的答案是浏览器 WebSocket API 不能像 HTTP 那样自定义 Header所以常见方案是把 token 放在查询字符串里。ServerEndpoint的鉴权要写在 Configurator 的modifyHandshake因为这里在升级 HTTP 协议进入 WebSocket 之前执行可以设置 HTTP 状态码拒绝连接。package com.example.chatroom.ws; import jakarta.websocket.HandshakeResponse; import jakarta.websocket.server.HandshakeRequest; import jakarta.websocket.server.ServerEndpointConfig; import java.util.List; public class TokenConfigurator extends ServerEndpointConfig.Configurator { Override public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) { ListString tokens request.getParameterMap().get(token); if (tokens null || tokens.isEmpty() || !期望的token值.equals(tokens.get(0))) { response.setStatusCode(401); } } }使用方式是在ServerEndpoint注解里加上configurator TokenConfigurator.class。modifyHandshake里通过request.getParameterMap()拿到的是 URL 查询参数注意要做 null 判断否则不带 token 的连接会直接抛空指针。真正生产级做法是校验 JWT 并把 userId 放进sec.getUserProperties()在onOpen里读出来。Spring WebSocket 的拦截器原理类似beforeHandshake返回 false 则拒绝握手。很多 WebSocket 面试题考的“握手和 onOpen 的区别”本质就是要你答清楚握手阶段还能操作 HTTP 响应onOpen 已经进入 WebSocket 协议域。4.4 Nginx 反向代理里的 WebSocket 配置要点Spring Boot 聊天室上线后通常会放在 Nginx 后面如果 Nginx 没配 Upgrade 头WebSocket 握手会直接失败。下面是常见做法里最小的一组配置location /ws/chat { proxy_pass http://chat-backend; 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_http_version 1.1是必须的因为 HTTP/1.0 不支持 Upgrade。proxy_set_header Connection upgrade让 Nginx 知道这个连接要升级成 WebSocket。proxy_read_timeout是 Nginx 等待后端响应的超时时间如果小于你的业务心跳间隔后端长时间没有下发数据Nginx 会先断掉这条连接客户端就收到 1006。这里要保证 Nginx 的 read timeout 大于服务端和客户端的最大空闲周期一般 120 秒比较稳。4.5 并发相关参数与连接生命周期检查清单聊天室的连接数不等于 Tomcat 工作线程数但大量广播会瞬间占满线程。需要关注三个层次Tomcat 的最大线程数、单连接的消息大小限制、系统文件描述符上限。消息过大时服务端会以 1009 的状态关闭连接。ServerEndpoint可以这样限制单条消息最大值ServerEndpoint( value /chat/{roomId}, maxTextMessageSize 8192, maxIdleTimeout 90000 )maxTextMessageSize单位是字节超过上限会抛异常并关闭连接maxIdleTimeout是容器级别的空闲超时和上文的业务心跳不冲突可以理解为最后一道防线。简易环境这几个参数可以先按经验值设置参数推荐值说明客户端心跳间隔30s太密浪费流量太疏容易触发代理超时服务端空闲超时90s大于三个心跳周期Nginx proxy_read_timeout120s大于服务端空闲超时maxTextMessageSize8192聊天场景足够传图片走另外通道Tomcat 最大线程数200~400配合压测结果调整不要盲改如果项目里引入了 actuator记得给 /actuator 相关端点加访问控制否则线程池状态、连接指标裸奔在公网相当于把压测情报直接送给别人。5. 验证技巧用 Node 脚本模拟 20 个客户端并观察 JSON 日志5.1 最小可运行的连接压测脚本聊天室写完以后不能只开一个浏览器说“通了”。常见做法是用 Node 的 ws 库写一个多客户端脚本验证广播是否丢消息、连接是否能正常拆销。先安装依赖npm install ws然后创建 stress.jsconst WebSocket require(ws); const URL ws://localhost:8080/chat/demo-room?tokentest-token; const CLIENT_COUNT 20; const MESSAGE_COUNT_PER_CLIENT 50; let connectedCount 0; let totalSent 0; let totalReceived 0; function createClient(index) { const ws new WebSocket(URL); let sent 0; const timer setInterval(() { if (ws.readyState WebSocket.OPEN sent MESSAGE_COUNT_PER_CLIENT) { ws.send(JSON.stringify({ type: CHAT, roomId: demo-room, from: user-${index}, content: msg-${sent}, ts: Date.now() })); sent; totalSent; } }, 200); ws.on(message, () { totalReceived; }); ws.on(open, () { connectedCount; }); ws.on(close, () { clearInterval(timer); }); } for (let i 0; i CLIENT_COUNT; i) { createClient(i); } setTimeout(() { console.log({ connectedCount, totalSent, totalReceived, lossRate: ((totalSent * (CLIENT_COUNT - 1) - totalReceived) / (CLIENT_COUNT - 1) * 100).toFixed(2) % }); process.exit(0); }, 15000);脚本里的URL可以直接换成线上地址CLIENT_COUNT和MESSAGE_COUNT_PER_CLIENT决定压力规模每个客户端用独立 timer 发送避免一个慢连接阻塞其他客户端。输出的lossRate是丢包率的粗略估算每个客户端发送 50 条其他 19 个客户端都应该收到这些广播用期望收到的总数减去实际收到的总数就能看出广播是否在某些连接上失败了。5.2 服务端要打点的日志关键词跑脚本之前先把服务端日志结构调整成好搜索的格式。下面这几个关键字覆盖了大多数聊天室故障场景关注点日志关键字说明连接建立ws connected观察 connectedCount 是否线性增长连接关闭ws closed对比 connected 与 closed 数量排查泄漏异常断开ws error / 1006统计关闭码分布广播耗时broadcast-cost-ms超过 50ms 说明广播逻辑或 GC 有压力心跳超时heartbeat timeout出现频率高说明客户端断网或代理层有问题消息大小message-size-too-large命中说明协议或参数要调整广播耗时的埋点建议放在同步广播的循环外面用 System.nanoTime 计算一次广播的完整耗时。如果出现持续上升优先怀疑某个客户端的 TCP 窗口太小导致服务端写阻塞。5.3 复现和修复的闭环技巧把CLIENT_COUNT改成 500大概率能复现两个现象一是服务端日志出现大量广播异常二是客户端收到乱序或丢消息。先别急着改代码把 Tomcat 线程数和单连接发送队列打印出来确认瓶颈是线程不够还是单连接写阻塞。修复时优先做分段广播比如每 50 个 Session 一个批次批次之间不设间隔但把同步 sendMessage 换成带队列的异步发送。最后把超时关闭连接的阈值从 90 秒调到 30 秒观察客户端重连是否变平稳这个动作能快速验证心跳逻辑是否真的在工作。把这段脚本参数改写成读取环境变量并放进 CI 里的启动后检查任务就是一套不依赖浏览器的 WebSocket 健康检查。以后再遇到“偶尔掉线、重启就好”的线上问题翻一遍 connected、closed、broadcast-cost-ms 三个关键字的日志基本能定位到是代理层超时还是服务端线程被拖死。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。