ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring WebSocket实战:从原生API到STOMP的实时通信方案

Spring WebSocket实战:从原生API到STOMP的实时通信方案 先说个真实场景。前阵子接手一个后台管理系统提了一个很普通的需求管理员在后台审核流程的时候用户端要实时看到审核进度不用刷新页面。第一反应是轮询但轮询的体验有多糟糕写过的人都懂——要么延迟高要么请求多到把服务端打崩。后来又想过SSE但浏览器兼容和连接模型又有限制。最后选的就是Spring全家桶里的WebSocket模块配合Spring Boot一把梭。这一篇就是Spring开发系列教程的第27篇专门聊WebSocket。这一篇不会只贴代码就算完我会把选型思路、握手细节、心跳检测、鉴权方式、集群广播这些真正会在生产环境踩到的坑全部摊开讲清楚适合已经有Spring基础、想给项目加实时通信能力的开发者也适合那些被“轮询还是WebSocket”纠结了几天的人。1. 先搞清楚Spring做WebSocket到底有哪几种姿势1.1 原生WebSocket API和Spring封装差在哪很多人一提到WebSocket下意识就想到浏览器里的new WebSocket(url)觉得后端不也照着协议撸一个Server就行了吗说实话如果不用Spring你完全可以基于Java的javax.websocket标准去写Tomcat、Jetty都支持加ServerEndpoint注解就能跑起来一个WebSocket服务端。这套方式在纯Java Web项目里没有任何问题简单直接。但一旦进了Spring生态事情就没这么简单了。因为你不仅要处理WebSocket的握手和消息收发还要考虑和Spring MVC共用一套上下文、把Spring容器里的Service注入到WebSocket处理器里、在握手阶段做登录校验、在业务代码里主动向指定用户推送消息。如果用原生javax.websocket这些东西全都要自己拼装尤其是Bean注入Spring管理下的Session和原生WebSocket的Session生命周期差异会让你踩很多暗坑。Spring提供的方案是在底层协议之上加了一层封装核心是WebSocketHandler接口和HandshakeInterceptor。你把处理器写成Spring Bean它就能拿到容器里的其他Bean你把拦截器配好它就能在握手时介入HTTP请求。这层封装不会改变WebSocket协议本身但让你从“用Java写网络协议”变成“用Spring写业务逻辑”开发体验完全是两个级别。1.2 STOMP子协议到底是不是必需品聊到Spring WebSocket就必须绕开STOMP这个词。很多教程一上来就铺STOMP搞得好像不用STOMP就做不了WebSocket一样。实际上STOMP是构建在WebSocket之上的一个消息子协议它不是必需品。我打个比方。WebSocket是一条全双工的管道好比一条双向通车的马路。你可以不遵守任何交通规则想怎么跑就怎么跑这就是原生WebSocket。但业务一旦复杂起来比如要对某个用户单独推消息、要支持订阅某个频道、要处理超时心跳那这条马路上就需要一套明确的规则。STOMP就是一套前后端都认的“交通规则”让消息不只是“收到”和“发送”而是有了“目的地”、“订阅”、“回执”这些语义。如果你做的功能只是后台页面上的实时告警、单机环境下的聊天室、一对一的简单推送原生WebSocket完全够用也是这一篇我会重点拆的部分。但如果你要做多频道订阅、要做点对点推送、要和Spring Security的鉴权体系深度融合那STOMP值得认真学。这一篇两种方案都会讲但原生API我会讲得更细因为那是理解WebSocket的根基STOMP只是在这根基上的更高层抽象。1.3 选型参考表这里直接给一张选型对照表是我实际项目里常用的判断标准你可以直接对着这张表选。业务场景推荐方案理由单机应用后端主动推送告警/通知原生WebSocketHandler实现简单依赖少可控性强需要按用户定向推送且要订阅多个频道STOMP Spring Messaging自带点对点和广播语义省去自己设计消息格式集群部署多实例需要消息互通WebSocket Redis Pub/Sub或MQWebSocket本身是连接级协议跨节点要借助中间件需要心跳保活、自动重连原生方案或STOMP都行关键在心跳设计心跳是协议之上的机制和选型关系不大和Spring Security强鉴权绑定STOMP更优雅原生也能做核心是握手阶段拿到认证信息选型不用纠结太久。我自己的经验是如果项目里已经引入了Spring Messaging相关模块直接STOMP如果只是临时加一个实时推送小功能原生API绝对最省事。不要动不动就上重型抽象后面维护成本真的不低。2. 搭一套最小可跑的WebSocket推送服务原生API方案2.1 核心依赖与配置类Spring Boot项目加WebSocket支持依赖非常简单只需要一个spring-boot-starter-websocket。这个starter会把Spring WebSocket模块和内置的Tomcat WebSocket支持都带进来。如果你用的是Spring Boot 2.7以上的版本还要注意一点WebSocket相关的配置类不再推荐实现WebSocketConfigurer的EnableWebSocket但实际开发中EnableWebSocket依然能用官方文档也承认这是最基础的入口。配置类长这样Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final MyWebSocketHandler myWebSocketHandler; private final AuthHandshakeInterceptor authHandshakeInterceptor; public WebSocketConfig(MyWebSocketHandler myWebSocketHandler, AuthHandshakeInterceptor authHandshakeInterceptor) { this.myWebSocketHandler myWebSocketHandler; this.authHandshakeInterceptor authHandshakeInterceptor; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myWebSocketHandler, /ws/notify) .addInterceptors(authHandshakeInterceptor) .setAllowedOrigins(*); } }这里面有几处细节值得说道说道。第一addHandler的路径/ws/notify就是前端连接WebSocket时用的地址但要注意WebSocket握手本质上是一次HTTP GET请求升级协议的过程所以这个路径在Spring MVC里不需要再写一个Controller去接收。Spring的WebSocketHandlerMapping会自动接管这个路径的握手请求这背后就是WebSocketHttpRequestHandler在处理。第二addInterceptors这一步其实很容易被忽略。很多人把WebSocket写通了之后就发现一个问题连接都建立起来了但我怎么知道这个连接是哪个用户答案是握手拦截器。前端在建立连接的时候其实是在发一个HTTP请求这个请求是可以带HttpSession、带Token的。拦截器的作用就是在这个HTTP请求转成WebSocket连接之前把用户信息保存下来后面处理器里才能拿到。第三setAllowedOrigins(*)在本地测试没问题但生产环境建议配成具体的域名。这个配置的作用是允许哪些域名发起WebSocket连接防止跨域攻击。WebSocket协议本身不受同源策略限制所以这个白名单就是你唯一的一道门槛不能全放通。依赖配置dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency2.2 处理器实现与Session管理核心处理器要实现WebSocketHandler接口。这个接口看起来方法不多真正会用到的其实就afterConnectionEstablished连接建立、handleMessage收到消息、handleTransportError异常、afterConnectionClosed连接关闭这四个。我先给一个完整的处理器骨架再把Session管理这个最关键的环节单独拆开讲。Component public class MyWebSocketHandler extends TextWebSocketHandler { private static final MapString, WebSocketSession SESSION_POOL new ConcurrentHashMap(); Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId (String) session.getAttributes().get(userId); SESSION_POOL.put(userId, session); // 可以在这里做上线通知比如推一条系统消息给这个用户 } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload message.getPayload(); // 业务处理这里一般是接收前端的心跳包或指令 // 比如前端发 {type:PING}后端回 {type:PONG} } Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { // 捕获异常避免连接异常导致系统日志刷屏 } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId (String) session.getAttributes().get(userId); SESSION_POOL.remove(userId); } public void sendToUser(String userId, String message) { WebSocketSession session SESSION_POOL.get(userId); if (session ! null session.isOpen()) { synchronized (session) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { // 发送失败的处理 } } } } public void broadcast(String message) { SESSION_POOL.values().forEach(session - { if (session.isOpen()) { synchronized (session) { try { session.sendMessage(new TextMessage(message)); } catch (IOException e) { // 忽略单个失败 } } } }); } }这里要重点说三个点。第一个是Session的线程安全问题。WebSocketSession.sendMessage并不是完全线程安全的尤其是在高并发下多个业务线程同时对同一个Session发消息会偶发异常。所以我在发消息的地方加了synchronized (session)这是比较稳妥的做法。很多刚接触WebSocket的同学在这里翻车自测的时候没问题一上生产并发一高就各种莫名其妙的报错。第二个是Session的地图管理。我用的是ConcurrentHashMapkey是userIdvalue是Session。为什么用userId做key而不是用sessionId因为业务推送的时候你手里的业务查询条件通常就是userId你不可能在推送前先遍历一遍所有Session筛选哪个是目标用户。以userId为key推送逻辑就变成了一次时间复杂度O(1)的查找。但要注意同一用户多端登录时后面的连接会覆盖前面的连接这个行为是否符合业务预期得提前想好。第三个是连接状态检查。在sendToUser里我先判断session ! null session.isOpen()因为连接可能随时断掉而SESSION_POOL里的缓存不一定会及时清理。这些判断看着啰嗦但能避免大量的空指针和发送异常。2.3 握手拦截器把用户身份灌进连接里写WebSocket服务端绕不开的一个问题就是连接建立之后服务端怎么知道这是谁WebSocket协议规范里没有提供主动带身份信息的机制所以必须在握手阶段做手脚。前面提到了我要写拦截器现在把实现拆开。握手拦截器要实现HandshakeInterceptor接口两个方法beforeHandshake和afterHandshake。Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) throws Exception { // 方案一从请求头拿Token String token request.getHeaders().getFirst(Authorization); // 方案二从Url查询参数拿token适合前端WebSocket对象不方便自定义Header的场景 String tokenFromParam ((ServletServerHttpRequest) request).getServletRequest().getParameter(token); // 解析出userId这里省略JWT解析细节 String userId parseUserIdFromToken(token ! null ? token : tokenFromParam); if (userId null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } // 把userId放进attributes这个map后续会传到WebSocketSession的attributes里 attributes.put(userId, userId); return true; } Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手成功后的回调一般用不到可以留空 } }这个拦截器里有三个细节是实战后才真正理解的。第一attributes这个参数非常重要。你在beforeHandshake里放进这个map里的键值对后面在afterConnectionEstablished里通过session.getAttributes()拿到。这是连接生命周期内传递用户信息的唯一标准通道不要试图在处理器里自己塞一个静态变量去存用户身份那会在并发下乱套。第二前端创建WebSocket连接时如果想带自定义Header会卡在浏览器API的限制上。因为浏览器原生WebSocket对象不支持自定义Header你只能在URL上拼查询参数。很多前端同学和我抱怨过这一点。所以我在代码里支持了从请求头和URL参数两个位置取Token就是为了兼容两拨不同的前端。第三握手失败时可以直接返回false让连接建立不了同时往response里写一个401状态码。这个逻辑对于前端来说非常直观连接没建立成功就知道是登录过期了。当初我在这块吃过亏拦截器里没有对Token做校验结果任何客户端都能连上来等于实名认证的门口没查身份证。3. 生产环境不能少的心跳机制设计与断线重连3.1 为什么会断线代理空闲超时是隐形杀手WebSocket基于TCP长连接很多人以为连接一旦建立就是永久的。现实根本不是这样。中间的任何一层代理Nginx、云负载均衡、运营商网关、甚至你本机路由器的NAT都会对空闲连接做回收处理。最常见的是Nginx的proxy_read_timeout默认值是60秒。这个配置的含义是如果60秒内Nginx和后端服务之间没有任何数据往来Nginx就会主动切断这条连接。WebSocket建立之后如果客户端不主动发消息服务端也不推消息这条连接在代理眼里就是一条“空闲TCP连接”到点就会被回收。浏览器端的表现就是WebSocket的onclose事件被触发而且错误码往往是1006这是“非正常关闭”的意思因为代理掐断连接时根本不走WebSocket的关闭流程。所以我的结论是心跳机制不是可选项而是必选项。你要让这条连接一直有“动静”就算没有实际业务数据也要有协议层的ping/pong帧或应用层的心跳包在流动。这就是常说的“保活”。3.2 前端心跳与自动重连实现前端这一侧的心跳逻辑本质上要解决两件事定时发心跳包给服务端以及在发现连接断开后自动重连。我直接给一个常用的前端片段基于浏览器原生WebSocket。class ReconnectWebSocket { constructor(url, token) { this.url url ?token token; this.heartBeatInterval 30000; // 30秒发一次心跳 this.reconnectAttempts 0; this.maxReconnectAttempts 10; this.ws null; this.connect(); } connect() { this.ws new WebSocket(this.url); this.ws.onopen () { this.reconnectAttempts 0; // 启动心跳定时器 this.heartBeatTimer setInterval(() { this.ws.send(JSON.stringify({ type: PING })); }, this.heartBeatInterval); }; this.ws.onmessage (event) { const message JSON.parse(event.data); if (message.type PONG) { // 收到心跳响应连接是健康的 return; } // 这里处理真正的业务推送 }; this.ws.onclose () { clearInterval(this.heartBeatTimer); if (this.reconnectAttempts this.maxReconnectAttempts) { setTimeout(() { this.reconnectAttempts; this.connect(); }, 3000 * this.reconnectAttempts); } }; this.ws.onerror (error) { // 不用在这里重连onclose会触发 console.error(WebSocket error, error); }; } sendMessage(message) { if (this.ws this.ws.readyState WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } }这个片段里有几个值得展开的细节。心跳间隔30秒是经验值配合后端Nginx的proxy_read_timeout设置为60秒以上就能跑得稳。如果中间还有云负载均衡比如阿里云的SLB默认空闲超时可能是900秒那30秒的心跳完全够用甚至可以把间隔放宽到60秒减少无效消息的流量消耗。重连策略我这里用的是递增间隔第一次断线3秒后重连第二次6秒第三次9秒最多重试10次。递增间隔的好处是避免极端情况下多个客户端同时重连造成服务端压力陡增也就是常说的“重连风暴”。如果项目并发量大重连的间隔应该加上随机抖动比如3秒到5秒之间的随机数进一步分散压力。3.3 后端心跳检测超过多久没消息就判定断线前端有心跳后端也不能只傻等。后端需要在一段时间内没收到任何消息时主动关闭连接释放资源。否则会出现一种很恶心的局面前端已经断网了但服务端并不知道Session还躺在内存里推送消息一直发不出去白白占用连接。Spring原生WebSocket方案里最优雅的方式是利用Spring的WebSocketHandlerDecorator配合java.util.concurrent.ScheduledExecutorService实现空闲检测。这里我用一个定时任务扫描所有Session的最后活跃时间。Component public class WebSocketHeartbeatChecker { private final MapString, WebSocketSession sessionPool; private final ScheduledExecutorService scheduler Executors.newScheduledThreadPool(1); public WebSocketHeartbeatChecker(MapString, WebSocketSession sessionPool) { this.sessionPool sessionPool; // 每10秒检查一次超过60秒没消息就关闭 scheduler.scheduleAtFixedRate(this::check, 10, 10, TimeUnit.SECONDS); } private void check() { long now System.currentTimeMillis(); sessionPool.forEach((userId, session) - { long lastActiveTime (long) session.getAttributes().getOrDefault(lastActiveTime, now); if (now - lastActiveTime 60000) { try { session.close(CloseStatus.SESSION_NOT_FOUND); } catch (IOException e) { // 关闭失败也做移除处理 } sessionPool.remove(userId); } }); } }这里有一个单独需要兜住的点在handleTextMessage或者afterConnectionEstablished里必须更新lastActiveTime这个属性不然所有连接都会被当成空闲连接关闭。我在实际项目中见过一个模式很常见的错误开发只写了心跳回复逻辑忘了更新最后活跃时间结果服务端每过一分钟就把所有正常连接都关一遍。这个问题排查的时候非常隐蔽因为单看日志每一个连接关闭都像是被正常清理了。4. 场景升级用STOMP Spring Messaging做更规范的消息服务4.1 为什么需要从原生方案切换到STOMP当你的实时推送从“给指定用户推一条消息”升级到“支持多个频道订阅、支持按用户和按群组定向推送、支持回执确认”这一类复杂场景时原生WebSocket方案的短板就暴露了。原生方案的问题在于消息体完全没有结构。服务端往连接里写什么前端就收到什么至于这条消息是给哪一个频道的、是广播给全体还是只给某一个人、要不要回执全都要自己在协议层之上再设计一套格式。业务一多这套自研格式就越来越失控最终你会发现我们自己在重新发明一个简陋版的STOMP协议。STOMP全称Simple Text Oriented Messaging Protocol它定义了一套非常简单的文本消息格式比如SEND、SUBSCRIBE、UNSUBSCRIBE这样的命令帧再加上目标地址的概念。Spring把STOMP做成了spring-messaging模块的一部分用起来非常顺滑。4.2 使用EnableWebSocketMessageBroker搭建消息代理先看配置类。Configuration EnableWebSocketMessageBroker public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客户端订阅前缀客户端只能订阅 /topic 和 /queue 开头的地址 registry.enableSimpleBroker(/topic, /queue); // 客户端发送消息的前缀客户端发消息时要带 /app 前缀 registry.setApplicationDestinationPrefixes(/app); // 点对点推送的前缀服务端推给指定用户时实际地址是 /user/{userId}/queue/xxx registry.setUserDestinationPrefix(/user); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { // STOMP端点前端穿 socks.js 连这个地址 registry.addEndpoint(/ws-stomp) .setAllowedOrigins(*) .withSockJS(); } }这里要特别留意enableSimpleBroker和withSockJS()两处。enableSimpleBroker(/topic, /queue)的意思是让Spring内置一个轻量级消息代理处理以/topic开头的广播订阅和以/queue开头的点对点订阅。如果你的服务是简单的单机部署这个内置代理完全够用。但如果你上了集群每个Spring实例各自维护自己的SimpleBroker那不同实例上的用户就互相收不到消息这时候就要换enableStompBrokerRelay把消息转发给RabbitMQ等外部消息中间件。withSockJS()则是加了一层SockJS兜底。SockJS会在浏览器不支持WebSocket时降级到HTTP轮询等方式实现无缝兼容。但要冷静看待这个降级如果生产环境明确所有用户都是现代浏览器SockJS反而会增加消息体里的无用信息。配置完之后写STOMP的消息处理风格和写Spring MVC的Controller很接近。Controller public class NotificationController { private final SimpMessagingTemplate messagingTemplate; public NotificationController(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate messagingTemplate; } // 前端发过来的消息会带 /app 前缀 MessageMapping(/notify) public void handleNotify(String message, Principal principal) { // principal 是Spring Security认证通过后自动注入的用户信息 // 处理业务逻辑然后推送 messagingTemplate.convertAndSendToUser( principal.getName(), /queue/notify, 你有新的审核通知 ); } }这段代码里convertAndSendToUser是这个方案的标志性优势原生WebSocket里的sendToUser是自己在内存Map里找SessionSTOMP模式则是通过SimpMessagingTemplate把消息交给消息代理代理再根据用户订阅关系转投递。用户是否在线、在哪个节点上连接全都由框架帮你处理了。4.3 Principal在STOMP中如何自动注入很多人好奇Principal从哪来的。答案是STOMP握手阶段会走和原生WebSocket一样的握手流程但Spring提供了一套更好的机制来处理用户身份。如果项目里用了Spring Security并且配置了登录认证那么当用户通过登录后建立WebSocket连接时握手阶段会从HttpSession里恢复认证信息Principal就会被自动放入连接上下文。后面MessageMapping方法里加一个Principal参数Spring就会把当前连接用户的身份塞进去。这个方案的优雅之处在于你不需要像原生方案那样session.getAttributes().get(userId)再手动判断直接就是标准化的用户抽象。不过要补充一个坑如果没有使用Spring SecurityPrincipal就是nullMessageMapping方法里拿不到认证信息。此时你依然需要在握手阶段自己解析Token然后塞进attributes这就绕回到了我们在原生方案里讲的拦截器逻辑。STOMP方案只是说“如果你有Spring Security会方便很多”并不是说它替你解决了所有的身份识别问题。5. 实战中一定会踩的坑鉴权、集群广播与排查速查5.1 连接鉴权服务端如何确认连接是本人鉴权是WebSocket开发和调试中最容易被拖延的一个环节但你在生产放开连接之后必然出事故。这里说的鉴权不是“连接之后判断用户是不是登录了”而是“在握手时就要确认用户是登录状态没登录的直接拒绝连接”。Spring Security WebSocket的最佳实践是借助ChannelInterceptor在消息进入的时候做拦截配合Spring Security的WebSocketMessageBrokerConfigurer里的configureClientInboundChannel。实现起来是这样的Configuration EnableWebSocketMessageBroker public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer { Override public void configureClientInboundChannel(ChannelRegistration registration) { registration.interceptors(new ChannelInterceptor() { Override public Message? preSend(Message? message, MessageChannel channel) { StompHeaderAccessor accessor StompHeaderAccessor.wrap(message); // 从Header里取出令牌校验失败抛异常 String token accessor.getFirstNativeHeader(token); if (!validateToken(token)) { throw new IllegalStateException(未认证的用户); } return message; } }); } }这里说明一下为什么不用我们自己写的那些握手拦截器做鉴权。因为当客户端第一次建立STOMP连接时真正携带Token的时机有两个层次一是HTTP握手阶段二是STOMP的CONNECT帧。很多前端的sockjs库会把Token放在CONNECT帧的Header里而不是HTTP请求头里所以只在握手阶段拦截是拿不到这部分Token的。ChannelInterceptor拦截的是所有进入服务端消息通道的处理你可以在这里统一校验相当于给所有消息加了一道过滤。用户只要不是登录状态在发送任何消息时都会被拒绝连接虽在但功能完全不可用。如果连“连接建立”这件事都要前置鉴权那么在注册Stomp端点时加上setHandshakeHandler自定义一个DefaultHandshakeHandler在determineUser里返回你的认证用户。这是另外一个层次的做法两种可以叠加。5.2 集群部署时消息如何广播到所有实例这一步其实是WebSocket落地时最大的拦路虎。单机环境下连接都在一台机器上你的推送逻辑不管怎么实现都能找到目标Session。但上了集群用户连的是实例A管理员操作落在实例B上实例B往自己的Session池里查用户发现查不到推送就丢了。解决思路很清晰把“谁在哪个实例上连接”这件事交给共享存储来记录把“需要广播的消息”交给消息中间件来分发。我见过两种主流的落地方案。方案一是Redis Pub/Sub。把所有实例订阅同一个Channel某一实例需要推送时先往这个Channel发一条包含userId和message的消息或者直接发全量广播消息所有实例都会收到这条消息然后各查各的Session池发现用户在自己节点上就真正推送出去。这个方案的好处是不用改动业务消息模型坏处是广播量一大Redis和网络会有额外负载。方案二是接入MQ比如RabbitMQ的Topic Exchange让每个Spring实例绑定一个自己的队列消息按userId的hash路由到对应实例。这种模式的选路更精准不会像方案一那样所有实例都收到所有消息。实际项目里怎么选我倾向于一个判断标准如果只是推送通知、公告这类全量消息Redis Pub/Sub就够了简单出问题也好排查如果是大量点对点消息希望尽量不浪费资源就上MQ。以Redis Pub/Sub为例核心是在Spring里注入RedisTemplate然后定义消息监听器Component public class RedisMessageSubscriber implements MessageListener { private final NotificationService notificationService; public RedisMessageSubscriber(NotificationService notificationService) { this.notificationService notificationService; } Override public void onMessage(Message message, byte[] pattern) { String body new String(message.getBody(), StandardCharsets.UTF_8); // 解析出userId和消息内容 notificationService.pushToLocalUser(userId, content); } }然后在配置里注册监听容器Configuration public class RedisListenerConfig { Bean public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, RedisMessageSubscriber subscriber) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(subscriber, new PatternTopic(ws-notify)); return container; } }这样一来你在任何一台实例上调用推送接口都会先发到Redis频道所有实例收到后再各推各的用户。整体思路不复杂但一定要先在本地多实例场景压一遍再上生产不然很容易出现“消息发了两遍”或者“部分用户收不到”这种隐蔽问题。5.3 Spring Security集成时的CORS与握手穿透问题Spring Security不少默认配置对WebSocket是不友好的。最常见的是Spring Security默认会拦截所有请求包括WebSocket握手时的HTTP Upgrade请求。而且CSRF防护对WebSocket握手也会起作用。如果你在Spring Security里开了CSRF默认情况下WebSocket握手会被拒绝因为握手请求没有带CSRF Token。处理方式是在WebSocket端点上放行CSRF。这也是我踩过的坑本地没加Security一切正常一联调安全模块配置齐全的环境WebSocket就疯狂报403排了半天才发现是CSRF。配置大致是这样Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http.csrf(csrf - csrf.ignoringAntMatchers(/ws-stomp/**)) .authorizeRequests() .antMatchers(/ws-stomp/**).permitAll() .anyRequest().authenticated(); } }关于CORSSpring Security的CORS配置和WebSocket的setAllowedOrigins是两套体系。握手阶段如果走的是Spring Security过滤器链那么CORS的预检请求可能会先于握手被拦截。稳妥的做法是把WebSocket端点路径加入permitAll同时在前端代码里不要故意发跨域请求去试探本地联调时用同一个域名和端口。5.4 常见问题排查速查表我把这些年实际遇到的频率最高的问题整理成了一张表后面排查基本按这个思路走。现象可能原因排查方向握手一直403CSRF拦截、Spring Security未放行WebSocket路径检查SecurityConfig放行规则看服务端日志具体哪个过滤器拒绝连接建立后几十秒自动断开代理空闲超时没有心跳先看服务端有没有收到前端的心跳包再看Nginx的proxy_read_timeout后端主动推送时Session为null用户离线或Session被清理检查SESSION_POOL的移除逻辑确认是断线清理还是逻辑遗漏消息能看到但推送不出去WebSocketSession被多个线程同时发送发送时加synchronized或改用ConcurrentWebSocketSessionDecorator集群环境部分用户收不到广播消息投递依赖本机Session池思考是否需要引入Redis Pub/Sub或MQ广播连接正常但PING不回PONG后端没有处理心跳消息的逻辑检查handleTextMessage里的消息类型分发STOMP连接时Principal为null未配置Spring Security或握手阶段未恢复认证信息检查身份注入方式自定义HandshakeHandler或ChannelInterceptor这张表的价值在于WebSocket出了问题之后九成以上都不是“协议实现错了”而是周边环节没配合好。尤其是代理超时、安全拦截、线程并发这三个方向几乎覆盖了我在生产环境遇到的所有疑难杂症。6. 关于这套方案的几点个人体会最后聊点框架之外的感想。WebSocket本身不是什么新东西协议规范多年没变过但在Spring生态里把它用好的关键不在于背熟那几个类名而在于对整个连接生命周期的理解。我见过太多半路出家的开发者照抄一个WebSocketHandler就跑起来然后线上隔三岔五掉线最后把锅甩给WebSocket协议不稳定。实际上协议很稳定不稳定的是缺少心跳、缺少重连、缺少Session清理的周边工程。真正把WebSocket上线当回事你至少要做到三件事。第一连接鉴权不能偷懒别想着连上再做校验攻击者不会按你的套路出牌。第二心跳和自动重连是配套的前端重连逻辑和后端空闲检测缺一不可否则你的推送服务就和“薛定谔的在线状态”一样时灵时不灵。第三集群部署必须先想好消息广播方案单机能跑只是第一步线上服务几乎不可能只部署一个实例。就我个人经验而言从原生WebSocket方案起步把Session管理、拦截器、心跳这三板斧练熟再往STOMP方向扩展这个学习路径最稳。直接一上来就折腾STOMP的人往往连最基础的WebSocket连接生命周期都没搞清楚出了问题都不知道从哪一层开始查。这一篇把Spring WebSocket从原生API到STOMP从单机到集群从连接到心跳做了一个比较完整的梳理。代码都是可以直接搬进项目用的配置里也标注了容易踩坑的地方。如果你正在给项目加实时推送功能照着至少能少走两天弯路。后面有具体问题欢迎一起聊。
返回列表