简介:面向有 Java 与 Spring 基础的开发者,这套 SpringBoot+WebSocket 示例工程以精简可运行的结构演示连接建立、消息收发与广播链路,可作为聊天室、实时通知、在线协作等场景的入门模板。压缩包共 106 个文件、约 169KB,其中 82 个 xml 多为 Maven 与构建配置,5 个 java 源文件及其编译后的 class 承载核心处理逻辑,4 个 html 页面与 2 个 js 脚本提供浏览器端调试入口,另有 properties 配置、mvnw 启动脚本与 jar 依赖,解压后可直接导入 IDE 运行。目前已有 412 人学习下载,适合边跑边读。工程通过配置类注册端点、自定义处理器维护会话集合完成消息广播,配合前端页面可直观观察推送效果,读者能据此理解握手流程、会话生命周期与前后端联调的细节,并在此基础上扩展鉴权、编解码与异常处理。
1. 从 Controller 里塞不进实时消息说起:SpringBoot WebSocket Demo 环境清单
有个场景我见过太多次:后端接口都写完了,前端要的是服务端主动推消息,结果在 Controller 里加了个轮询接口先顶着,QPS 一上来数据库先扛不住。这时候才想起来上 WebSocket,翻半天教程,pom 里加了个 starter,配置类抄了一段,启动不报错,连上就断,日志里一片 404 和 403。SpringBoot WebSocket Demo 这类工程看着简单,真正装起来卡人的从来不是代码量,而是版本对齐、依赖范围、握手路径和跨域这几处细节。
这套环境适合三类人:正在给后台管理系统加消息通知的开发者、要做在线协作或实时日志面板的后端、以及第一次接触长连接、想先把一个最小可跑 Demo 装起来再改业务的人。下面按我实际搭环境顺序写:先列清 JDK、Maven、SpringBoot 版本,再装依赖写配置,最后落到怎么验证握手成功、断了怎么排查。
2. 环境准备与版本选择:JDK、Maven、SpringBoot 三件套怎么对齐
动手之前先把版本钉死,这是整个安装过程里最省事的一步。SpringBoot WebSocket Demo 的坑,八成源于版本没对齐:SpringBoot 2.x 和 3.x 在 Jakarta 包名上完全分家,WebSocket 的配置类写法不变,但引入的注解包路径变了,抄错一段就是启动即失败。
2.1 版本组合与最低要求
我一般按下面这套组合起步,稳定、资料多、出问题好搜:
| 组件 | 推荐版本 | 最低可用 | 说明 |
|---|---|---|---|
| JDK | 17 | 8(配 SpringBoot 2.7) | SpringBoot 3.x 强制 17 起 |
| Maven | 3.8.x | 3.6.x | 3.9 对部分老插件兼容性一般 |
| SpringBoot | 3.2.x | 3.0.x | 2.7.x 是 2 系最后稳定版 |
| spring-boot-starter-websocket | 随 Boot 版本 | 随 Boot 版本 | 不要单独指定版本号 |
| 前端测试工具 | 浏览器控制台 | 同左 | 先用原生 WebSocket 验证 |
选 3.2.x 的理由很实际:它属于 3.x 里文档覆盖比较全的一档,社区问答多,真翻车了能找到人踩过的痕迹。如果团队还在 2.7,那就统一锁 2.7.18,别一半 2.x 一半 3.x 混着来,javax.websocket和jakarta.websocket混用是会直接编译不过的。
JDK 这块有个玄学现象:机器上装了多个 JDK,java -version显示 17,Maven 编译却用 8,最后报invalid target release。原因通常是JAVA_HOME指的还是老版本,PATH 里java是新的。先统一再往下走。
2.2 安装 JDK 与 Maven 并验证
以 JDK 17 为例,装完必须验证三件事:java、javac、JAVA_HOME指向同一个目录。
# 查看当前生效的 JDK 版本 java -version javac -version # 确认 JAVA_HOME 指向的位置(Linux/macOS) echo $JAVA_HOME # Windows 用这条 # echo %JAVA_HOME% # 确认 Maven 用的是哪个 JDK mvn -vmvn -v的输出里有一行Java version,这行才是 Maven 编译时真正用的版本,很多人只看java -version就以为对齐了。如果两处不一致,去改JAVA_HOME,别去改 Maven 的mvn.cmd,那是给自己埋雷。
Maven 装完还要看一眼settings.xml里的镜像地址和本地仓库路径。国内环境不配镜像,第一次拉 SpringBoot 依赖能等到怀疑人生。常见做法是在~/.m2/settings.xml的<mirrors>里加一个国内镜像,本地仓库路径也顺手改到空间大的盘,避免 C 盘被.m2撑爆。
<!-- ~/.m2/settings.xml 片段:只保留关键项 --> <settings> <localRepository>D:/maven-repo</localRepository> <mirrors> <mirror> <id>aliyun</id> <!-- 国内镜像,拉依赖快很多 --> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/public</url> </mirror> </mirrors> </settings>localRepository换成自己机器上空间充足的位置;mirrorOf写central表示只接管中央仓库请求,别写成*,否则公司私服也会被劫持,依赖直接拉不到。改完执行mvn help:effective-settings能确认配置真生效了,比猜强。
3. 创建工程与引入 WebSocket 依赖:pom 里那三行别写错
工程骨架建议直接用 Spring Initializr 生成,勾 Web 和 WebSocket 两个依赖,省得手写目录结构。手动建也行,但pom.xml里的 parent、starter 版本、打包插件三处最容易出岔子。
3.1 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-web</artifactId> </dependency> <!-- WebSocket 核心依赖,版本由 parent 托管 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> </dependencies>spring-boot-starter-websocket里已经带了spring-websocket和spring-messaging,不用再手动引。很多人会额外加一个javax.websocket-api的显式依赖,在 Boot 3.x 下这是错的,会引入老包名导致类加载冲突。
提示:依赖版本交给 parent 托管,不要自己写
<version>,版本对不上时症状往往是启动时报NoSuchMethodError,比编译错误难查得多。
打包插件这块,spring-boot-maven-plugin不加的话mvn package出来的 jar 没有主清单,java -jar直接报「no main manifest attribute」。Initializr 生成的工程默认带,手写 pom 时容易漏。
3.2 目录结构与最小启动类
工程跑起来只需要四个文件:启动类、WebSocket 配置类、一个处理器、一个前端测试页。
// 启动类:位置必须在其他组件所在包的父级,否则扫描不到 @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }@SpringBootApplication隐含了组件扫描,默认扫当前包及子包。有人把启动类放到com.example.demo.config下面,然后抱怨处理器没注册上,这不是框架的问题。目录按controller / config / handler三层分就够用,Demo 阶段不用过度设计。
依赖拉完后先跑一次mvn clean compile,确认能编过再往下写配置。这一步能提前暴露版本冲突,比写完一堆代码再发现要省时间。
4. 配置 WebSocket 服务端:注册端点、跨域与消息处理器
这是整个安装流程里最容易翻车的一段。SpringBoot 原生 WebSocket 有两种风格:一种是@ServerEndpoint注解式,走的是 JSR-356 规范;另一种是 Spring 的WebSocketHandler风格。我建议 Demo 用后者,配置和调试都更可控,也更容易和 Spring 的依赖注入配合。
4.1 注册端点与放行握手路径
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new EchoHandler(), "/ws/echo") // 开发阶段放开跨域,生产环境务必改成具体域名 .setAllowedOriginPatterns("*"); } }addHandler的第二个参数就是握手路径,前端new WebSocket("ws://localhost:8080/ws/echo")必须和它完全一致,多一个斜杠都会 404。setAllowedOriginPatterns是 Boot 2.4 之后推荐的写法,老教程里的setAllowedOrigins("*")在新版本里行为和它不完全一样,跨域带凭证时会失效。
注意:
@EnableWebSocket别和@EnableWebSocketMessageBroker同时加在一个类上,两者是不同体系,混用会出现处理器注册顺序问题,表现是连上后立刻断开。
处理器本身只需要实现几个回调:
public class EchoHandler extends TextWebSocketHandler { // 收到消息时回显,用于验证链路是否通 @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // 手动指定 true 表示消息完整发送,不拆包 session.sendMessage(new TextMessage("echo: " + payload)); } @Override public void afterConnectionEstablished(WebSocketSession session) { // 连接建立后的钩子,适合放会话登记逻辑 System.out.println("connected: " + session.getId()); } }handleTextMessage里做了最简回显,先把「连得上、发得回」验证通过,再往里加业务。血泪经验是:一上来就把会话池、心跳、鉴权全堆进去,出问题时根本不知道是哪一层断的。TextWebSocketHandler对二进制消息不处理,如果要做文件传输,得换成BinaryWebSocketHandler或继承AbstractWebSocketHandler自己分发。
4.2 会话管理与心跳的配置取舍
会话池别用裸HashMap。WebSocket 的会话在多线程下会被并发读写,HashMap扩容时可能死循环。我一般用ConcurrentHashMap<String, WebSocketSession>,key 用业务上的用户标识,不直接用session.getId(),否则前端重连一次就多一条脏数据。
public class SessionRegistry { // 并发容器,避免多线程下 put/get 冲突 private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); public void register(String userId, WebSocketSession session) { WebSocketSession old = sessions.put(userId, session); // 同一用户重连时,把旧会话关掉,防止消息重复推送 if (old != null && old.isOpen()) { try { old.close(); } catch (IOException ignored) { } } } public void remove(String userId) { sessions.remove(userId); } }这段的重点在put之前的旧会话处理。不做的话,用户断网重连,服务端还留着一条半死连接,推消息时一半成功一半超时,排查起来像黑匣子。心跳这块 Demo 阶段可以先不做,浏览器和服务端都有默认超时;真上生产再按业务需要加ping/pong,间隔一般取 25 到 30 秒,太短会白白耗连接数。
配置文件里顺手把端口和日志级别定一下,排查时省事:
server: port: 8080 logging: level: # 打开握手相关日志,出问题时能看到具体拒绝原因 org.springframework.web.socket: DEBUG日志级别开到 DEBUG 是排查握手的后悔药。默认级别下握手失败只返回一个 403,什么原因都不说;开了 DEBUG 能看到是来源校验没过还是路径没匹配上。
5. 避坑排查:握手 404、连上就断与跨域 403 这几处
装 WebSocket 环境时翻车的姿势高度集中,我整理了几条反复遇到的,按现象、原因、解决写,照着对号入座基本能定位。
5.1 常见问题对照
现象一:前端报WebSocket connection to ... failed: Error during WebSocket handshake: Unexpected response code: 404。
原因通常是路径不一致或没注册处理器。registry.addHandler里的路径和前端 URL 差一个前缀,或者工程里同时存在@ServerEndpoint("/ws")和registerWebSocketHandlers两套注册方式,后者覆盖了前者。解决:只保留一套注册风格,路径用常量统一管理,前后端引用同一个值。
现象二:连接建立后一两秒自动断开,服务端日志无异常。
原因多见于跨域配置写了setAllowedOrigins("*")但前端带了 Cookie,或者中间过了反向代理没转发Upgrade和Connection头。解决:跨域改用setAllowedOriginPatterns,Nginx 侧补两行:
# WebSocket 必须显式转发升级头,否则代理层直接按普通 HTTP 处理 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s;proxy_read_timeout默认 60 秒,长连接空闲一会儿就被代理掐了,表现是「用着用着就断」,不加这行很容易误判成代码问题。
现象三:启动报jakarta.websocket.DeploymentException或ClassNotFoundException: javax.websocket.*。
原因就是 2.x 和 3.x 包名混用。项目是 Boot 3.x,代码里却 import 了javax.websocket.server.ServerEndpoint。解决:统一改成jakarta.websocket.*,同时排查依赖树里有没有老版本 API 被间接引入,mvn dependency:tree | grep websocket一眼能看出来。
现象四:本地能连,部署到服务器后连不上,浏览器报 1006。
原因多半是反向代理只配了 HTTP,未转发升级头,或者安全组没放行对应端口。解决:先确认代理配置里的两条升级头,再用curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket"打一下握手接口,看返回是不是 101。返回 200 就说明请求根本没到 WebSocket 处理器。
现象五:消息发出去前端收不到,但服务端日志显示已发送。
原因通常是session.sendMessage被并发调用。WebSocketSession 不是线程安全的,两个线程同时往一个会话写会抛IllegalStateException: The remote endpoint was in state [TEXT_FULL_WRITING]。解决:给会话加同步,或者用ConcurrentWebSocketSessionDecorator包一层,它内部做了发送缓冲。
提示:这五条里,第 1 条和第 3 条占了实际问题的多数。写代码前先把路径和包名对齐,能砍掉一大半返工。
5.2 依赖冲突的排查手法
版本冲突不一定会报编译错,有时是运行期才炸。养成拉完依赖先看一眼树的习惯:
# 只看 websocket 相关依赖,确认没有两个版本并存 mvn dependency:tree -Dincludes=org.springframework:spring-websocket # 找冲突时可加这个参数,直接定位是被谁引入的 mvn dependency:tree -Dverbose | grep -A2 websocket输出里如果同一个 artifact 出现两个版本,靠前的生效。排除掉旧的那个,别指望 Maven 帮你选对。
6. 装完怎么验证:三步确认链路真跑通
代码写完、启动无报错,不等于装好了。我验证 WebSocket 环境固定走三步,任何一步不过都不算完成。
第一步,服务端起得来且端口在监听。mvn spring-boot:run之后看日志有没有Tomcat started on port 8080,再用netstat -ano | findstr 8080(Windows)或lsof -i:8080(macOS/Linux)确认端口真的被占用。端口没起来但日志说启动了,通常是配置文件和实际加载的文件不是同一个。
第二步,用手写的前端页面完成一次完整收发。浏览器控制台直接敲:
// 地址必须和 registerWebSocketHandlers 里的路径一致 const ws = new WebSocket("ws://localhost:8080/ws/echo"); ws.onopen = () => { console.log("open"); // 连上后立刻发一条,验证服务端回显 ws.send("hello"); }; ws.onmessage = (e) => console.log("recv:", e.data); // 关闭时打印状态码,1006 代表异常断开,需要查代理或跨域 ws.onclose = (e) => console.log("closed:", e.code, e.reason); ws.onerror = (e) => console.error("error", e);控制台依次出现open、recv: echo: hello才算链路通。onclose里把e.code打出来很关键,1000 是正常关闭,1006 是异常断开,1011 是服务端内部错误,光看「断了」两个字没法定位。
第三步,模拟一次异常断开再重连。手动把服务端停掉,观察前端是否触发onclose,重新启动后再连一次,确认会话池里没有残留的旧会话。这一步能提前发现会话清理逻辑没写的问题,上线后用户重连频繁时就是消息重复推送。
验证通过后,建议把这三步写进工程的 README,团队里换人搭环境时照着走,比口头传帮带靠谱。我自己是从一次部署后「本地好好的、线上一直 1006」的事故之后,才养成每次装完都强制走一遍握手验证、并且把proxy_read_timeout一起检查的习惯。WebSocket 这东西,能连上只是开始,断得干净、重连得上才算装明白。希望这套流程能帮到你,少在版本和路径上返工。
本文还有配套的精品资源,点击获取