简介:这是一款面向计算机相关专业在校学生、初学者及课程设计者的轻量级在线聊天室实战项目,基于SpringBoot与WebSocket构建,解决传统JSP+XML方案维护性差、技术栈陈旧等问题,适用于毕设、课设、作业演示及全栈技能进阶学习。资源包共115个文件,含21个核心Java后端逻辑类、7个前端交互JS脚本、4个CSS样式文件、2个Thymeleaf模板HTML页,以及SQL建表语句、YML配置、启动脚本等,整体仅1.59MB,结构清晰、注解完备、去除了冗余JSP与XML SQL,便于快速理解与二次开发。已有171人下载学习,项目源自高分(答辩均分96分)本科毕设,所有代码经实测运行通过,配套README文档与多组GIF操作演示(含登录、消息收发、用户状态等关键流程),可直接部署运行,亦支持在Thymeleaf+注解驱动架构基础上拓展群聊、消息持久化等功能。
1. 需求复盘:什么样的聊天室才算“轻量级”
1.1 这个项目是怎么来的
我在做订单管理系统的消息通知模块时,收到一个临时需求:运营同事需要在后台实时查看订单状态变化,并且能在同一个公共频道里互相提醒。当时第一反应是接一个开源IM方案,但翻了一圈发现,成熟方案基本都是“全家桶”,用户体系、离线推送、消息持久化、群组管理、多端同步一应俱全。对于内部工具来说,这些功能大多用不上,反而带来了部署成本和维护负担。
后来我换了个思路:既然只是“在一个网页里收发消息、看到谁在线”,完全可以用SpringBoot + WebSocket自己写一个。最终交付的聊天室项目,后端Java代码只有两个类,前端一个HTML文件,加起来不到500行,部署就是一个jar包,连数据库都不用装。这个项目后来也成了我做实时告警、工单留言、运营协同等功能时反复复用的基础组件。
1.2 轻量级到底轻在哪
很多人一听到“聊天室”,下意识就会想到网易云信、融云这类云服务,或者Openfire、Rocket.Chat这类开源服务端。我也理解这种选择,毕竟公司要做一个对外产品时,稳定性和功能完整性必须优先。但内部协同工具完全不是这个路子,它要求的是:能快速改、快速部署、快速理解。
这里说的“轻量级”并不是功能阉割,而是有明确边界:
- 不依赖独立的消息服务器,应用进程内直接处理WebSocket连接;
- 不引入消息中间件,聊天数据暂存在内存,配合定时清理;
- 不做复杂的用户体系,通过URL参数或者登录session拿到一个昵称即可;
- 不把持久化作为第一优先级,需要存历史时加一张表就行,不需要时保持无状态。
这个边界决定了后面的技术选型和代码结构。如果需求是千万级DAU的直播弹幕,那是另外一个项目,不是这篇讨论的范围。轻量级方案最怕的就是边界不清,做一会儿想加这个功能,做一会儿又担心集群扩容,最后变成四不像。
1.3 核心需求清单
整理一下这个聊天室必须满足的能力:
- 支持多用户同时在线,昵称允许重复但消息不能串;
- 支持文本消息实时广播,任何用户发送的消息所有在线用户都能看到;
- 在线人数状态实时变化,新用户进入或退出时自动通知;
- 连接异常时前端自动重新连接,不能白屏或卡死;
- 部署简单,一条命令启动,前端页面通过静态资源访问。
这些需求并不复杂,但把每一条落实到代码和配置里,都会遇到一些具体问题。下面几个章节按实现路径逐个展开。
2. 协议与选型:为什么聊天室首选WebSocket
2.1 HTTP轮询、SSE和WebSocket的本质区别
刚开始我把这个需求想成了普通HTTP接口:前端隔几秒调一次接口拉最新消息。但仔细想会发现,轮询有两个无法回避的问题。第一是延迟,轮询间隔设得太短会浪费大量无意义的请求,设得太长又体验不到“实时”;第二是方向性,HTTP请求的方向永远是客户端发起、服务端响应,服务端想主动推消息给某个用户,必须等这个用户下一次请求。
SSE(Server-Sent Events)解决了一部分问题,它允许服务端向客户端单向推送。但聊天场景里用户自己的发言也要发出去,如果走SSE,前端仍然要用HTTP POST把消息发给服务端,等于同时维护两条连接,逻辑更绕。WebSocket解决的是完整问题:通过一次HTTP升级握手建立TCP长连接,之后服务端和客户端可以随时互相发数据,不再有请求-响应配对。
用生活里的话说,HTTP像是写信,每封都要走一遍投递流程;SSE像是开了一个广播电台,听众只能听不能讲;WebSocket像是通话,接通之后双方可以直接说话。
2.2 一次握手背后的关键细节
客户端连接ws://host:8080/chat时,实际是先发了一个HTTP请求,请求头里带上了WebSocket升级标记。服务端收到后如果同意升级,返回101状态码,连接就变成WebSocket长连接。这个过程中有三个字段值得注意:
Upgrade: websocket,表示请求方希望把协议升级为WebSocket;Sec-WebSocket-Key,一段Base64随机值,服务端根据它算出响应头Sec-WebSocket-Accept返回,以此确认双方用的是同一个协议版本;Sec-WebSocket-Protocol,可选子协议,比如STOMP消息协议会在这里声明。
握手完成后,数据以“帧”为单位传输,每帧有FIN、opcode、payload len、mask等字段。作为应用开发人员,一般不需要手工解析帧,但理解这些有助于排查抓包看到的奇怪现象。比如客户端发往服务器的帧必须带mask掩码,不带mask的包会被服务端判定为非法连接并关闭。
2.3 原生WebSocket、SockJS/STOMP和Netty的取舍
SpringBoot集成WebSocket有三种常见姿势。
第一种是直接用spring-boot-starter-websocket自带的原生WebSocket支持,基于WebSocketHandler和HandshakeInterceptor。这种方式代码量最小,没有额外依赖,消息格式自己定。缺点是没有现成的“频道订阅”“消息路由”语义,广播、分组都要自己用集合维护。
第二种是SockJS + STOMP。STOMP定义了Command、Header、Body,自带subscribe、send、destination这些语法糖,语义上更接近消息队列。但它的坑也很明显:SockJS为了兼容不支持WebSocket的旧浏览器,做了大量降级传输,在现在的主流浏览器环境里已经是多余逻辑,还容易带来路径匹配的混乱。内部项目里为一个小众兼容场景引入整套STOMP,我建议慎重。
第三种是Netty + WebSocket。Netty的吞吐量和内存控制确实优于原生容器实现,但需要自己处理线程模型、handler链,代码复杂度陡然上升。几十人的内部聊天室上Netty,属于杀鸡用牛刀。
我最终选第一种。原因很简单:没有历史包袱,不需要STOMP那套消息格式,也不需要Netty级别的性能。原生方案的瓶颈在单进程内的连接数,而SpringBoot内嵌Tomcat默认支持几百到上千并发连接完全没问题,对轻量级场景足够了。
2.4 SpringBoot版本对WebSocket实现的影响
这里单独提一下版本问题,因为我见过太多人卡在这个坑上。SpringBoot 2.x和3.x的websocket配置类区别不大,主要区别在底层容器。SpringBoot 2.x默认Tomcat 9,3.x默认Tomcat 10,而Tomcat 10把javax.servlet迁移到了jakarta.servlet。如果你的项目用Spring Boot 3.x,所有跟servlet相关的依赖都要保证是jakarta命名空间,否则手写拦截器或过滤器时很容易遇到类加载报错。
另外,Spring Boot 3.x要求JDK 17及以上,如果服务器上只有JDK 8,直接用SpringBoot 2.7.x会更省事。后面示例代码以SpringBoot 2.7.x为主,版本切到3.x只需改父POM和JDK配置,业务代码基本不用动。
3. 后端实现:两个Java类撑起核心功能
3.1 工程结构和依赖配置
整个后端结构很清晰,核心文件就四个:
spring-boot-chatroom/ ├── pom.xml └── src/main/ ├── java/com/example/chatroom/ │ ├── ChatroomApplication.java │ ├── config/WebSocketConfig.java │ └── handler/ChatWebSocketHandler.java └── resources/ ├── application.yml └── static/ └── chat.htmlpom.xml里关键依赖只有web和websocket两个starter:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> </dependencies>不需要额外引入json库,spring-boot-starter-web已经自带Jackson。application.yml只配置端口和上下文路径,其他保持默认:
server: port: 8080 servlet: context-path: /3.2 配置类:把WebSocket处理器注册进Spring容器
WebSocket不是一个Servlet,但它也需要一个入口让框架知道路径、处理器、拦截器之间的关系。SpringBoot通过实现WebSocketConfigurer接口来完成注册:
@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler(), "/chat") .addInterceptors(new ChatHandshakeInterceptor()) .setAllowedOrigins("*"); } @Bean public ChatWebSocketHandler chatHandler() { return new ChatWebSocketHandler(); } }这里有两个细节得记一下。setAllowedOrigins("*")是允许跨域握手,前后端分离部署时必须有;如果同一个域部署,可以收紧为具体域名,减少被任意站点连接的风险。addInterceptors是握手的前后置钩子,一般用来从HTTP请求参数里解析身份信息,再传递给后续的WebSocket会话。
3.3 握手拦截器:从请求里取昵称
聊天室至少要有个昵称,否则用户看到的全是匿名消息。最方便的做法是前端在连接时把昵称放在URL参数里,服务端在握手阶段从request.getParameter读取并存到attributes里:
public class ChatHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; String username = servletRequest.getServletRequest().getParameter("username"); if (username == null || username.trim().isEmpty()) { username = "游客" + (int) (Math.random() * 10000); } attributes.put("username", username); } return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后没有需要额外处理的事 } }要留意的是,WebSocketSession的getAttributes()和HTTP Session不是同一个东西。attributes里放的是本次WebSocket会话的附加属性,生命周期跟随连接,不会在多个浏览器会话之间共享。所以在线用户列表不能放这里,必须用静态Map或Spring容器管理的Bean来保存。
3.4 核心处理器:连接管理、消息广播、在线人数
这是整个项目的核心类。继承TextWebSocketHandler,重写四个方法:连接建立、文本消息接收、连接关闭、传输异常。代码看起来长,逻辑其实很直接:
public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Logger log = LoggerFactory.getLogger(ChatWebSocketHandler.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>(); private static final String SYSTEM = "系统"; @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String username = (String) session.getAttributes().get("username"); SESSIONS.put(session.getId(), session); sendSysMessage(username + " 加入了聊天室", null); broadcastOnlineCount(); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String username = (String) session.getAttributes().get("username"); String content = message.getPayload(); // 约定消息是JSON,这里做最基础的解析 JsonNode root = OBJECT_MAPPER.readTree(content); String text = root.path("content").asText(""); Map<String, Object> msg = new HashMap<>(); msg.put("type", "chat"); msg.put("from", username); msg.put("content", text); msg.put("time", LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))); broadcast(OBJECT_MAPPER.writeValueAsString(msg), null); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String username = (String) session.getAttributes().get("username"); SESSIONS.remove(session.getId()); sendSysMessage(username + " 离开了聊天室", null); broadcastOnlineCount(); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 出现异常先关闭,由前端的重连逻辑恢复 if (session.isOpen()) { session.close(CloseStatus.SERVER_ERROR); } SESSIONS.remove(session.getId()); log.error("websocket transport error, sessionId={}", session.getId(), exception); } private void sendSysMessage(String content, String excludeSessionId) throws JsonProcessingException { Map<String, Object> msg = new HashMap<>(); msg.put("type", "system"); msg.put("from", SYSTEM); msg.put("content", content); msg.put("time", LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))); broadcast(OBJECT_MAPPER.writeValueAsString(msg), excludeSessionId); } private void broadcastOnlineCount() throws JsonProcessingException { Map<String, Object> msg = new HashMap<>(); msg.put("type", "online"); msg.put("count", SESSIONS.size()); broadcast(OBJECT_MAPPER.writeValueAsString(msg), null); } private void broadcast(String payload, String excludeSessionId) { TextMessage textMessage = new TextMessage(payload); SESSIONS.forEach((sessionId, session) -> { if (!session.isOpen() || sessionId.equals(excludeSessionId)) { return; } try { // 并发场景下同一个session同时写可能抛异常,加锁保证一帧发完再发下一帧 synchronized (session) { session.sendMessage(textMessage); } } catch (IOException e) { log.error("send message error, sessionId={}", sessionId, e); } }); } }几个实现要点,结合我实际走的弯路一起说:
SESSIONS用ConcurrentHashMap保存所有在线会话,key直接用session.getId()。如果换用昵称做key,两个同名用户会互相踢下线,体验很糟。
广播方法里用synchronized(session)包裹发送动作。Tomcat的WebSocket实现中,同一连接并发发送多个TextMessage可能触发IllegalStateException,这是非常典型的并发坑。锁的粒度放在单个session上,而不是整个发送循环,否则所有用户的发送都串行,性能损耗不值得。
系统消息和在线人数通知都通过同一广播通道发送,前端用type字段区分渲染样式。异常处理里把连接关闭并移除,前端随后触发onclose,执行自动重连。
3.5 消息体设计与扩展点
消息统一用JSON字符串传输,结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| type | String | chat-聊天消息,system-系统通知,online-在线人数 |
| from | String | 发送者昵称 |
| content | String | 消息内容或系统提示文字 |
| time | String | HH:mm:ss格式的发送时间 |
如果要加私聊功能,在content之外增加to字段,广播时判断目标用户。如果要加历史消息,可以在Handler里维护一个LinkedList,超过100条就移除最早的数据,前端连接成功后通过history消息类型批量拉取。这些扩展不需要改动传输协议,只是增加字段和分支判断。
3.6 在线用户列表:从会话集合反查
有人会问,在线用户列表应该是实时数据,要不要单独维护一个数据结构?其实不需要。SESSIONS这个Map本身就是在线列表,要展示用户,遍历它取出每个session的username属性即可。连接建立时也可以用username -> sessionId维护一个映射,方便按昵称查会话。轻量级场景直接用前端遍历反查就行,少一套映射就少一份数据不一致的风险。
4. 前端实现:一个HTML页面搞定收发与重连
4.1 页面结构与基础样式
前端不引入任何第三方库,直接用一个chat.html承载所有逻辑。页面分成三个区域:顶部显示在线人数,中间是消息列表,底部是输入框和发送按钮。样式简洁为主,内部工具没人关心花哨的UI:
<!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <title>在线聊天室</title> <style> body { margin: 0; padding: 16px; font-family: "Microsoft YaHei", sans-serif; } #header { margin-bottom: 8px; color: #555; } #messages { border: 1px solid #ddd; height: 400px; overflow-y: auto; padding: 8px; } .msg { margin-bottom: 6px; } .msg .time { color: #999; font-size: 12px; margin-left: 6px; } .system { color: #e67e22; font-size: 13px; } #sendBox { display: flex; margin-top: 8px; } #content { flex: 1; padding: 6px; } #sendBtn { padding: 6px 20px; margin-left: 8px; cursor: pointer; } </style> </head> <body> <div id="header">当前在线:<span id="onlineCount">0</span> 人</div> <div id="messages"></div> <div id="sendBox"> <input id="content" type="text" placeholder="输入消息后按回车发送"> <button id="sendBtn">发送</button> </div> <script> // 逻辑放在下面 </script> </body> </html>4.2 建立连接:URL参数传昵称
连接前先让用户输入昵称,然后调用new WebSocket(...)。这里大多数人会在第一版忽略一个细节:new WebSocket()是异步连接,readyState可能还是CONNECTING,此时调用send()会报错,必须等onopen事件触发后才能发送。
let ws; let nickname = prompt("请输入你的昵称", "游客" + Math.floor(Math.random() * 10000)); function connect() { const protocol = location.protocol === "https:" ? "wss://" : "ws://"; ws = new WebSocket(protocol + location.host + "/chat?username=" + encodeURIComponent(nickname)); ws.onopen = function () { setStatus("已连接"); }; ws.onmessage = function (event) { const msg = JSON.parse(event.data); renderMessage(msg); }; ws.onclose = function () { setStatus("连接断开,2秒后重连..."); setTimeout(connect, 2000); }; ws.onerror = function () { ws.close(); }; } function sendMessage() { const input = document.getElementById("content"); const text = input.value.trim(); if (text === "" || !ws || ws.readyState !== WebSocket.OPEN) { return; } ws.send(JSON.stringify({ type: "chat", content: text })); input.value = ""; } document.getElementById("sendBtn").onclick = sendMessage; document.getElementById("content").onkeydown = function (e) { if (e.key === "Enter") { sendMessage(); } }; connect();这段代码里的onerror主动调用ws.close(),目的是让状态迅速进入CLOSED,确保onclose被触发并进入重连流程。如果只依赖onclose,有些浏览器在断网场景下触发时机很晚,用户会等到不耐烦。
4.3 渲染
本文还有配套的精品资源,点击获取