news 2026/9/12 19:05:59

Spring Boot + WebSocket 即时聊天系统:从握手到断线重连全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot + WebSocket 即时聊天系统:从握手到断线重连全解析

简介:基于Spring Boot与WebSocket并结合JavaScript实现的即时聊天系统,面向需要了解Web实时通信机制的初中级开发者。资源压缩包内含99个文件,总大小10.7MB,其中包含25个Java源文件、13个JS文件、9个CSS文件及4个HTML页面,并配有pom.xml、mvnw等Maven构建配置和README说明文档,目录划分清晰,便于定位服务端与前端代码。项目采用Spring Boot搭建后端,通过WebSocket实现消息推送,前端利用原生JavaScript与WebSocket API完成实时收发,同时包含若干图片、PSD设计稿等辅助素材。已有109人学习该资源,适合作为课程设计或个人练习WebSocket通信时的参考案例。读者可从中获取一套可运行的服务端与客户端代码结构,理解Spring Boot集成WebSocket的配置方式、消息映射与前端连接逻辑,并在此基础上扩展多人会话或消息持久化功能。

1. WebSocket 即时聊天:Springboot 和 JS 分别解决什么问题

把一个「基于Springboot websocket + js实现的即时聊天系统.zip」拆开看,真正值钱的部分不在 WebSocket 本身,而在连接建立之后那套状态管理。WebSocket 只是一个长连接通道,HTTP 协议下那种「请求-响应」模型在这里变成了双工帧,服务端可以主动把消息塞给浏览器。但连接建立只是开始,用户在线状态怎么维护、消息怎么路由、断线之后前端怎么恢复,才是这个项目里最容易写乱的三块。这个标题对应的技术栈很典型:Spring Boot 负责端点注册、握手拦截和 session 管理,JS 负责连接生命周期和消息渲染。适合想把即时通讯的完整闭环跑通的人,也适合准备面试时被问到「WebSocket 和 HTTP 的区别到底在工程上意味着什么」的人。接下来按一条可复现的路径把整个系统搭出来。

2. Springboot 端搭建 websocket 服务:依赖、端点和握手拦截

2.1 引入依赖:spring-boot-starter-websocket 与内嵌容器

Spring Boot 集成 WebSocket 不需要额外安装独立中间件,内嵌的 Tomcat 或 Jetty 从 Servlet 3.1 起就原生支持 WebSocket。引入一个 starter 即可:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

这个 starter 会把spring-websocketspring-messaging拉进来,同时自动配置WebSocketHandlerRegistry相关的组件。项目中如果同时存在spring-boot-starter-web,两者可以共存,HTTP 接口负责登录、历史消息查询,WebSocket 只管实时帧。需要注意,Spring Boot 2.x 与 3.x 在这块的依赖坐标没有变化,但 3.x 底层换成 Jakarta 命名空间,网上大量 2.x 时代的代码片段在 3.x 下会遇到编译级别的报错,后面第 6 章专门讲这个兼容问题。

2.2 注册端点和 allowedOriginPatterns 参数

用 Spring 官方推荐的WebSocketHandler方式注册端点,先写一个配置类:

@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ChatWebSocketHandler(), "/ws/chat") .addInterceptors(new AuthHandshakeInterceptor()) .setAllowedOriginPatterns("*"); } }

addHandler第一个参数是消息处理器实例,第二个参数是客户端连接的 URL 路径。addInterceptors注册握手拦截器,在这里做 token 校验和用户身份绑定。setAllowedOriginPatterns控制哪些页面来源允许发起 WebSocket 握手,这个参数在前后端分离场景下经常踩坑。

setAllowedOriginPatterns("*")表示允许所有来源,生产环境建议收紧为前端具体域名。与setAllowedOrigins的区别在于:setAllowedOrigins不支持通配符与携带凭证的请求同时使用,而setAllowedOriginPatterns内部基于OriginHandshakeInterceptor实现,匹配逻辑更灵活。曾有线上事故是前端从http://localhost:5173访问,后端只配了http://localhost:8080,握手直接被拒,onclose回调先于onopen触发,现象非常迷惑。

参数作用开发环境建议
setAllowedOriginPatterns("*")放行任意 Origin 来源方便本地调试
setAllowedOriginPatterns("https://chat.example.com")只放行指定域名生产环境必配
setAllowedOrigins精确来源列表,不支持通配符携带凭证少用,容易误伤

2.3 握手拦截器做 token 校验

握手拦截器是在 HTTP 升级为 WebSocket 之前执行的,此时可以拿到 query 参数、Header 和 Cookie。常见做法是前端连接时带上token,后端在beforeHandshake里校验并把用户 ID 塞进 attributes:

public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String query = request.getURI().getQuery(); String token = parseQueryParam(query, "token"); String userId = authService.validateToken(token); // 校验失败返回 null if (userId == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("userId", userId); return true; } private String parseQueryParam(String query, String key) { if (query == null || query.isEmpty()) { return ""; } return Arrays.stream(query.split("&")) .map(pair -> pair.split("=")) .filter(kv -> kv.length == 2 && key.equals(kv[0])) .map(kv -> kv[1]) .findFirst() .orElse(""); } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后如果需要记录日志,可以在这里做 } }

拦截器方法返回false意味着拒绝握手,客户端会收到非 101 状态码的响应,相应触发onerror而不是onclose。attributes 里的数据在afterConnectionEstablished中通过session.getAttributes()取出来,这样就完成了匿名 TCP 连接到业务用户身份的映射。实际开发中不要学示例里那样裸传 token,至少要用HttpHeaders里的Authorization头,避免 token 出现在 Nginx access log 的 query 里。

2.4 消息处理器:TextWebSocketHandler 的职责边界

消息处理器继承TextWebSocketHandler,只需要关注三个时机:连接建立、收到文本消息、连接关闭:

public class ChatWebSocketHandler extends TextWebSocketHandler { private static final Map<String, WebSocketSession> SESSIONS = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { String userId = (String) session.getAttributes().get("userId"); WebSocketSession old = SESSIONS.put(userId, session); if (old != null) { try { old.close(CloseStatus.NORMAL); } catch (IOException e) { log.warn("close old session failed", e); } } } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); // payload 是 JSON 字符串,解析后按 type 分发路由,逻辑见第 4 章 routeMessage(payload, session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { SESSIONS.values().remove(session); } }

TextWebSocketHandler只处理文本帧,BinaryWebSocketHandler处理二进制帧,聊天场景用文本足够。SESSIONSConcurrentHashMap保证线程安全,WebSocket 的回调方法工作在不同的 Tomcat 线程上,用普通HashMap会出现ConcurrentModificationExceptionafterConnectionClosedSESSIONS.values().remove(session)是 O(n) 操作,用户量级上万以后要考虑反向索引。

2.5 WebSocketHandler 与 @ServerEndpoint 的取舍

另一个常见写法是@ServerEndpoint("/ws/chat")@OnOpen@OnMessage注解,这套是 Java WebSocket 标准 JSR-356 的 API。两种方案并存,新手经常在知乎和博客里看到两套代码,不知道选哪个。

建议选WebSocketHandler,原因是它由 Spring 容器管理,可以直接注入UserServiceMessageService等 Bean。@ServerEndpoint每个连接都是独立实例,由 WebSocket 容器创建,不经过 Spring 的依赖注入流程,想用 Service 得写静态方法或自定义SpringContextHolder绕一圈。对于即时聊天这种业务逻辑复杂、需要查库写库的场景,WebSocketHandler的侵入性更小。

提示:面试时被问到「两种方式区别」时,核心答端点由谁管理、依赖注入如何解决,不要停留在注解 vs 继承这种表面差异。

3. JS 客户端接入 websocket:连接参数、心跳与断线重连

3.1 最小接入代码:new WebSocket 与四个回调

浏览器原生支持 WebSocket,不需要引入 socket.io 之类的库。最小可用代码把四个生命周期回调写全:

const token = document.getElementById('token').value; const ws = new WebSocket(`ws://${location.host}/ws/chat?token=${token}`); ws.onopen = function () { console.log('connection established'); ws.send(JSON.stringify({ type: 'chat', to: 'user123', content: 'hello' })); }; ws.onmessage = function (event) { const msg = JSON.parse(event.data); renderMessage(msg); }; ws.onerror = function (event) { // 不会接收到具体的 HTTP 状态码,只能从 event 里拿通用错误 console.error('websocket error', event); }; ws.onclose = function (event) { console.log(`closed code=${event.code} reason=${event.reason}`); };

URL 用location.host拼出来,避免把 IP 和端口写死在 JS 里。HTTPS 页面必须用wss://,否则浏览器直接拒绝混合内容。onclose里的event.code是关闭码,1000表示正常关闭,1006表示异常断开,这个状态码是排查连接稳定性的关键线索。收消息后先JSON.parse再交给渲染层,不要把原始字符串直接插入 DOM,否则消息内容里的 HTML 会变成 XSS 注入点。

3.2 readyState 状态机与 binaryType 参数

WebSocket 实例有一个readyState属性,常见误用是在onclose之外的地方判断连接是否可用,然后拿到一个已经 CLOSED 的实例去send,结果抛InvalidStateError

readyState 值常量含义
0CONNECTING正在握手
1OPEN可以收发帧
2CLOSING正在发送关闭帧
3CLOSED连接已关闭,send 会抛异常

发送前统一判断:

function safeSend(ws, payload) { if (ws.readyState === WebSocket.OPEN) { ws.send(payload); } else { console.warn('socket not open, current state:', ws.readyState); } }

binaryType参数影响二进制帧的接收格式,默认是blob,如果需要接收 ArrayBuffer 要显式设置。聊天系统基本不涉及二进制帧,保持默认即可。真正值得关注的是bufferedAmount,大消息场景下用它判断数据是否已经真正进入网络栈,避免积压。

3.3 前端心跳:区分「空闲」与「假死」

WebSocket 底层有 TCP 的 keepalive 机制,但默认探测周期很长,且经过代理后 TCP 状态可能不一致。实际开发中,一个晚上挂机的浏览器标签页,第二天打开时连接往往已经死了,但onclose迟迟不触发。解决办法是应用层心跳:前端定期发 ping,服务端回 pong。

let heartbeatTimer = null; function startHeartbeat(ws) { stopHeartbeat(); heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); } }, 30000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }

心跳间隔设为 30 秒,onmessage里收到pong时更新lastActiveTime。如果连续两个心跳周期没收到任何消息,主动调用ws.close()并触发重连逻辑,而不是干等对端把连接断开。Nginx 对空闲连接默认proxy_read_timeout 60s,会把 60 秒内没有数据的连接掐断,心跳间隔必须小于这个值,通常取 25 到 30 秒。

3.4 断线重连与指数退避

连接断开后立刻重连是错误做法,服务端 gc 或网络抖动时会导致大量客户端同时发起握手,造成连接风暴。指数退避是标准解法:

let retryCount = 0; function connect() { const ws = new WebSocket(`ws://${location.host}/ws/chat?token=${token}`); ws.onopen = function () { retryCount = 0; startHeartbeat(ws); }; ws.onclose = function (event) { stopHeartbeat(); if (event.code !== 1000) { const delay = Math.min(1000 * Math.pow(2, retryCount), 30000); retryCount++; setTimeout(connect, delay); } }; ws.onmessage = function (event) { const msg = JSON.parse(event.data); if (msg.type === 'pong') { return; } renderMessage(msg); }; }

每次onopen成功重置retryCount,失败后按 1s、2s、4s……退避,封顶 30 秒。code 1000意味着服务端主动正常关闭,比如用户被顶下线,此时不重连。code 1006是异常断开,必须重连。Math.random()可以再叠加一个 0 到 1000ms 的随机抖动,防止大量客户端同时重连。

4. 即时聊天消息协议:会话管理、单聊与群聊广播

4.1 消息 JSON 协议:type、from、to 与 mid 字段

长连接通道建立后,消息承载格式需要设计。常见做法是统一走 JSON,最好在前后端约定一个稳定的 schema,避免每人一套字段命名。实际项目里沉淀下来的一套最小字段如下:

字段类型说明
typestringping/pong/chat/group/ack
midstring客户端生成的消息唯一 ID,用于去重和确认
fromstring发送方用户 ID
tostring接收方用户 ID 或群组 ID
contentstring文本消息内容
tsnumber客户端毫秒时间戳

mid是最容易被忽略的字段。前端每次发送前用Date.now() + '-' + Math.random()生成一个 ID,服务端处理完回一个ack帧,前端收到ack后把消息状态从「发送中」改为「已送达」。没有mid就无法做消息确认和重复消息去重,网络抖动时的重发机制就无从谈起。

public class ChatMessage { private String type; private String mid; private String from; private String to; private String content; private long ts; // getter / setter 省略 }

4.2 在线状态管理:双 Map 结构与 ConcurrentHashMap

服务端维护在线 session 的方式,直接决定消息路由的正确性。最简单的方案是用一个全局 Map 把 userId 映射到 WebSocketSession:

private static final Map<String, WebSocketSession> ONLINE_USER_MAP = new ConcurrentHashMap<>();

用户上线时put,下线时remove。但只有这一个 Map 不够,场景是同一用户多端登录:手机和电脑同时在线,put会把旧 session 顶掉,微信那种「手机电脑同时收消息」的需求无法实现。更完整的结构是「userId -> session 集合」:

private static final Map<String, CopyOnWriteArrayList<WebSocketSession>> ONLINE_USER_MAP = new ConcurrentHashMap<>(); public void addSession(String userId, WebSocketSession session) { ONLINE_USER_MAP.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(session); } public void removeSession(String userId, WebSocketSession session) { ONLINE_USER_MAP.computeIfPresent(userId, (k, list) -> { list.remove(session); return list.isEmpty() ? null : list; }); }

CopyOnWriteArrayList保证遍历时并发修改不抛异常,聊天场景下单个用户的 session 数量很小,写时复制的开销可以接受。遍历时逐个判断session.isOpen(),防止往已关闭的连接上发消息导致IOException

4.3 单聊消息路由代码

收到一条type=chat的消息后,路由逻辑分三步:查在线表、转发、确认。转发层不落库,只负责把消息送到目标用户的 session,落库由独立的消息服务处理:

public void routeChat(ChatMessage msg, WebSocketSession fromSession) throws Exception { List<WebSocketSession> targets = ONLINE_USER_MAP.get(msg.getTo()); if (targets == null || targets.isEmpty()) { // 目标不在线,进入离线消息逻辑 offlineService.save(msg); return; } boolean delivered = false; for (WebSocketSession target : targets) { if (target.isOpen()) { target.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); delivered = true; } } if (delivered) { fromSession.sendMessage(new TextMessage(objectMapper.writeValueAsString( Map.of("type", "ack", "mid", msg.getMid()) ))); } }

注意sendMessage是阻塞操作,目标端网络慢会拖慢当前线程。生产环境建议为每个 session 配置独立的发送队列,或用ConcurrentWebSocketSessionDecorator设置发送缓冲上限,防止一个慢客户端拖垮整个 Tomcat 线程池。单机版把在线表放在内存即可,多实例部署时ONLINE_USER_MAP是节点本地的,消息会路由到错误节点,需要引入 Redis 发布订阅做跨节点转发。这也是从 demo 到真正能上线的分水岭。

4.4 群聊广播与离线消息的兜底

群聊广播本质上是对群成员列表做一次路由循环:

public void routeGroup(ChatMessage msg) { List<String> memberIds = groupService.getMemberIds(msg.getTo()); for (String memberId : memberIds) { List<WebSocketSession> sessions = ONLINE_USER_MAP.get(memberId); if (sessions == null || sessions.isEmpty()) { offlineService.save(msg); // 离线成员进入离线队列 continue; } for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); } } } }

群聊场景要排除发送者本人,否则前端会收到自己发出的消息再渲染一次,产生重复气泡。离线消息的兜底方案先落库,用户下次上线时拉取未读消息即可。用内存ConcurrentLinkedQueue做离线队列只适合解决眼前问题,服务重启内存消息就丢了。生产环境常见的做法是把离线消息落到 Redis List 或者直接进消息表,上线时按WHERE receiver_id = ? AND read_status = 0查询补发,通过mid做幂等去重。

5. websocket 鉴权、可靠性保障与常见排错

5.1 握手与 token 校验:解析 URL 参数或 Header

握手阶段的 token 校验是第一步防线,但只有握手校验远远不够。WebSocket 每次连接只有一次 HTTP 握手,之后所有帧都不走 HTTP 头,token 不存在「过期后主动失效」的可能。服务端需要额外做两件事:握手时把 token 的过期时间写入 session attributes,服务端定时任务扫描并关闭过期连接;同时设置@PreDestroy钩子,应用停机时优雅关闭所有 session。

用 Header 传 token 比 query 参数安全,但浏览器原生 WebSocket API 无法自定义 Header,需要在 URL 上携带,或者用protocols参数传。一个务实的折中方案是:WebSocket URL 只传一个短期有效的 ticket(比如有效期 60 秒的一次性凭证),连接建立后服务端返回正式 token,之后的业务消息都带这个 token 做逐条校验。虽然逐条校验开销较大,但能有效防止连接被中间人劫持后直接收发消息。

5.2 消息确认与重发机制

即时聊天大多不需要 100% 可靠投递,但消息丢失对用户感知非常糟糕。HTTP 层天然有 response,WebSocket 没有,所以要在应用层做确认回执。服务端收到消息后立即回ack,客户端收到ack后从发送中列表移除该消息,超时未收到ack则自动重发(带相同的mid),服务端用ConcurrentHashMap缓存最近收到的mid做去重:

private static final Cache<String, Boolean> MID_CACHE = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(5)) .build(); public boolean isDuplicate(String mid) { return MID_CACHE.getIfPresent(mid) != null; }

Caffeine是本地缓存库,这里用它做滑动窗口去重,避免同一个mid被处理两次造成消息重复入库。5 分钟窗口大于重试退避上限,足以覆盖丢包场景。这里不需要引入消息队列,WebSocket 场景下客户端重发 + 服务端去重是最实用的组合。

5.3 onclose code 1006、H5 能连但 App 连不上等高频故障表

聊天系统上线后,线上反馈最多的几个问题高度集中,逐一列在下面:

现象大概率原因排查方向
onclose触发code: 1006, reason: ""对端没有正常关闭帧,可能是服务端异常退出、Nginx 掐断、网络中断查服务端日志有没有异常堆栈;lsof -i :8080看连接数;Nginx error log 有没有 timeout
H5 浏览器能连,打包成 App 后连不上Origin 校验冲突或证书问题确认setAllowedOriginPatterns是否放行file://或 webview 的 Origin;wss 证书是否匹配 App 内配置的域名
空闲一段时间后自动断开应用层心跳缺失或心跳间隔大于代理超时前端每 25-30 秒发 ping,服务端收到 ping 回 pong
failed to send websocket request: io发送途中连接被强制断开,客户端依然尝试写入打印客户端堆栈,发送前检查readyState;服务端关闭 session 前主动发送关闭帧
页面刷新后收不到消息旧 session 未清理,消息发到死连接afterConnectionClosed里必须从 Map 移除 session,并在关闭前关闭旧连接

1006 是排查成本最高的错误码,因为它没有 reason。本质是 TCP 层被 RST,服务端看在Tomcat的 access log 里只会看到非 101 响应或空记录,需要把WebSocketSession.isOpen()的检查提前到每次 send 之前,同时给 session 增加心跳检测,连续 N 个周期没有收到任何帧就主动close(CloseStatus.POLICY_VIOLATION),让客户端收到一个明确的关闭码,而不是一直悬着。

5.4 用 Python 脚本验证长连接稳定性

浏览器开发者工具适合人工验证,自动化压测或稳定性验证建议用 Python 脚本。假设环境中已执行pip install websocket-client,一段能持续并发保活并统计断线次数的脚本如下:

import json import threading import time import websocket class ChatClient: def __init__(self, name, url): self.name = name self.url = url self.ws = None self.connected = False self.reconnect_count = 0 def on_message(self, ws, message): # 收到 pong 不需要额外处理,连接活着就够了 pass def on_open(self, ws): self.connected = True ws.send(json.dumps({"type": "ping", "ts": int(time.time() * 1000)})) def on_close(self, ws, code, reason): self.connected = False self.reconnect_count += 1 print(f"{self.name} closed: {code} {reason}") def on_error(self, ws, error): self.connected = False print(f"{self.name} error: {error}") def run(self): self.ws = websocket.WebSocketApp( self.url, on_message=self.on_message, on_open=self.on_open, on_close=self.on_close, on_error=self.on_error, ) self.ws.run_forever(ping_interval=25, ping_timeout=10) def start(self): t = threading.Thread(target=self.run, daemon=True) t.start() if __name__ == "__main__": url = "ws://localhost:8080/ws/chat?token=demo" clients = [ChatClient(f"client-{i}", url) for i in range(10)] for c in clients: c.start() time.sleep(300) # 跑 5 分钟,观察输出里是否有异常关闭 print("done")

ping_interval=25ping_timeout=10WebSocketApp自带的心跳参数,会每 25 秒发 ping 并按 10 秒超时判定连接健康度。脚本输出里如果出现code: 1006或频繁的error,说明服务端或网络链路有问题,结合服务端日志的时间点比对,基本能定位到是 Nginx 超时还是应用代码抛异常导致连接被回收。

6. 落到生产前的一步:session 管理、版本兼容与验收技巧

6.1 用 userId 维度管理 session 的实践细节

第 4 章的双 Map 结构解决了多端在线的问题,但还有一个容易被忽略的细节:用户踢下线的场景。同一账号在另一台设备登录后,旧设备经常收到「账号在其他设备登录」的提示,实现方式是握手成功后把同 userId 的旧 session 全部关闭,让旧设备触发onclose,前端拿到code 4000后跳转登录页:

public void kickoutOldSessions(String userId, WebSocketSession currentSession) { List<WebSocketSession> sessions = ONLINE_USER_MAP.get(userId); if (sessions == null) { return; } for (WebSocketSession session : sessions) { if (!session.getId().equals(currentSession.getId())) { session.close(new CloseStatus(4000, "kickout")); } } }

关闭旧 session 必须用自定义关闭码40001000会被前端当作正常下线而执行重连逻辑,踢下线就失去了意义。前端onclose里只对1000不重连,其他关闭码统一走重连或提示流程。另一个细节是心跳超时判定放在服务端而不是前端:服务端维护lastReceiveTime,每 30 秒扫描一次,超过 60 秒没收到任何帧的连接主动关闭,避免死连接持续占着内存和文件描述符。

6.2 Spring Boot 3.x 的 Jakarta 命名空间与代码兼容

Spring Boot 2.x 项目升级到 3.x 后,WebSocket 相关代码最容易踩的坑是javax.websocket变成jakarta.websocketspring-boot-starter-websocket依赖里实际用的是org.springframework.web.socket包,这个包名在两个大版本间没有变化,受影响的只有依赖了 JSR-356 标准 API(javax.websocket.*)的代码。如果项目里的 WebSocket 实现是@ServerEndpoint注解风格,升级时会看到一堆Package javax.websocket does not exist的编译错误,需要批量把javax换成jakarta。用WebSocketHandler方式实现的项目不受影响。另外 Spring Boot 3.x 要求 JDK 17,项目里用到的javax.validation相关依赖也要一并调整到jakarta.validation

6.3 浏览器 DevTools 的 Frame 面板验证收发

联调阶段最实用的验证手段在浏览器开发者工具里。打开 Network 面板筛选 WS,点击连接记录后切到 Messages 页签,可以看到每一帧的发送方向和数据内容,支持过滤二进制帧与文本帧。验证顺序建议是:先确认握手请求的响应状态码是 101,再逐步触发登录、发消息、多端登录、断网重连四个场景;每次onmessage触发后,对照帧数据确认服务端返回的 JSON 字段与前端解析逻辑一致。断网模拟可以直接在 Console 执行window.dispatchEvent(new Event('offline')),观察心跳是否在恢复后正确重启。这一步过滤掉绝大多数字段名不匹配、大小写不一致、时间戳精度不符等联调问题。

所有帧走通之后,再回到SESSIONS的清理逻辑上做一轮复查:服务端重启、用户退出、token 过期三种场景下,Map 里是否残留失效 session。内存中的WebSocketSession对象不会因为客户端关闭而自动消失,清理逻辑不写在finally块里,早晚会内存泄漏。聊天的技术难点从来不在链路建立,而在连接的生命周期管理,把这一点抠干净,这个项目才算真正落地。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 19:02:52

Spring Boot 将配置绑定到第三方对象详解

Spring Boot 将配置绑定到第三方对象详解 在 Spring Boot 中&#xff0c;配置绑定通常用于将配置文件中的属性映射到我们自己的 Java 类&#xff08;Bean&#xff09;上&#xff0c;这可以通过 ConfigurationProperties 轻松实现。然而&#xff0c;在实际开发中&#xff0c;我们…

作者头像 李华
网站建设 2026/9/12 19:02:08

AI Agent生产环境容错设计:从错误分类到可观测性的完整指南

你有没有遇到过这种场面&#xff1a;本地demo里Agent跑得行云流水&#xff0c;一部署到生产环境就各种翻车——模型突然返回一段粘着JSON的废话&#xff0c;工具调用传错参数&#xff0c;第三方接口超时&#xff0c;上下文被截断……你盯着日志一脸懵&#xff0c;完全不知道从哪…

作者头像 李华
网站建设 2026/9/12 19:01:49

高性能计算在材料力学仿真中的优化与应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 18:55:34

二分查找进阶:两个正序数组的中位数与分割线解法解析

1. 题目解析与暴力解法的演进这道题在LeetCode热题100里算是一道分水岭。题目本身不难理解&#xff0c;给定两个正序数组nums1和nums2&#xff0c;要求找出并返回这两个数组的中位数&#xff0c;而且题目还点了一句“时间复杂度应该为O(log (mn))”。光是这句话&#xff0c;就把…

作者头像 李华
网站建设 2026/9/12 18:55:11

CKEditor解决Word粘贴图片上传问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 18:54:13

企微实战开发之记忆功能 - 排查构建包命令

目录 一、本地&#xff1a;确定构建包及Chunk哈希值最新 二、公网验证 2.1 确认浏览器加载包的信息 2.2 本地终端 2.3 CDN/代理层 忽略 no-cache 请求 2.3.1 作用&#xff1a;安全防御 2.3.2 解决“忽略请求头”的情况 2.3.3 总结 三、服务器&#xff1a;确认加载的包及…

作者头像 李华