尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SpringBoot+WebSocket手写轻量级聊天室:两个Java类搞定

SpringBoot+WebSocket手写轻量级聊天室:两个Java类搞定 简介这是一款面向计算机相关专业在校学生、初学者及课程设计者的轻量级在线聊天室实战项目基于SpringBoot与WebSocket构建解决传统JSPXML方案维护性差、技术栈陈旧等问题适用于毕设、课设、作业演示及全栈技能进阶学习。资源包共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. 协议与选型为什么聊天室首选WebSocket2.1 HTTP轮询、SSE和WebSocket的本质区别刚开始我把这个需求想成了普通HTTP接口前端隔几秒调一次接口拉最新消息。但仔细想会发现轮询有两个无法回避的问题。第一是延迟轮询间隔设得太短会浪费大量无意义的请求设得太长又体验不到“实时”第二是方向性HTTP请求的方向永远是客户端发起、服务端响应服务端想主动推消息给某个用户必须等这个用户下一次请求。SSEServer-Sent Events解决了一部分问题它允许服务端向客户端单向推送。但聊天场景里用户自己的发言也要发出去如果走SSE前端仍然要用HTTP POST把消息发给服务端等于同时维护两条连接逻辑更绕。WebSocket解决的是完整问题通过一次HTTP升级握手建立TCP长连接之后服务端和客户端可以随时互相发数据不再有请求-响应配对。用生活里的话说HTTP像是写信每封都要走一遍投递流程SSE像是开了一个广播电台听众只能听不能讲WebSocket像是通话接通之后双方可以直接说话。2.2 一次握手背后的关键细节客户端连接ws://host:8080/chat时实际是先发了一个HTTP请求请求头里带上了WebSocket升级标记。服务端收到后如果同意升级返回101状态码连接就变成WebSocket长连接。这个过程中有三个字段值得注意Upgrade: websocket表示请求方希望把协议升级为WebSocketSec-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 93.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两个starterdependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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, MapString, 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 MapString, 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(); MapString, 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 { MapString, 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 { MapString, 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字符串传输结构如下字段类型说明typeStringchat-聊天消息system-系统通知online-在线人数fromString发送者昵称contentString消息内容或系统提示文字timeStringHH: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 langzh head meta charsetUTF-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 idheader当前在线span idonlineCount0/span 人/div div idmessages/div div idsendBox input idcontent typetext placeholder输入消息后按回车发送 button idsendBtn发送/button /div script // 逻辑放在下面 /script /body /html4.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 渲染本文还有配套的精品资源点击获取
返回列表