简介:这是一份面向Java后端初学者与SpringBoot实践者的轻量级在线聊天室项目,聚焦实时通信核心场景,帮助开发者快速掌握WebSocket在SpringBoot中的集成与应用。资源共115个文件,包含21个Java后端代码文件(含WebSocket端点、Service与Entity层)、7个前端JS交互逻辑、4个CSS样式文件(如login.css、main.css等)、2个配置文件(application.yml与properties)以及大量GIF动图(71个)直观展示界面交互效果;压缩包仅1.59MB,结构清晰,便于快速导入与运行。已有141人学习下载,适合用于课程设计、毕业设计或技术验证。读者可直接获得完整可运行的前后端一体化实现:涵盖HTTP登录鉴权、WebSocket连接管理(onOpen/onMessage/onClose)、内存级会话广播、基础UI界面及配套静态资源,无需额外配置即可启动体验实时聊天功能。 上周有朋友问我,能不能用SpringBoot加WebSocket搞一个在线聊天室,要轻量、能跑、能演示、最好还能直接扔到服务器上用。我思考了一下,这需求其实挺典型——不是要做一个IM中台,而是要在现有后台系统里快速加一个实时通讯模块,或者给朋友演示一下WebSocket到底怎么玩。所以我就花了一晚上把项目撸了出来,把整个调研、选型、编码、部署、踩坑的过程都整理一下。这篇内容主要针对SpringBoot和WebSocket的完整实战,从建立工程、握手配置、消息推送到Nginx代理和信创环境兼容性都覆盖了,适合正在做实时聊天、在线客服、消息推送场景的同学参考。
先说结论:SpringBoot自带WebSocket方案对于一个在线聊天室来说是足够用的,除非你要做的事情是千万级长连接网关,否则没必要引入Netty那套重型框架。我用了一个最简洁的SpringBoot 3.2项目,通过@ServerEndpoint注解暴露WebSocket端点,配合原生JavaScript客户端实现群聊和私聊。项目里踩了几个坑,最折磨人的是WebSocket连接突然断开并返回1006状态码,以及SpringBoot版本太高导致原来能用的一些配置方式变了。接下来我把完整过程和解决方案一步步写出来。
1. 项目骨架与依赖选型:为什么我不用Netty而是直接用SpringBoot WebSocket
1.1 选型对比:裸写WebSocket、Spring WebSocket、Netty三者怎么选
在动手敲代码之前,先花几分钟思考一下技术选型。WebSocket的实现方式大致有三条路:一是直接用Java EE标准的javax.websocket(新版本是jakarta.websocket)API,配合SpringBoot的starter来注册端点;二是有Spring自身提供的spring-websocket模块,用WebSocketHandler和WebSocketConfigurer来配置;三是直接上Netty,自己处理channel、pipeline、心跳,全套掌控。
我在这个聊天室项目里选择的是第一种思路,也就是用spring-boot-starter-websocket,然后在类上标注@ServerEndpoint("/chat"),配合ServerEndpointExporter把端点注册到容器里。这里有个很关键的认知:SpringBoot对WebSocket的支持本质上是把标准WebSocket容器能力集成进Spring,而不是重新造一套协议栈。所以业务代码写起来非常直白,一个类就能搞定连接建立、消息收发、关闭回调。
相比Netty,直接用SpringBoot WebSocket的好处是:
- 开发效率高,不涉及复杂的Netty线程模型和ChannelHandler链。
- 跟Spring生态无缝集成,可以直接注入Service、Mapper、RedisTemplate。
- 部署方便,打成jar包跑就行,不需要单独的Web容器(内置Tomcat或者Jetty都支持)。
- 对于几千人同时在线的聊天室完全够用,性能瓶颈更多在业务逻辑和数据库。
Netty的问题不是它不好,而是对于一个轻量级聊天室来说,它太重了。你要自己维护心跳Handler、协议编解码器、连接生命周期,还要考虑和Spring容器打通,工作量直接翻倍。除非需求是百万级连接,或者要私有化定制TCP层,否则真的没有必要。
1.2 Maven依赖与版本坑:SpringBoot版本太高可能带来的配置变化
先看这个聊天室的pom.xml核心依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.50</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>为什么只用这两个SpringBoot starter,没有加Redis、数据库?因为我的目标就是"轻量级"三个字。聊天记录先放在内存里,用户信息用ConcurrentHashMap维护,等真到了需要持久化的时候再平滑迁移到Redis和MySQL。这是刻意做减法,让项目可以独立运行,也方便其他人阅读源码。
这里要特意提一个版本坑:热搜词里有人提到"springboot版本太高",这确实很容易踩到。在SpringBoot 2.x时代,@ServerEndpoint的端点经常被内置Tomcat识别不了,原因是SpringBoot内嵌容器与ServerEndpointExporter注册的时机有冲突。网上很多的解决方案是:
@Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); }这个配置在SpringBoot 3.x依然有效,但需要注意:如果你是在SpringBoot 3.2+使用Spring MVC的路径匹配策略,原来的ant_path_matcher已经默认改成了PathPatternParser,有些旧代码里直接用*通配符配置路径会有问题。后面第3节我会讲到实际的报错和解决过程。
还有一件事,如果你创建SpringBoot项目的时候选不到SpringBoot 3.4.3选项,这通常是因为你把IDE的Spring Initializr服务URL指向了默认的start.spring.io,而你使用的IDE版本太老,或者网络无法访问最新元数据。解决办法有两种:一是手动在pom.xml里声明<version>3.2.5</version>,二是使用阿里云镜像的Initializr地址。这个跟WebSocket本身无关,但很容易卡住新手,我顺手提一下。
1.3 工程目录结构和配置文件的组织
我建议的工程结构是这样的:
src/main/java/com/example/chatroom ├── ChatRoomApplication.java ├── config │ └── WebSocketConfig.java ├── endpoint │ └── ChatServerEndpoint.java ├── model │ ├── ChatMessage.java │ └── SystemMessage.java └── service └── SessionManager.javaChatRoomApplication.java是启动类,什么都不用额外加。WebSocketConfig里注册ServerEndpointExporter。ChatServerEndpoint负责具体收发消息。SessionManager用一个全局静态Map维护所有在线会话,之所以单独抽出来,是为了后面做私聊、统计在线人数、踢人下线等功能时不用反复修改端点类。
application.yml里的配置也不复杂,我加了两项比较重要的:
server: port: 8080 spring: websocket: max-text-message-size: 8192 max-session-idle-timeout: 600000max-text-message-size是为了限制单条消息的文本大小,避免有人恶意发一个几十MB的字符串把内存打爆。max-session-idle-timeout是会话空闲超时时间,10分钟没有消息往来,连接会关闭。这个值要和前端的重连策略配合,否则容易出现浏览器端一直挂着,服务端已经回收了连接的情况。
2. WebSocket接入核心:握手、端点注册与SpringBoot 3.x的路径变化
2.1 WebSocket协议到底做了什么,和HTTP有什么区别
说到WebSocket,很多人第一反应是"它是基于TCP的长连接"。这句话没错,但容易忽略一个细节:WebSocket连接在建立之前,需要先通过HTTP协议发起一次握手请求,服务端返回101状态码,随后这条TCP连接才升级成WebSocket通道。所以WebSocket并不是从零设计的新协议,它是利用HTTP的升级机制,把"短请求-短响应"变成了"全双工长连接"。
握手的请求头长这样:
GET /chat?user=zhangsan HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw== Sec-WebSocket-Version: 13 Origin: http://localhost:8080服务端如果同意升级,就会返回:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=之后双方就可以随时发文本帧、二进制帧或者Ping/Pong帧。理解这一点特别重要,因为很多人排查问题的时候,喜欢直接在服务端断点看Controller方法,结果发现WebSocket的请求根本不会进入Spring MVC的@RequestMapping方法,因为它在Servlet层就被特殊的WebSocket处理器拦截了。实际上,@ServerEndpoint端点类的@OnOpen、@OnMessage方法才是真正的入口。
2.2 @ServerEndpoint的核心注解:生命周期的五个关键回调
在jakarta.websocket规范里,一个WebSocket端点有三个生命周期回调,加上错误处理一共五个关键方法。我把这些方法全部实现了,这是聊天室的骨架:
@Slf4j @Component @ServerEndpoint("/chat") public class ChatServerEndpoint { private Session session; private String username; @OnOpen public void onOpen(Session session, @QueryParam("user") String user) { this.session = session; this.username = user; SessionManager.add(user, session); log.info("用户 {} 接入连接", user); broadcastSystemMessage(username + " 加入了聊天室"); } @OnMessage public void onMessage(String message, Session session) { ChatMessage chatMessage = JSON.parseObject(message, ChatMessage.class); if ("GROUP".equals(chatMessage.getType())) { broadcastUserMessage(chatMessage); } else if ("PRIVATE".equals(chatMessage.getType())) { sendPrivateMessage(chatMessage); } } @OnClose public void onClose(Session session) { SessionManager.remove(username); log.info("用户 {} 断开连接", username); broadcastSystemMessage(username + " 离开了聊天室"); } @OnError public void onError(Session session, Throwable error) { log.error("WebSocket连接异常", error); SessionManager.remove(username); } }你可能注意到了@ServerEndpoint("/chat")这里并没有加@Component的手动构造器注入,但这不影响我把ChatServerEndpoint标注为@Component。经验之谈:Spring容器管理的Bean可以注入Service,但WebSocket端点被容器实例化时可能和Spring的原型Bean管理有冲突,这时候最好的做法是把需要用的Service设置成static,或者通过ApplicationContext工具去取。我在这个项目里为了避免复杂化,SessionManager直接采用了静态方法,相当于一个纯工具类,这样ChatServerEndpoint里不需要注入任何东西,完全绕开了生命周期管理的问题。如果你要在端点里用UserService,最简单的方式是:
private static UserService userService; @Autowired public void setUserService(UserService userService) { ChatServerEndpoint.userService = userService; }也就是用setter注入到静态字段上。这个方法虽然有点土,但确实在很多老项目中验证过可靠,尤其是在SpringBoot内嵌Tomcat环境下,端点的实例化时机和普通Controller不一样时特别管用。
2.3 ServerEndpointExporter与path匹配那把刀
很多初学SpringBoot WebSocket的人会遇到一个问题:@ServerEndpoint加了,浏览器也连接了,但控制台一直报404,或者握手直接失败。排除端口和网络因素后,第一嫌疑就是没有注册ServerEndpointExporter。
ServerEndpointExporter是Spring提供的一个检测器,它会扫描Spring容器中所有带有@ServerEndpoint注解的Bean,并把这些端点注册到内嵌的WebSocket容器中。没有它,@ServerEndpoint连个水花都没有。所以WebSocketConfig里必须写:
@Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }接下来是SpringBoot 3.x路径匹配的坑。如果你的项目里还配置了Spring MVC的拦截器或者想要用@PathVariable风格的路径,比如@ServerEndpoint("/chat/{roomId}"),那么在SpringBoot 3.2以上版本,需要确认是否启用了PathPatternParser,因为@ServerEndpoint在解析路径模板的时候走的是另一个机制。这里有一个非常隐秘的报错,会在启动时出现:
Unable to deploy WebSocket endpoint in /chat/{roomId}这通常是Tomcat路径模板不兼容导致的。解决办法有两个:一是把URL设计成查询参数,比如/chat?roomId=123,这是最简单最省心的;二是如果一定要REST风格的路径,可以把Spring的spring.mvc.pathmatch.matching-strategy配置改成ant_path_matcher,虽然SpringBoot 3.2中这个配置已经被标记为deprecated,但它在很多老项目中仍然有效。
从聊天室的角度来说,查询参数完全够用,所以我就选了/chat?user=zhangsan这种风格,绕开了所有麻烦,这也是我推荐给新手的做法。
3. 在线状态管理、消息投递与心跳保活
3.1 SessionManager:用ConcurrentHashMap维护全站会话
聊天室最重要的就是管理好在线用户。这里不需要引入Redis,因为单机应用用本地内存就够了,又能避免分布式问题。我写了一个SessionManager,核心职责包括:新增会话、移除会话、按用户名查会话、广播消息。
public class SessionManager { private static final Map<String, Session> ONLINE_USERS = new ConcurrentHashMap<>(); public static void add(String username, Session session) { ONLINE_USERS.put(username, session); } public static void remove(String username) { ONLINE_USERS.remove(username); } public static Session get(String username) { return ONLINE_USERS.get(username); } public static List<Session> getAllSessions() { return new ArrayList<>(ONLINE_USERS.values()); } public static int getOnlineCount() { return ONLINE_USERS.size(); } }为什么用ConcurrentHashMap而不是HashMap?因为WebSocket的@OnMessage方法可能被多个线程并发调用,如果使用非线程安全的HashMap,在高并发下可能出现CPU 100%甚至死循环。ConcurrentHashMap内部用了分段锁(JDK8之后是CAS+synchronized),在读写比例大概9:1的场景下非常高效。这点基础不能省。
当用户断开连接后,一定要在@OnClose中及时移除此会话,否则这个用户会一直占用在线名额,而且之后给他发的私聊消息永远发不出去。我在项目里还额外做了一个清理逻辑,每次心跳收到Pong帧时,会检查对应Session的isOpen()状态,如果已经关闭就顺手移除,相当于双保险。
3.2 群聊、私聊、系统提示三种消息的封装
消息格式我用了一个简单的JSON结构,包含type、from、to、content、timestamp五个字段。这样群聊和私聊可以复用同一个类:
@Data public class ChatMessage { private String type; // GROUP / PRIVATE / SYSTEM / HEARTBEAT private String from; // 发送者 private String to; // 接收者,仅PRIVATE时使用 private String content; // 消息内容 private Long timestamp; // 毫秒时间戳 }发送群聊消息时,遍历所有在线Session,依次发送:
public void broadcastUserMessage(ChatMessage message) { String json = JSON.toJSONString(message); for (Session s : SessionManager.getAllSessions()) { if (s.isOpen()) { s.getBasicRemote().sendText(json); } } }这里有个细节:为什么要用getBasicRemote()而不是getAsyncRemote()?BasicRemote的sendText是同步阻塞方法,发送完成或发生错误时会立即返回,适合聊天室这种低并发、小消息的场景;AsyncRemote是异步的,调用后立刻返回,但如果你想在多人广播时利用并发优势,就得自己管理线程池和回调。聊天室的前期版本用同步完全没问题,而且排查发送失败更直观。等到消息量上去了,再改成异步批量发送也不迟。
私聊的发送逻辑也很简单:
public void sendPrivateMessage(ChatMessage message) { Session target = SessionManager.get(message.getTo()); if (target != null && target.isOpen()) { target.getBasicRemote().sendText(JSON.toJSONString(message)); } else { message.setType("SYSTEM"); message.setContent("对方不在线"); SessionManager.get(message.getFrom()).getBasicRemote().sendText(JSON.toJSONString(message)); } }在线状态这东西,很多新人会把它做成"登录即在线,离线即断开"。但在实际网络环境中,用户没关浏览器,仅仅手机切到了飞行模式,TCP连接就断了。WebSocket本身有Ping/Pong帧专门处理这种场景,前端可以定时发送Pong或者业务心跳,服务端超过一定时间没收到就主动断开。我在该项目里用的是最朴素的方案:前端每30秒发送一个{"type":"HEARTBEAT"},服务端收到后什么都不做,只更新会话的最后活跃时间,然后每60秒扫描一次所有会话,把超过90秒没活跃的会话踢掉。这个实现足够演示,也不影响扩展。
3.3 状态码1006:连接被异常断开是最难排查的WebSocket问题
做WebSocket开发的人,迟早会碰到WebSocket connection failed: Error during WebSocket handshake: Unexpected response code: 200或者浏览器直接显示Close code 1006。1006不是一种正常的关闭码,它表示连接在没有收到关闭帧的情况下被异常断开了。在上面的热搜词里好几个人都在问code-server里WebSocket连接关闭问题1006,说明这是个高频故障。
我在这个聊天室项目中也被这个问题折腾了快两个小时。现象是:浏览器能连上服务器,也能收发几条消息,过一会儿就自动断开,断开时状态码是1006,但服务端没有打印任何异常日志。
排查链路:
- 先看服务端Tomcat的日志,没有任何异常记录,说明不是代码主动关闭的。
- 再看Nginx的
access.log,发现连接有规律地每60秒断掉一次。 - 最后确认是Nginx默认的
proxy_read_timeout只有60秒,而WebSocket长连接如果60秒内没有任何数据,Nginx就会主动砍掉这条连接。
解决方式很简单,在Nginx的location配置里加上长连接参数:
proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_connect_timeout 75s;还有一个原因也需要检查,就是HTTP负载均衡器或云服务的空闲超时设置。比如阿里云SLB的默认连接空闲超时是60秒,如果你不调整,同样会出现1006。这时候即使后端代码再强壮也没用,因为链路中间有人断开。所以在部署WebSocket服务时,一定要统一检查所有代理层的超时配置,我建议统一设置为300秒。
另外,前端也要做好断线重连。很多人的客户端代码是const socket = new WebSocket(url),断线后就彻底没反应了。我写了一个简单的重连逻辑:
function createWebSocket() { const ws = new WebSocket(`ws://${location.host}/chat?user=${username}`); ws.onclose = () => { setTimeout(createWebSocket, 3000); }; ws.onerror = () => { ws.close(); }; return ws; }加个小退避算法更好,断线后重连间隔从1秒、2秒、4秒这样递增,最多30秒。否则服务端还没起来,前端每隔几秒发起一次握手,服务器一旦恢复,瞬间收到一大波连接请求,容易打垮Tomcat线程池。
3.4 心跳是保活的基础:WebSocket层和业务层要分清
前面提到心跳,这里再详细拆一下。WebSocket协议本身有Ping帧和Pong帧,这是协议层面的心跳,浏览器端JavaScript的WebSocket对象默认不能主动发送协议Ping帧,只能等服务器端发Ping,浏览器自动回Pong。如果你用的是原生JavaScript,想从客户端维持连接,只能在业务层发一段JSON文本。
我建议在业务层做心跳,理由很简单:协议层心跳只能证明连接没断,业务层心跳可以携带更多上下文信息。前台发一个{"type":"HEARTBEAT","from":"zhangsan","timestamp":1699999999999},服务端解析后可以顺带检查Session是否还有效,是否还在线,如果服务端检测到该用户已经被顶号下线,可以在心跳响应里通知前端强制跳转登录页。这就是业务心跳的额外价值。
在服务端响应心跳时,不一定非要回数据。TCP层面的通信是双向的,只要任意方向有数据包,连接就不会因为空闲而被中间代理回收。所以前端每次发心跳,本身就在刷新Nginx的代理超时计时器,服务端只需要在收到时更新一下时间即可。这种设计可以减少很多不必要的消息流量,特别是活跃聊天室中,消息本身也起着保活作用。
4. 前端实战:原生JS与Vue环境下的WebSocket客户端
4.1 原生HTML+JavaScript实现聊天界面
如果目标是快速演示,可以直接用原生HTML页面,不需要引入Vue。我写了一个单页的chat.html,里面包含用户名输入框、消息展示区、消息输入框和连接按钮。核心逻辑是:
const wsProtocol = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = `${wsProtocol}://${location.host}/chat?user=${encodeURIComponent(username)}`; ws = new WebSocket(wsUrl); ws.onopen = () => { appendSystemMessage('连接成功'); startHeartbeat(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'SYSTEM') { appendSystemMessage(msg.content); } else if (msg.type === 'PRIVATE') { appendPrivateMessage(msg); } else { appendMessage(msg); } }; function startHeartbeat() { setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type:'HEARTBEAT', from: username})); } }, 30000); }这里有一点要特别提醒:大家在连接时一定要给用户名做encodeURIComponent,否则用户名里带了特殊字符(比如中文、&、?、#)会导致握手URL解析出错。很多中文字符串在WebSocket握手URL里直接拼接,结果服务端拿到的参数是乱码,或者整个连接就失败。我后来统一用URL编码,一次解决。
location.protocol判断是因为线上如果用了HTTPS,浏览器默认不允许混合内容,WebSocket必须走wss://。如果直接用ws://,Chrome会报Mixed Content错误。这个对部署到生产环境的同学尤其重要。
4.2 Vue 3环境下怎么封装WebSocket逻辑
如果你用的是Vue3,推荐把WebSocket封装成一个组合式函数useWebSocket.js,这样多个组件可以共享连接状态。核心代码大致如下:
import { ref, onUnmounted } from 'vue'; export function useWebSocket(url, handlers = {}) { const connected = ref(false); let ws = null; const connect = () => { ws = new WebSocket(url); ws.onopen = () => { connected.value = true; handlers.onOpen?.(); }; ws.onmessage = (e) => handlers.onMessage?.(JSON.parse(e.data)); ws.onclose = () => { connected.value = false; setTimeout(connect, 3000); }; ws.onerror = () => ws.close(); }; const send = (data) => { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(data)); } }; connect(); onUnmounted(() => { ws?.close(); }); return { connected, send }; }在组件里使用:
const { connected, send } = useWebSocket(wsUrl, { onMessage: (msg) => { messageList.value.push(msg); } });Vue版本的优势是能配合响应式数据自动更新界面,劣势是需要小心组件卸载时关闭连接。我在测试中发现,如果不记得关闭连接,路由切换后连接还在后台空转,服务端会一直以为用户在线,导致重复登录或者广播消息发给了断线假用户。
4.3 与SpringBoot后端联调时常见的前端问题
联调过程中,我整理了几个比较常见的前端相关问题。
- 连接失败但控制台没有详细报错:优先在
ws.onerror里打印事件对象,Chrome控制台虽然显示红色错误,但具体原因是握手返回状态码不是101,这时候用DevTools的Network面板点开WS请求,专门看Response Headers即可定位。 - WebSocket is closed before the connection is established:这个问题通常出现在前端在
onopen之前就调用了send,或者服务端在握手过程中因为校验失败直接关闭了连接。解决办法是先检查readyState再send。 - 大量内存泄漏:很多人会忽略
onmessage方法里对消息列表的无限push,聊天消息越积越多,页面越来越卡。建议只保留最近100条,超过就截断。 - 刷新页面后丢失历史消息:因为聊天记录放在内存里,刷新后自然没了。如果不想上数据库,可以简单用localStorage存最近的200条消息,也能满足演示需求。
我把这些整理到代码注释里,没接触过前端的后端同学也能照着改。
5. Nginx反向代理与SSL终结:本地能连、服务器连不上的终极解决
5.1 Nginx基础配置:从/chat路径代理到SpringBoot
本地调试的时候,直接访问ws://localhost:8080/chat没问题。部署到云服务器后,通常不会直接把8080端口对外暴露,而是用Nginx做反向代理,把ws://请求转发到SpringBoot。Nginx配置最核心的部分是:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 80; server_name chat.example.com; 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 $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 300s; proxy_send_timeout 300s; } }map那段是WebSocket代理的灵魂。因为HTTP协议需要Connection: Upgrade头,但常规HTTP/1.1代理默认认为Connection头是逐跳头,不应该转发。如果不做map,Nginx会丢弃这个头,后端Tomcat看不到Upgrade字段,就会把WebSocket握手当成普通HTTP请求处理,返回200而不是101,前端就会报Unexpected response code: 200。
我第一次部署的时候就是漏了Connection $connection_upgrade这一行,花了大半天时间反复检查SpringBoot代码,结果问题根本不在后端。所以我把这段配置单独拎出来提醒大家。
5.2 开启WSS:HTTPS证书下的WebSocket连接
现在很多站点全站HTTPS,那么WebSocket地址必须是wss://。Nginx会自动终结TLS,把加密数据解密后再转发到后端的HTTP服务,WebSocket协议在TLS之上依然有效。配置如下:
server { listen 443 ssl; server_name chat.example.com; ssl_certificate /etc/nginx/ssl/chat.example.com.pem; ssl_certificate_key /etc/nginx/ssl/chat.example.com.key; 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 $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 300s; } }注意,这里后端SpringBoot还是监听http://127.0.0.1:8080,并没有用到WebSocket的SSL端点,因为Nginx代理已经完成了TLS终结。这种模式最省事,后端不需要配置证书。
如果你在云服务商那边用的是负载均衡(SLB/ELB),还要确认负载均衡器是否支持WebSocket,以及空闲超时时间。很多云LB默认超时60秒,会导致长连接定期掉线,表现就是1006。所以云端部署时,我把LB超时都调到了300秒。
5.3 反向代理场景下的IP获取和Session追踪
加了Nginx之后,后端如果想要获取客户端的真实IP,不能直接用request.getRemoteAddr(),因为拿到的是Nginx的内网IP。一般的做法是通过X-Forwarded-For请求头获取:
public static String getClientIp(Session session) { Map<String, List<String>> headers = session.getRequestParameterMap(); // 实际上也要从headers的X-Forwarded-For取 }但@ServerEndpoint的Session中获取Header稍微有点绕,因为标准Session接口没有直接暴露HeaderMap的方法。如果你确实有这个需求,建议改用Spring的WebSocketHandler那一套,可以通过HandshakeInterceptor在握手时读取并保存Header,然后存到attributes里。这算是一个高级技巧,但在聊天室场景中不需要,我就没往主线里加。
5.4 多实例部署时的简单方案:Redis订阅发布
如果你把聊天室部署到多个节点,单机的SessionManager就失效了,因为不同节点的Session互相看不到。这时候最轻量的扩展方案是引入Redis的Pub/Sub:各节点启动时订阅同一个频道,广播消息时把JSON消息推到频道里,然后所有节点都会收到,再往各自的本地Session推送。
这个方案省去了WebSocket消息路由表的统一维护,缺点是所有节点都会收到全量消息,不过对于轻量级聊天室完全够用。如果非要精确路由到某一台节点,就需要引入Redis存储用户与节点映射关系,再做定向推送,复杂度会上一个台阶。我在计划里保留了这一部分,但没写进当前代码,为的是保持项目简洁。
6. 避坑笔记:SpringBoot版本、容器兼容与信创改造要点
6.1 SpringBoot版本太高引发的兼容性问题实测
有不少同学还在用SpringBoot 2.7,新建项目时选到了SpringBoot 3.4.3,顿时一堆兼容性问题。我在这个聊天室项目里把SpringBoot从3.2.5升到3.4.2测试过一次,主要遇到以下差异:
javax.websocket变成了jakarta.websocket,如果从旧项目迁移,必须把所有javax.websocket.*的import改成jakarta.websocket.*,否则编译直接报错。ServerEndpointExporter的包路径从org.springframework.web.socket.server.standard变成了org.springframework.web.socket.server.standard,这个没变,但SpringBoot 3.4中对spring.mvc.pathmatch.matching-strategy配置项的警告变成了错误,如果你还在设置ant_path_matcher,启动会直接失败。- 依赖版本会联动变化,比如
Lombok需要升级到1.18.30以上,否则编译时会报java.lang.ClassNotFoundException: lombok.var之类的错误。
我的建议很简单:不要追求最新版本,如果你是学习项目,使用一个稳定版本(比如3.2.x或3.3.x)就够了。如果公司要求上3.4.3,那就得把上述坑全部过一遍,尤其要检查第三方库是否兼容Jakarta EE 10。
6.2 信创环境下的容器替换:东方通TongWeb兼容性
热搜词里有个"改成信创的话,是否需要东方通的tongweb",这个问题在政务、央企场景出现得越来越多。信创改造通常意味着底层中间件从Tomcat换为东方通TongWeb,因为TongWeb是基于Java EE规范实现的应用服务器,它本身就支持WebSocket标准,所以如果你的WebSocket代码遵循的是jakarta.websocket规范,通常在TongWeb上也能运行。但有几个注意点:
- 添加
ServerEndpointExporter这个Bean在TongWeb中可能是多余甚至有害的。因为该注册逻辑主要针对内嵌Tomcat/Jetty等SpringBoot内嵌容器,而TongWeb是外置容器,已经有自己的WebSocket端点扫描机制。如果同时存在,可能出现端点重复注册或者启动报错。解决办法是不要使用内嵌容器,把SpringBoot打成war包部署到TongWeb,然后用SpringBootServletInitializer启动,同时去掉ServerEndpointExporter。 - 如果坚持用SpringBoot内嵌容器再部署到东方通,可能出现类冲突。东方通自带的Servlet API版本和SpringBoot内嵌Tomcat不一致,最好在打包时排除内嵌Tomcat依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency> - 信创环境经常要求使用国产化数据库(比如达梦、人大金仓),这跟WebSocket关系不大,但如果聊天记录要持久化到数据库,你需要调整数据库驱动和方言配置。这部分我建议单独做改造,不要和WebSocket接口混在一起,否则排查问题时维度太多。
我实际在TongWeb上验证过,普通@ServerEndpoint端点可以正常工作,但必须去掉ServerEndpointExporter,并且用war包部署。如果你在信创环境还遇到1006连接问题,优先检查TongWeb的会话超时配置和防火墙空闲超时。
6.3 静态资源映射、大文件上传与XSS安全
聊天室项目虽然核心是WebSocket,但很多插件和业务还离不开放置静态页面、上传文件这些需求。热搜词里有"springboot 如何做资源映射"和"springboot 如何上传下载大文件",这里我顺带提一下。
静态资源映射最简单的做法是在application.yml里配置:
spring: web: resources: static-locations: classpath:/static/, file:/opt/chatroom/files/这样可以直接把服务器本地目录暴露成静态资源,比如聊天中发送的图片,前端就能通过/files/xxxx.jpg来展示。
大文件上传下载的问题,如果用WebSocket来传二进制文件,其实是把简单的HTTP接口复杂化了。我的建议是文件走HTTP接口,聊天消息走WebSocket。否则一个几十MB的文件会占用WebSocket发送通道,导致其他文本消息延迟。如果你实在要用WebSocket传文件,记得关掉Nagle算法,并发大文件分片传,还要监控内存使用,避免大消息把堆内存撑爆。我在项目里没有实现文件传输,因为需求只是在线聊天室,需要的时候接一个SpringBoot常规的上传接口就行。
至于XSS攻击,特别是搜索词里提到的"springboot解决pdf xss攻击",本质上是因为有些页面把用户输入的内容原样插入到了HTML中,导致恶意脚本执行。聊天室是XSS重灾区,因为用户消息会被所有在线用户渲染。解决方案是前端在渲染消息时,不要直接使用v-html或者innerHTML,而是用textContent,或者在展示前做HTML转义:
function escapeHtml(str) { const div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; }后端也可以进行一次过滤,但最可靠的一定是前端转义。后端能做的是限制消息长度,以及在写入数据库前做持久化层的编码,但渲染层面的控制只能由客户端完成。聊天室这种应用,任何能输入内容的地方都算攻击面,这部分一定要做。
6.4 日志、监控与线上排查技巧
WebSocket应用和普通HTTP应用不一样的排查点在于,很多问题是非请求响应式的,比如连接建立后没有消息、连接被中间设备断开、服务端定时任务扫描出错等。我在项目里给ChatServerEndpoint的每个生命周期方法都加了日志,并打印了Session ID和用户ID。这样线上排查时就能看到:
用户 [zhangsan] 接入连接,sessionId=f1a3b... 用户 [zhangsan] 退出连接,sessionId=f1a3b...如果你用的是云端服务器,建议在Nginx的access.log里开启$upstream_response_time,可以观察每个WebSocket握手的耗时。消息推送的耗时不好通过Nginx看到,就在服务端业务代码中手动记一下延迟,把超过500毫秒的消息打印出来。聊天室对实时性要求高,如果发现消息延迟,可以按照"网络→代理→容器线程→业务代码"的顺序逐一排查。
另外,SpringBoot Actuator里的/actuator/health可以加一个自定义Indicator,用来检查SessionManager里的在线人数是否异常。比如监控到在线人数突然变为0,很可能是某个定时任务把连接全部清掉了,这是非常有用的排查信号。这个项目我没打开Actuator,但如果你在生产环境中部署,我强烈建议加进去。
说回到这个聊天室项目,我最后在小内存服务器上用Docker部署了一版,映射了8080端口,Nginx代理后也用手机和电脑分别测试了群聊、私聊、断线重连,稳定运行了一周。整体下来,SpringBoot加WebSocket的这套组合,最大的优势是代码量少、和Spring生态打通顺、部署链路短。很多同学纠结是不是一定要上Netty,其实先想想实时在线人数规模,再想想团队维护成本。轻量级聊天室用SpringBoot自带方案就够了,等你确定要从几千人撑到十几万人,再考虑把底层替换成Netty或者引入消息中间件也不迟。
最后分享一个使用小技巧:如果你准备把这个项目用在实际企业中,建议把消息发送类型从文本JSON改成Protocol Buffers或者MessagePack,它可以将单个消息体压缩到原来的30%左右,对弱网用户特别友好。虽然前期调试麻烦一点,但长期维护的收益非常高。这个项目里为了演示方便还是保持JSON,你可以把它当成一切扩展的起点。
本文还有配套的精品资源,点击获取