资讯详情

SpringBoot WebSocket 消息推送 Demo:握手与会话管理

📅 2026/10/26 4:03:23 | 华诺云谱 👁 阅读
SpringBoot WebSocket 消息推送 Demo:握手与会话管理
简介这份 SpringBootWebSocket 示例工程面向需要为 Web 应用补充实时通信能力的后端开发者与入门学习者围绕聊天室、实时通知一类场景演示如何在 Spring Boot 中集成 WebSocket 并完成消息收发。工程包含 WebSocket 配置类与自定义处理器完成连接建立、会话登记与消息广播另外还附带可直接打开的前端页面用于联调测试。整包共 106 个文件以 82 个 xmlMaven 与工程配置、5 个 java 源文件、5 个 class 编译产物、4 个 html 页面为主另有 properties 配置、js 脚本以及 1 个 jar 依赖压缩包约 169KB体量轻便导入 IDE 即可运行。已有 412 人学习可见它作为起步模板有一定参考价值。读者可从中获得一套可运行的连接与广播骨架、前后端对接示例以及在此基础上扩展身份校验、消息编解码与异常处理的着手点适合作为实时功能开发的练手底稿。1. 消息推送总在轮询上翻车这个 Demo 把 WebSocket 讲透了做过实时消息的开发者大概都有过这样的血泪经验前端setInterval每隔三秒拉一次接口数据延迟不说服务端日志被轮询请求刷得密密麻麻稍微上点量数据库连接池就开始告急。这时候老手会告诉你该上 WebSocket 了。但真去翻 SpringBoot 的 WebSocket 文档WebSocketHandler、HandshakeInterceptor、WebSocketConfigurer一堆接口摆在面前反而不知道从哪下手。这份 SpringBootWebSocket Demo 就是来解决这个问题的。它把「浏览器与服务端建立长连接、双向收发消息」这条链路完整跑通从前端页面到一个后端 Handler从连接建立、会话管理到消息广播一层层拆给你看。适合两类人一是刚接触 WebSocket、想在本地先跑通一个能看见效果的最小项目的开发者二是项目里正打算把轮询换成推送、需要一个可改造骨架的熟手。2. 工程骨架与依赖从 pom 到目录看清这个 Demo 的骨架拿到一个 SpringBoot Demo我习惯先看两件事pom.xml里引了什么src下目录怎么摆。这两处基本决定了这个项目的技术边界和扩展上限比读 README 靠谱得多。2.1 依赖清单spring-boot-starter-websocket 是唯一的关键项这个 Demo 的核心依赖只有一个就是 SpringBoot 官方提供的 WebSocket starter。它内部封装了 Spring 的 WebSocket 抽象层并且把底层容器内嵌 Tomcat对 WebSocket 协议的支持一并带进来了不需要额外引 Tomcat 的 websocket 包。dependencies !-- Web 基础提供内嵌 Tomcat 与 SpringMVC -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 核心Spring 的 WebSocket 抽象 容器对 WS 协议的支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency !-- 简化实体类消息体 POJO 用 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies三个依赖各司其职。starter-web提供 HTTP 服务和静态资源映射前端页面直接丢resources/static下就能访问省掉单独起前端的麻烦。starter-websocket是主力它把spring-websocket和spring-messaging拉进来前者是原生 WebSocket 的接口层后者是 STOMP 消息协议那一套。这个 Demo 走的是原生 Handler 路线所以spring-messaging实际用不上但它跟着传递依赖进来不影响运行。提示如果你的项目只需要原生 WebSocket不想被 STOMP 那套消息代理概念干扰依赖引到starter-websocket就够了不用再单独引spring-messaging避免后面看源码时被两套模型绕晕。Lombok 是可选项只是为了让消息实体类少写 getter/setter。如果本地 IDE 没装 Lombok 插件编译会报「找不到符号」这时候要么装插件开注解处理要么老老实实手写 getter别在这上面卡半小时。2.2 目录结构五层划分每层职责单一标准 Maven 布局下这个 Demo 的包结构大致是这样组织的src/main/java/com/example/websocketdemo/ ├── WebSocketDemoApplication.java # 启动类 ├── config/ │ └── WebSocketConfig.java # 注册 Handler 与拦截器 ├── handler/ │ └── ChatWebSocketHandler.java # 核心连接、消息、关闭回调 ├── interceptor/ │ └── AuthHandshakeInterceptor.java # 握手阶段校验可选扩展点 ├── session/ │ └── SessionManager.java # 会话池管理在线连接 └── model/ └── Message.java # 消息体 POJOconfig包负责把 Handler 挂到某个 URL 路径上是「路由层」handler包是业务核心连接建立、收到消息、连接关闭三个回调都在这里interceptor是握手阶段的钩子用来做鉴权或参数提取session单独抽出来管理会话集合避免 Handler 里塞满静态 Mapmodel放消息结构。resources下通常有个static/index.html里面用原生WebSocketAPI 连上来页面上几个输入框加一个消息列表功能虽小但闭环完整。这种「一个 HTML 搞定前端」的做法在 Demo 里很常见好处是不用装 Node 环境双击就能跑。2.3 启动类与配置一个注解加一个 Bean启动类没什么花活标准写法SpringBootApplication public class WebSocketDemoApplication { public static void main(String[] args) { SpringApplication.run(WebSocketDemoApplication.class, args); } }真正要留意的是配置类。SpringBoot 的自动配置不会帮你注册 WebSocket 端点必须自己实现WebSocketConfigurer把 Handler 显式挂到路径上Configuration EnableWebSocket // 开启原生 WebSocket 支持 public class WebSocketConfig implements WebSocketConfigurer { Autowired private ChatWebSocketHandler chatHandler; Autowired private AuthHandshakeInterceptor authInterceptor; Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler, /ws/chat) // 端点路径 .addInterceptors(authInterceptor) // 握手拦截器 .setAllowedOrigins(*); // 跨域放行生产环境要收紧 } }EnableWebSocket是关键注解没有它WebSocketConfigurer实现了也不会生效端点根本不会注册前端连上去直接 404这是新手最容易踩的坑之一。addHandler的第二个参数是路径前端连接时写ws://localhost:8080/ws/chat即可。setAllowedOrigins(*)在开发阶段图省事生产必须改成具体域名。这个参数控制的是 WebSocket 握手时的Origin头校验写成*等于任何来源的页面都能连配合 Cookie 场景会有安全风险这一点在二次开发时要收掉。3. 核心链路拆解一个连接从握手到收发消息经历了什么这一段是整篇最该细看的部分。WebSocket 看起来神秘其实就三件事握手把 HTTP 升级成 WS、连接建立后维护会话、消息来了分发给目标。把这个 Demo 的 Handler 读透这三件事就都清楚了。3.1 继承 TextWebSocketHandler三个回调撑起全部业务原生 WebSocket 的 Handler 有两种基类可选TextWebSocketHandler处理文本帧BinaryWebSocketHandler处理二进制帧。聊天、通知这类场景传 JSON 字符串用前者就够。Component public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Logger log LoggerFactory.getLogger(ChatWebSocketHandler.class); Autowired private SessionManager sessionManager; // 1. 连接建立成功 Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 从握手阶段塞进来的属性里取用户标识 String userId (String) session.getAttributes().get(userId); sessionManager.add(userId, session); log.info(连接建立, userId{}, sessionId{}, 当前在线{}, userId, session.getId(), sessionManager.onlineCount()); } // 2. 收到客户端消息 Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); log.info(收到消息: {}, payload); // 解析目标用户与内容这里用简单字符串协议演示实际建议 JSON Message msg JSON.parseObject(payload, Message.class); sessionManager.sendTo(msg.getTo(), msg.getContent()); } // 3. 连接关闭正常关闭或异常断开都会进 Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); sessionManager.remove(userId); log.info(连接关闭, userId{}, status{}, 当前在线{}, userId, status, sessionManager.onlineCount()); } }三个回调分别对应连接的三个生命周期节点。afterConnectionEstablished在握手成功后立刻触发此时session已可用但注意还没经过任何业务处理适合做「登记上线」。handleTextMessage是主要业务入口所有客户端发来的文本都从这里进。afterConnectionClosed是清理点不管是用户主动关闭、网络断开还是服务端踢人都会走到这里是释放资源的唯一可靠时机。session.getAttributes()是贯穿三处的那根线。它不是 WebSocket 协议自带的东西而是本地构建的MapString, Object在握手拦截器里塞值在后续回调里取值。这个机制很重要——WebSocket 连接本身不带身份信息身份只能靠握手阶段注入比每次消息都带 token 靠谱。还有一个容易忽略的点handleTextMessage里没有throws之外的异常处理。如果JSON.parseObject抛异常消息会被吞掉客户端看不到任何反馈。实际项目里这里要包 try-catch失败时回一条错误消息给发送方否则排查问题时就是黑匣子。3.2 会话管理ConcurrentHashMap 才是正解多人在线就得存会话。这个 Demo 把会话池单独拆成SessionManager是很清醒的做法避免 Handler 里出现一个private static Map又当爹又当妈。Component public class SessionManager { // key 用业务用户 IDvalue 是这条连接的会话 // 必须并发容器WebSocket 的回调线程来自容器线程池天然多线程 private final MapString, WebSocketSession sessions new ConcurrentHashMap(); public void add(String userId, WebSocketSession session) { sessions.put(userId, session); } public void remove(String userId) { sessions.remove(userId); } public int onlineCount() { return sessions.size(); } // 发给指定用户 public void sendTo(String userId, String content) throws IOException { WebSocketSession target sessions.get(userId); if (target ! null target.isOpen()) { // WebSocketSession 不是线程安全的发送必须加锁 synchronized (target) { target.sendMessage(new TextMessage(content)); } } } // 广播给所有人 public void broadcast(String content) throws IOException { for (WebSocketSession s : sessions.values()) { if (s.isOpen()) { synchronized (s) { s.sendMessage(new TextMessage(content)); } } } } }容器选型上ConcurrentHashMap不是可选项而是必须项。WebSocket 的每个回调都由容器线程池里的线程执行两个用户同时上线add就是并发的用HashMap轻则数据丢失重则死循环这种玄学问题一旦出现日志里根本看不出来。sendTo里那层synchronized (target)更是血泪经验。WebSocketSession的 javadoc 明确写了自己不是线程安全的而 Spring 官方的ConcurrentWebSocketSessionDecorator会强制串行化写入。如果两个线程同时往同一个 session 发消息底层 TCP 写出会交错客户端收到的可能是半截 JSON 拼着另一条消息的头典型的「消息错乱」。加锁是最小的修复成本代价是高并发下单个 session 的吞吐受限量大再换成消息队列异步派发。isOpen()的判断也不能省。连接刚断但afterConnectionClosed还没执行的那一瞬间session 还在 Map 里但已经不可写直接 send 会抛IOException把整个循环带崩。广播里尤其明显——一个人掉线其他人全收不到这类事故排查起来极其费时。3.3 握手拦截器把身份注入 attributesWebSocket 不像 HTTP 那样每个请求都带 Cookie/Header连接建立一次就长期复用。所以鉴权只能在握手那一下完成完成后把身份存起来后续消息靠 session 关联。Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { // 握手之前此时还是 HTTP 请求能拿到 header 和参数 Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 演示从 URL 参数取 userId实际项目应从 token 解析 String query request.getURI().getQuery(); String userId extractParam(query, userId); if (userId null || userId.isEmpty()) { // 返回 false 会直接拒绝握手前端 onerror 触发 return false; } attributes.put(userId, userId); // 塞进去后面回调里能取 return true; } // 握手之后一般用来记日志或做统计很少改状态 Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // no-op } private String extractParam(String query, String key) { if (query null) return null; for (String pair : query.split()) { String[] kv pair.split(); if (kv.length 2 key.equals(kv[0])) return kv[1]; } return null; } }beforeHandshake是唯一能在连接建立前做拦截的地方。返回true放行返回false拒绝。注意拒绝后前端收到的是握手失败不是正常的关闭帧onerror会触发但onclose也会跟着触发前端状态机要按这个顺序处理否则会出现「连接失败但 UI 显示已断开」的错位。attributes这个 Map 是拦截器和 Handler 之间的唯一通道。它的生命周期和 session 一致session 关闭后自动失效不需要手动清理。这也意味着不能在 attributes 里塞大对象会话多了内存会持续占用塞个 userId 这种短字符串最合适。示例里从 URL 参数取 userId 只是为了 Demo 好演示。实际项目里 token 通常放在 header 或者Sec-WebSocket-Protocol子协议里浏览器原生 WebSocket API 不支持自定义 header所以常见做法是把 token 拼在 URL query 上注意日志脱敏或者用子协议字段承载这两种方案各有取舍前者简单但 token 容易进访问日志。3.4 前端这一侧原生 API 二十行搞定前端页面不用框架也能跑原生WebSocket对象够用了// 连接时把身份带上服务端拦截器从 query 里取 const userId user_ Math.floor(Math.random() * 1000); const ws new WebSocket(ws://localhost:8080/ws/chat?userId${userId}); // 连接打开可以发消息了 ws.onopen () { console.log(已连接); appendMsg(系统, 连接成功); }; // 收到服务端推送 ws.onmessage (event) { const data JSON.parse(event.data); appendMsg(data.from || 对方, data.content); }; // 出错和关闭要分开处理 ws.onerror () appendMsg(系统, 连接出错); ws.onclose (e) appendMsg(系统, 连接关闭 code${e.code}); // 发送按钮 document.getElementById(sendBtn).onclick () { const input document.getElementById(msgInput); const payload JSON.stringify({ to: user_1, content: input.value }); // 发送前必须检查状态CONNECTING 阶段直接 send 会抛异常 if (ws.readyState WebSocket.OPEN) { ws.send(payload); input.value ; } };四个事件回调对应连接全生命周期onopen之后才能send这是铁律。readyState有四个值CONNECTING(0)、OPEN(1)、CLOSING(2)、CLOSED(3)只有等于 1 时发送才合法。新手最常见的翻车就是页面一加载就send那时还在握手直接抛InvalidStateError。断线重连这里没实现Demo 层面可以理解但真实项目必须补。标准做法是在onclose里起一个带退避的setTimeout重连间隔按 1s、2s、4s、8s 递增并设上限同时用一个标志位防止重连风暴。4. 本地跑通三步启动与四个必验点代码看懂了接着要把它在本地跑起来。这个 Demo 的好处是没有外部依赖不需要数据库、不需要 MQ一个 JDK 加 Maven 就能完整验证。4.1 环境与启动命令环境要求不高JDK 8 及以上SpringBoot 2.x 系列Maven 3.6。如果你用的是 SpringBoot 3.x 分支JDK 必须是 17 起这一点先确认清楚否则启动直接报UnsupportedClassVersionError报错信息还不在第一屏。# 进入项目根目录先编译 mvn clean package -DskipTests # 方式一直接跑 jar java -jar target/websocket-demo-0.0.1-SNAPSHOT.jar # 方式二开发期用插件跑改代码不用重新打包 mvn spring-boot:run启动成功后控制台会打出Tomcat started on port(s): 8080和Started WebSocketDemoApplication。端口被占用是最常见的启动失败原因改application.yml里的server.port即可别去翻代码。server: port: 8080 logging: level: com.example.websocketdemo: debug # 打开自己包的日志方便看连接回调日志级别调到 debug 后握手、消息、关闭的每个回调都会打出来排查连接问题时这比断点还快。4.2 四个必验点从单连接走到双人互推光看到启动日志不算跑通要按顺序验证四件事第一页面能打开。浏览器访问http://localhost:8080/index.html能看到输入框和消息区。看不到就是静态资源路径不对检查文件是否在src/main/resources/static/下注意不是templates后者要走模板引擎渲染直接访问会 404。第二单连接能建立。打开浏览器开发者工具的 Network 面板筛选 WS刷新页面应该能看到一条状态 101 的请求。101 是Switching Protocols看到它说明 HTTP 成功升级为 WebSocket。如果一直是 pending 然后失败八成是拦截器返回了 false 或者路径写错。第三消息能双向走。在输入框发一条看控制台是否打出「收到消息」同时页面上是否出现自己发的这条。服务端收到但页面不回显那是前端onmessage里的解析出了问题页面有回显但服务端无日志那是连接根本没通。第四双人互推能成。开两个浏览器窗口用无痕模式避免 session 冲突各自连上A 发给 BB 的页面要能收到。这一步验证的是SessionManager的路由逻辑也是这个 Demo 最核心的价值点。如果 B 收不到先看两个窗口的 userId 是不是撞了——随机数方案在小样本下撞车概率不低。注意用一个浏览器开两个标签页测试时某些浏览器会对同域名连接做复用表现为第二个标签的连接状态异常。测试多连接一律用无痕窗口或不同浏览器能省掉大量误判。5. 避坑与排查五个真实踩过的坑WebSocket 的问题有个共同特点现象和原因隔得远日志还经常不给线索。下面五条是我在类似 Demo 上真实翻过的车每条按现象、原因、解决三段写遇到时可以直接对号。坑一前端一直报 404Network 里连 WS 请求都看不到。现象是new WebSocket()后立刻onerror控制台提示握手失败。原因通常是配置类没生效——要么漏了EnableWebSocket要么WebSocketConfig没被扫描到包路径不在启动类的同级或子包下。还有一种可能是版本问题SpringBoot 3.x 里WebSocketConfigurer的包路径变过从旧教程抄的 import 会编译不过或运行时不识别。解决先确认启动日志里有没有端点注册的痕迹再检查注解和包路径最后核对 SpringBoot 大版本与教程是否一致。坑二连接建立成功但服务端一收消息就断。现象是afterConnectionEstablished打了日志客户端发第一条消息后连接立刻关闭服务端没有任何异常堆栈。原因是handleTextMessage里抛了未捕获异常Spring 的默认行为是记录日志后关闭连接而日志级别不够时你看不到。解决把logging.level.org.springframework.web.socketdebug打开或者在 handler 里整体包 try-catch 把异常打出来。这类问题九成是 JSON 解析失败——客户端发的不是合法 JSON或者字段名和 POJO 对不上。坑三双人测试时 B 收不到消息但日志显示消息已发出。现象是sendTo执行了没报错B 的页面就是没反应。原因有两个方向一是SessionManager的 key 撞了两个连接用了同一个 userId后连的把先连的覆盖掉消息发给了自己二是WebSocketSession并发写导致帧损坏客户端解析失败但没报错。解决在add方法里加个判断key 已存在时先关旧连接再放新连接并把覆盖事件打日志并发写的问题按前面说的加synchronized或换ConcurrentWebSocketSessionDecorator。坑四本地好好的部署到服务器后连接频繁断。现象是连接能建立但空闲一段时间后必断重连又正常。原因基本是中间层反向代理、负载均衡的空闲超时默认 60 秒没数据就掐连接而 WebSocket 空闲时确实没数据。解决服务端加心跳定时给客户端发 ping 帧或者业务层的心跳包间隔设在超时时间的一半以内同时在代理层把proxy_read_timeout调大。心跳这事在 Demo 里可以不做上生产必须做否则用户看到的就是「页面放着不动就掉线」。坑五Chrome 能连某些浏览器或客户端连不上。现象是换浏览器或者用 Postman、移动端 SDK 连握手直接被拒。原因通常是setAllowedOrigins配得太死或者某些客户端不发Origin头Spring 的默认校验会拒绝无 Origin 的请求。解决生产环境按域名白名单配置不要图省事写*对接非浏览器客户端时确认对方是否带 Origin必要时对特定路径放开校验。这条坑的隐蔽性在于「同一份代码在不同客户端表现不同」很容易被误判成前端 bug。6. 二次开发改造从 Demo 到能上生产的三个方向Demo 跑通只是起点真拿去用至少要补三块消息协议、连接健壮性、水平扩展。这三块决定了它是「能演示」还是「能用」。6.1 消息协议标准化从裸字符串到带类型的信封原始 Demo 直接传 content消息没有类型扩展时会很痛苦。我一般会先定义一层信封结构Data public class WsMessage { private String type; // CHAT / PING / SYSTEM / ACK private String from; // 发送方 private String to; // 接收方广播时为 null private String content; // 业务载荷 private long timestamp; // 客户端可用于排序和去重 }对应 Handler 里按type分发CHAT走业务、PING直接回PONG不落库、ACK用于确认送达。加这一层的好处是后续加新消息类型不用改分发骨架只加分支。心跳也顺带解决了——前端每 30 秒发一次PING服务端回PONG既保活又能探测半开连接。6.2 连接健壮性心跳、重连、会话清理三件套心跳前面提过服务端侧可以用 Spring 的TaskScheduler定时遍历会话发 ping客户端侧在onmessage里刷新一个本地计时器超过阈值没收到任何消息就主动重连。重连要带退避别用固定间隔let retry 0; const MAX_RETRY 5; function connect() { const ws new WebSocket(url); ws.onopen () { retry 0; }; // 连上就重置计数 ws.onclose () { if (retry MAX_RETRY) return; // 超过上限停止提示用户 const delay Math.min(1000 * 2 ** retry, 30000); // 指数退避封顶 30s retry; setTimeout(connect, delay); }; }指数退避的意义在于避免服务端重启时所有客户端同时重连形成惊群。封顶 30 秒是经验值再长用户会觉得卡死。会话清理这块afterConnectionClosed是主路径但不能只依赖它。进程被 kill、容器 OOM 时这个回调不会执行Map 里的脏会话会一直留着。稳妥做法是给每个 session 记录最后活跃时间起一个定时任务扫掉超时的双保险。6.3 水平扩展单机 Map 撑不住时怎么办SessionManager用本地 Map 存会话单机没问题一旦部署两个实例A 实例上的用户给 B 实例上的用户发消息就找不到了。这是从 Demo 走向生产的最大一道坎。常见做法是引入 Redis 做会话路由每个实例启动时订阅一个频道用户上线时把userId - 实例标识写进 Redis发消息时先查目标用户在哪个实例不在本机就投递到对应频道由目标实例转发。另一种更彻底的做法是直接上 STOMP 外部消息代理用 broker 做广播Spring 的spring-messaging就是为这个准备的。改造成本上前一种方案改动小只动SessionManager和加一个 Redis 订阅后一种要重写 Handler 层把原生帧换成 STOMP 帧。选哪种看规模——几千连接以内Redis 路由足够再往上、还要做集群广播STOMP 那套更省心。提示改造成 Redis 路由时用户下线要记得删路由键并且给键设 TTL 兜底。否则实例崩溃后残留的路由会让消息发到不存在的实例表现为「消息发出去没人收」比直接报错更难查。最后说个验证方法改造完别只在本地测起两个实例跑在不同端口用两个浏览器分别连不同端口互发消息验证跨实例路由。这一步能提前暴露八成以上的集群问题比上线后再查划算得多。从那以后我每次做 WebSocket 相关的改造都会强制走一遍「双实例互推」这个验证因为单机测试永远测不出分布式下的会话丢失。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑