
简介一款轻量级的基于SpringBoot与WebSocket的在线聊天室项目源自MccreeFei聊天室的SpringBoot升级版彻底去掉了JSP文件与XML配置的SQL语句改用Thymeleaf模板和注解方式结构更清爽、便于维护。项目代码全部测试通过、可直接运行作为作者的毕业设计答辩评审平均分达到96分适合计算机相关专业学生用作毕业设计、课程设计、初期项目演示也可作为SpringBoot入门进阶的学习样例。压缩包共115个文件约1.59MB主要包括21个Java源码、7个JavaScript脚本、4个CSS样式、配置文件、HTML页面及SQL初始化脚本并附有71张界面效果动图便于快速了解页面交互同时打包了README说明文档降低上手成本。已有173人学习下载。通过这份项目使用者可以掌握WebSocket实时通信、Thymeleaf模板渲染、SpringBoot注解式开发等关键技能同时获得一套结构完整、可直接部署运行的前后端代码。1. 轻量级SpringBootWebSocket在线聊天室不依赖第三方消息中间件的实时通信方案开发一个实时在线聊天室第一个蹦出来的方案通常是Netty或者第三方推送服务但这两条路都要付出额外的学习成本。这份基于SpringBootWebSocket的轻量级聊天室源码把问题拉回到最小集只用SpringBoot内置的WebSocket能力不引入Redis、不引入MQ把在线聊天、消息广播、心跳保活、断线重连全部跑通。整个工程结构清晰很适合毕业设计、内部工具或者作为前后端分离项目的通信模块来学习。代码量不大但该有的边界处理都在拿到手改一下端口和页面就能用。2. WebSocket在SpringBoot中的接入方式从HTTP握手到长连接保活2.1 为什么选SpringBoot内置WebSocketNIO与JSR-356的取舍先回答一个问题网上聊天室项目一抓一大把很多用了Netty为什么这份源码选择SpringBoot自带的WebSocketSpringBoot内置的WebSocket基于JSR-356规范底层由嵌入的Tomcat容器实现NIO通信。这意味着只要pom里加了依赖、写一个配置类就能直接在Controller之外获得一个长连接端点不需要额外起Netty服务也不需要考虑Netty与Spring容器之间的Bean互通问题。对几百人同时在线的聊天室场景Tomcat的NIO线程池完全撑得住真正到了需要横向扩展或几万连接的时候再迁移到Netty也不迟。从维护角度看内置方案还有一个隐蔽的好处SpringBoot的自动配置会帮你处理WebSocket的Bean注入、生命周期管理等代码里直接Autowired就能拿到业务Service。Netty里想用Spring的Service还得自己写ChannelHandler的Spring上下文桥接这个折腾过程我经历过真心不建议新手一上来就上Netty。pom.xml里加两个依赖就够了dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency这份源码用的是2.x版本依赖如果你本机创建的是3.x工程注意包名从javax.websocket变成了jakarta.websocket后面避坑章节会专门说这个。starter-websocket已经把WebSocket相关的自动配置和Tomcat支持全带进来了这就是内置方案最省心的点。2.2 注册Handler与Interceptor端点路径、allowedOrigins与握手拦截接入WebSocket的入口是实现WebSocketConfigurer接口的配置类。这里有个容易忽视的设计细节Handler负责消息收发Interceptor负责握手阶段的前置检查比如校验Token、记录来源IP。Configuration public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), /chat) .addInterceptors(new ChatHandshakeInterceptor()) .setAllowedOrigins(*); } }逻辑说明addHandler把ChatWebSocketHandler注册到/chat端点客户端连接时用ws://服务器地址:端口/chat这个URL握手addInterceptors挂上握手拦截器每次握手前都会执行setAllowedOrigins(*)表示允许任意来源跨域连接。参数说明端点路径/chat可以按业务改成/ws、/talk等但前端和后端必须一致。allowedOrigins在生产环境建议改成具体域名比如setAllowedOrigins(http://localhost:8080)不然任何第三方页面都能往你的聊天室灌消息那比XSS还麻烦。HandshakeInterceptor长这样public class ChatHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { // 从query string里取出token校验通过才放行 String token request.getURI().getQuery(); if (token ! null token.contains(token)) { attributes.put(token, token); return true; } return false; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的日志记录 } }这个拦截器的价值在于聊天室不能裸奔握手时校验身份、把用户信息塞进attributes后续在WebSocketSession里就能直接取到当前用户是谁不用每次消息都带着userId来伪造身份。attributes是握手阶段和会话建立阶段的数据通道这个机制用起来很顺手。2.3 心跳机制30秒一次的定时任务与超时窗口设计WebSocket的长连接不是永久的。中间有NAT超时、代理空闲回收、浏览器省电策略等一堆东西会把连接悄悄掐断而连接断了之后TCP层不一定能及时告诉你。这就是心跳机制存在的意义主动发一个几乎不占资源的小消息证明连接还活着。ChatWebSocketHandler里重写afterConnectionEstablished方法连接建立后立刻启动一个心跳定时任务private final ScheduledExecutorService heartBeatScheduler Executors.newSingleThreadScheduledExecutor(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 每个session一个心跳任务间隔30秒 heartBeatScheduler.scheduleAtFixedRate(() - { if (session.isOpen()) { try { session.sendMessage(new TextMessage({\type\:\ping\})); } catch (IOException e) { // 发送失败说明连接已经失效直接关闭 try { session.close(); } catch (IOException ignored) {} } } }, 30, 30, TimeUnit.SECONDS); }逻辑说明scheduleAtFixedRate表示从初始延迟30秒后开始执行之后每30秒执行一次。任务里先判断session.isOpen()避免对已关闭的session发消息发送ping消息后客户端必须在规定时间内回应pong否则服务端可以主动close。参数说明30秒的间隔是经验值。如果改成10秒心跳消息会比聊天消息还多很浪费改成60秒配合浏览器或nginx的回收机制很可能在第一个心跳发出去之前连接就被回收了。超时窗口建议设置在70~90秒给网络抖动留足余量。从聊天室线上数据看30秒心跳加75秒超时是一个很稳的组合。这里有一个网上常见误用把心跳放在前端定时器里发而不是服务端发。服务端发的好处是统一控制节奏无论是哪个客户端版本断线逻辑都由服务端兜底。前端只需要收到ping回一个pong即可。3. 聊天室核心实现消息协议、广播转发与前端对接3.1 消息协议设计type字段决定消息走向聊天室本质上是一个消息分发系统。消息从客户端A发出服务端要决定是广播给所有人还是定向推送给某个人这就需要在消息结构里加一个type字段来区分。这份源码里消息协议是JSON格式很朴素{ type: chat, from: 10001, to: all, content: hello everyone, timestamp: 1710000000000 }这里type有四个枚举值chat表示聊天消息ping/pong表示心跳online表示用户上线offline表示用户下线。from是发送者IDto是接收目标all表示广播也可以填某个用户ID实现私聊。timestamp是毫秒级时间戳前端用来排序和展示时间。为什么用JSON而不是用TextWebSocketHandler默认支持的String直接传因为String消息只能表达一段文字表达不了这是一条上线通知还是一条聊天内容。JSON结构的自描述性让服务端和前端都能按type字段分支处理后续扩展私聊、系统通知、撤回消息只需要增加type枚举不用改连接层。对应的Java消息类public class ChatMessage { private String type; // chat / ping / pong / online / offline private String from; // 发送方用户ID private String to; // 接收方all 表示广播 private String content; // 消息内容 private Long timestamp; // 消息时间戳 // getter/setter省略 }在实际项目里这个类最好用Lombok的Data注解省掉getter/setter源码里为了给同学看结构所以手动写的。字段类型上content为什么用String而不用byte[]因为聊天场景下消息体就是文本用byte[]反而要在前后端做一遍编解码毫无收益。3.2 后端消息流转从handleTextMessage到广播服务端收到消息后的核心逻辑在ChatWebSocketHandler的handleTextMessage方法里。这个方法的入参是WebSocketSession和TextMessage前者是当前的连接会话后者是客户端发来的原始文本。Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 1. 解析JSON消息 ChatMessage chatMessage JSON.parseObject(message.getPayload(), ChatMessage.class); // 2. 心跳消息直接回pong不走广播 if (ping.equals(chatMessage.getType())) { session.sendMessage(new TextMessage({\type\:\pong\})); return; } // 3. 聊天消息广播给所有在线的session if (chat.equals(chatMessage.getType())) { broadcast(message.getPayload()); } } private void broadcast(String payload) throws IOException { // 遍历所有在线session逐个转发 for (WebSocketSession s : SESSIONS.values()) { if (s.isOpen()) { s.sendMessage(new TextMessage(payload)); } } }逻辑说明第一步先把JSON字符串解析成ChatMessage对象方便后续按type分支处理。第二步是心跳回应客户端ping服务端pong这个pong消息不需要广播也不应该被当作文本消息入库。第三步是聊天消息走broadcast方法把原始payload原样转发给所有在线session。有一个细节值得提broadcast里转发的是原始payload而不是重新序列化的对象。这样做的原因是避免二次序列化带来的性能损耗和字段丢失风险反正JSON字符串本身就是文本原样转发是最快的。如果要做消息内容过滤或者敏感词替换在broadcast之前先对ChatMessage.content做处理再序列化即可。SESSIONS这个静态Map里存的是所有当前在线的WebSocketSession。这里的写操作需要注意用户重连时同一个用户ID可能产生多个session老session需要先把它关掉再替换否则会出现同一个用户的消息收到两份。用一个ConcurrentHashMap加putIfAbsent就能解决重复会话问题。3.3 前端对接连接初始化、消息分流与断线重连前端页面是纯HTML加原生JavaScript实现的不依赖Vue或React打开浏览器就能跑。核心逻辑是创建一个WebSocket对象指向后端端点然后在onmessage里按type分流处理消息。let ws null; let heartbeatTimer null; function connect() { ws new WebSocket(ws:// window.location.host /chat); ws.onopen function() { console.log(聊天室连接成功); // 连接成功后开启心跳定时器每30秒发一次ping heartbeatTimer setInterval(function() { ws.send(JSON.stringify({type: ping})); }, 30000); }; ws.onmessage function(event) { const msg JSON.parse(event.data); // 按type分流心跳回应不渲染聊天消息才显示 if (msg.type pong) return; if (msg.type chat) { appendMessage(msg.from : msg.content); } }; ws.onclose function() { // 断线后先停心跳再走重连逻辑 clearInterval(heartbeatTimer); setTimeout(connect, 3000); }; } connect();逻辑说明前端心跳放在onopen之后启动确保连接建立才发送心跳避免空转。onmessage里先判断typepong直接忽略chat才执行渲染这个分流失很多新手会忘导致心跳消息出现在聊天窗口里刷屏。onclose里清掉旧的心跳定时器然后3秒后重连重连成功后onopen会重新设定时器。注意一个边界问题这里的setInterval间隔30秒与服务端心跳节奏保持一致。如果服务端改了心跳间隔前端这里的定时器时间必须跟着改否则会出现服务端等待pong超时、前端却以为连接正常的情况。超时检测建议放在前端的onerror和onclose两个事件里做单纯靠onclose在部分浏览器断电场景下会延迟很久才触发。断线重连的时间间隔也可以做指数退避第一次3秒、第二次6秒、第三次12秒最多30秒封顶。这个方案在服务端重启的场景下特别有用否则几百个客户端同时以3秒间隔重连服务端刚起来就会被连接请求打懵。4. WebSocket实战避坑指南五个典型翻车现场与修复方法4.1 nginx代理后连接秒断先说现象本地IDE里跑项目一切正常打包部署到服务器用nginx做反向代理后WebSocket连接建立成功但是大约100秒左右就自动断开再连又断周而复始。原因nginx默认配置里没有为WebSocket转发Upgrade和Connection请求头同时nginx的proxy_read_timeout默认值很短超过限制就会主动断开连接。WebSocket握手依赖HTTP的Upgrade头nginx如果不透传这个头连接就会退化成普通HTTP请求。解决方法是给nginx加一段location配置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 upgrade; proxy_read_timeout 75s; }逻辑说明proxy_set_header Upgrade $http_upgrade和proxy_set_header Connection upgrade是WebSocket代理的关键它们把客户端发来的Upgrade请求头透传给后端Tomcat让握手能正常完成。proxy_read_timeout设置的是nginx等待后端响应数据的超时时间WebSocket隧道建立后就靠它维持长连接如果是默认的60秒而心跳是30秒一次会有概率因为时间窗口太紧被掐断。参数说明proxy_read_timeout可以按心跳间隔来配一般设为心跳间隔的2~3倍。75秒对应30秒心跳很安全设成60秒也勉强行但如果你的心跳加大到45秒这个值必须一起调否则连接还是会被nginx掐。还有一处细节如果前端是HTTPS页面WebSocket地址要写成wss://而不是ws://nginx还要配SSL证书的代理透传否则浏览器会直接拒绝连接。4.2 SpringBoot版本太高导致WebSocket直接失效有同学建项目时一激动选了SpringBoot最新版3.2.x然后从网上下载的历史代码直接导入结果启动报错ClassNotFoundException: javax.websocket.WebSocketContainer。现象很明确代码里import的是javax.websocket开头的类但SpringBoot 3.x已经迁移到Jakarta EE 9所有javax.websocket包名全部变成了jakarta.websocket。原因SpringBoot 3.0开始JavaEE更名Jakarta EE包名整体迁移。因为Tomcat 10遵循Jakarta命名空间老的javax包不再被支持。解决就是把import全部替换// 旧写法SpringBoot 2.x import javax.websocket.*; import javax.websocket.server.ServerEndpoint; // 新写法SpringBoot 3.x import jakarta.websocket.*; import jakarta.websocket.server.ServerEndpoint;如果你的代码用的是面向接口的WebSocketHandler方式而非ServerEndpoint注解方式这个问题不一定会触发但spring-boot-starter-websocket这个依赖本身在3.x版本下也要求JDK 17。所以下载任何SpringBoot项目源码后第一件事是看pom.xml里的parent版本号和你本机JDK版本是否匹配不匹配的先改parent再跑不然会出现编译过了运行报错的诡异局面。4.3 心跳消息被当成聊天记录刷屏聊天室上线后有用户截图反馈聊天窗口里时不时冒出{type:ping}这样的内容消息多的时候甚至刷屏。原因前端onmessage里没有按type分流直接把所有收到的消息文本都渲染到了聊天列表。后端的ping消息是发给客户端的但因为它是文本帧消息客户端不判断类型就展示自然就暴露出来了。解决前端必须在onmessage里先JSON.parse然后严格按照type做分支只有type等于chat才走appendMessage渲染ping和pong一律不展示。顺便加一个防呆处理JSON.parse失败的原始文本也不要渲染在控制台打警告日志。这个坑几乎每个WebSocket聊天室项目都会遇到本质上是把传输层的消息和业务层的消息混为一谈了。4.4 并发写session导致消息丢失或异常聊天室用户量上来后控制台开始间歇性报错RemoteEndpoint is not available或者TextMessage size must be less than 8MB一看代码多处都在对同一个session调用sendMessage。原因Tomcat的WebSocketSession发送方法不是线程安全。当服务端同时收到多个客户端的消息、广播方法并发执行时多个线程同时往同一个目标session写数据就会触发底层远程端点异常。解决给session加一个发送锁或者维护一个session对应的SendQueue。常见做法是用一个HashMap存每个session的ReentrantLockprivate static final MapWebSocketSession, ReentrantLock SEND_LOCKS new ConcurrentHashMap(); private void sendToSession(WebSocketSession session, String payload) throws IOException { ReentrantLock lock SEND_LOCKS.computeIfAbsent(session, s - new ReentrantLock()); lock.lock(); try { if (session.isOpen()) { session.sendMessage(new TextMessage(payload)); } } finally { lock.unlock(); } }参数说明computeIfAbsent是线程安全的Map操作每个session只会生成一个锁实例。锁的粒度只覆盖sendMessage单次会话不会影响广播的遍历逻辑所以并发吞吐量不会明显下降。这个锁方案比粗暴的synchronized上在方法上更精准不会把不同session之间的发送互相堵住。4.5 浏览器空闲几分钟自动断开有用户反馈聊天室挂在那里不动大概三分钟后再切回来点发送消息发不出去页面也收不到新消息。打开开发者工具看WebSocket的状态是CLOSED。原因WebSocket连接受浏览器标签页的Throttling策略影响当标签页处于后台或空闲状态时浏览器会降低定时器执行频率心跳消息没法按30秒的间隔发出连接被服务端的超时机制回收。解决两个手段配合使用。第一个是前端监听visibilitychange事件页面重新可见时立刻检查WebSocket的readyState如果已经是CLOSED或CLOSING就主动重连document.addEventListener(visibilitychange, function() { if (!document.hidden ws (ws.readyState WebSocket.CLOSED || ws.readyState WebSocket.CLOSING)) { connect(); } });第二个是把心跳间隔和服务端超时时间拉开差距比如心跳30秒、服务端超时60秒或者前端用window.setTimeout做递归心跳而不是setInterval因为setInterval在后台标签页会被严重节流而setTimeout在页面恢复可见后能立刻触发一次。这些细节不处理线上就会有用户聊着聊着掉线的投诉但实际上是他自己切后台太久被回收了。5. 进阶改造在线状态列表、离线消息与多房间扩展当基础聊天室跑通之后下一步通常是朝三个方向改加在线用户列表、加离线消息补发、加多房间支持。这三个功能我建议从在线状态列表入手因为它直接复用现有的SESSIONS集合改动量最小但演示效果最直观。在线用户列表广播的代码核心是定时或者事件触发时把当前SESSIONS里的userId集合序列化成JSON广播给所有在线客户端。private void broadcastOnlineUsers() throws IOException { ListString userIds SESSIONS.keySet().stream().map(s - (String) s.getAttributes().get(userId)) .collect(Collectors.toList()); String payload {\type\:\onlineList\,\users\: JSON.toJSONString(userIds) }; for (WebSocketSession s : SESSIONS.values()) { if (s.isOpen()) { s.sendMessage(new TextMessage(payload)); } } }逻辑说明这里直接遍历SESSIONS取出每个session的attributes里的userId聚合成一个JSON数组封装成onlineList类型的消息广播出去。前端收到后解析users数组渲染在侧边栏。注意这里的坑直接从SESSIONS取key再强转userId依赖握手拦截器在attributes里塞过userId否则取出来是null。所以改造前先确认ChatHandshakeInterceptor里有类似attributes.put(userId, userId)的一行没有就得先去写。离线消息补发需要引入一个简单的存储源码里不包含数据库你可以先用一个ConcurrentHashMapString, List 放在内存里用户上线时一次性查出来补发// 用户上线的事件处理方法 private void sendOfflineMessages(WebSocketSession session) { String userId (String) session.getAttributes().get(userId); ListChatMessage offlineMessages OFFLINE_MESSAGES.remove(userId); if (offlineMessages null || offlineMessages.isEmpty()) { return; } // 拼成一条批量消息发送给该用户 for (ChatMessage msg : offlineMessages) { try { session.sendMessage(new TextMessage(JSON.toJSONString(msg))); } catch (IOException e) { // 发送失败则把消息重新放回队列 } } }代码逻辑不复杂用户上线时从内存Map里取他名下的离线消息取到就逐条补发发完直接从Map移除。发送失败说明连接可能又断了把消息重新放回队列等下次上线再补。这个兜底逻辑在弱网环境很重要不然用户会重复收到同一条消息。内存存储的边界很明确服务端重启消息就丢了。如果要做持久化把List换成数据库表或者引入Redis的List结构生产上建议用Redis因为离线和在线状态本来就是Redis最擅长的场景。但作为源码级别的毕业设计内存版足够展示思路。多房间扩展是改动最大的一块把SESSIONS拆成MapString房间号, MapString, WebSocketSession广播时只遍历当前房间的session集合。消息协议里要加roomId字段用户进入房间时发送一条join事件服务端在join事件里把session挂到对应房间的集合下。这个方案也是后续做权限控制的基础比如只有白名单用户才能加入某个私密房间。我当时把这份源码改成多房间版本时发现最麻烦的不是数据结构和广播逻辑而是前端要维护房间切换状态用户从一个房间调到另一个房间时旧房间的join和leave消息必须成对出现否则在线列表会残留脏数据。从那以后我每次写WebSocket项目都会先梳理session的加入和退出路径确认每一步都有对应的清理动作再开始写业务逻辑希望这些经验能帮你少走弯路。本文还有配套的精品资源点击获取