做后端的朋友大概都有过这样的经历:项目里要加一个实时消息推送,第一反应是上轮询,结果服务器压力一上来就顶不住;换成 WebSocket 之后,连接是通了,可一到鉴权环节就卡壳——HTTP 那套 Session、Token 的玩法到了长连接这儿好像突然失灵了。我最近在几个 SpringBoot3 项目里反复折腾这块,把 WebSocket 和拦截器结合起来做登录验证,踩了不少坑,也总结出一套比较稳的落地方式。这篇就把整个思路从原理到代码完整拆一遍,重点讲清楚拦截器为什么能拦、怎么拦、拦在哪个环节,以及那些文档里不会写的细节。不管你是刚接触 WebSocket 的新手,还是想把手上的实时功能做扎实的老手,应该都能从里面抄到能直接用的东西。
1. 整体方案设计与技术选型思路
1.1 为什么是 WebSocket 而不是轮询
先把这个前提说清楚,因为选型错了后面全是白费功夫。实时消息推送这个需求,最朴素的实现是前端定时发 HTTP 请求问服务器“有没有新消息”,也就是轮询。轮询的问题不在于能不能用,而在于成本:假设 1000 个在线用户,每 3 秒轮询一次,那就是每秒 300 多次请求,其中绝大多数是空响应。连接建立、请求头解析、线程调度这些开销全白花了,服务器 CPU 和带宽都被无效请求吃掉。
WebSocket 走的是另一条路:一次 HTTP 握手升级协议,之后这条 TCP 连接就一直保持着,双方随时可以主动发数据。服务端有消息就直接推,没消息就安静待着,没有无效请求。对于聊天、通知、协同编辑、实时看板这类场景,这是质的差别。SpringBoot3 对 WebSocket 的支持相当成熟,spring-boot-starter-websocket引进来就能用,底层基于 Spring 的WebSocketHandler体系,配置量很小。
但这里有个容易被忽略的点:WebSocket 的握手阶段本质上还是一次 HTTP 请求(带Upgrade: websocket头)。这意味着握手时可以拿到 HTTP 的请求信息——Cookie、Header、URL 参数全都在。这个特性正是我们做登录验证的抓手,后面拦截器的方案就是围绕它展开的。
1.2 拦截器方案的核心逻辑
很多人第一次做 WebSocket 鉴权,会想着在WebSocketHandler的afterConnectionEstablished方法里判断用户登录状态,没登录就session.close()。这个做法能用,但有两个毛病:一是连接已经建立了才去关,等于白白消耗了一次握手资源;二是关闭动作是异步的,前端可能已经收到“连接成功”的回调,体验很别扭。
更干净的做法是在握手阶段就把不合格的连接挡在门外。Spring 的 WebSocket 配置里可以注册HandshakeInterceptor,它在协议升级之前执行,能拿到ServerHttpRequest和ServerHttpResponse,返回false就直接拒绝握手,连接根本建立不起来。这跟 SpringMVC 里拦截器的思路一脉相承,只不过作用在握手这个特定环节。
我选这套方案的理由很直接:拦截时机最早、拒绝成本最低、和现有登录体系(Session 或 Token)对接自然。下面这张表把几种常见鉴权位置的差异摆出来,方便你判断自己的场景该用哪种。
| 鉴权位置 | 执行时机 | 拒绝成本 | 能否拿到 HTTP 请求信息 | 适用场景 |
|---|---|---|---|---|
| HandshakeInterceptor | 握手前 | 最低,连接不建立 | 能,完整请求对象 | 绝大多数登录校验场景 |
| WebSocketHandler 内判断 | 连接建立后 | 较高,需主动关闭 | 能,通过 attributes 传递 | 需要连接后再做二次校验 |
| 消息级校验 | 每条消息到达时 | 最高,逐条判断 | 否,只有消息体 | 细粒度权限控制 |
从表里能看出来,握手拦截是性价比最高的位置。实际项目里我通常还会叠加一层消息级校验做兜底,但登录验证这个主关口放在握手拦截器上,逻辑最清晰。
1.3 SpringBoot3 带来的变化
得提醒一句,SpringBoot3 基于 Spring Framework 6 和 Jakarta EE 9+,包名从javax.*全面换成了jakarta.*。WebSocket 相关的注解和接口本身变化不大,但如果你从 SpringBoot2 的项目里拷贝代码过来,javax.websocket那套东西会直接编译不过,得换成jakarta.websocket。另外 SpringBoot3 要求 JDK 17 起步,这个门槛在选型时就要确认好,别代码写完了发现环境不匹配。
还有一点,SpringBoot3 里WebSocketConfigurer的注册方式和以前基本一致,registerWebSocketHandlers方法里调addHandler和addInterceptors,顺序上拦截器要在 handler 之后加,这个顺序不能反,否则拦截器不生效。这个细节后面实操部分会再强调。
2. 核心组件拆解与关键细节
2.1 握手拦截器的三个关键方法
HandshakeInterceptor接口只有两个必须实现的方法,但真正理解它们各自能干什么,是写好鉴权逻辑的前提。
beforeHandshake是主战场,签名大致是接收请求、响应、handler 和一个 attributes 映射,返回布尔值。返回true放行,握手继续;返回false中断,连接不会建立。这里就是做登录判断的地方——从请求里取 Session 或 Token,校验通过就往 attributes 里塞用户信息,不通过直接返回 false。attributes 这个 Map 很关键,它里面的数据会传递到后续的WebSocketSession的 attributes 里,等于给这条连接打上了用户标签,后面收发消息时随时能取出来用。
afterHandshake在握手完成后回调,参数里带异常对象。这个方法一般用来做日志记录或者资源清理,鉴权逻辑不要放这儿,因为此时连接已经建立了,拒绝已经来不及。我见过有人把校验写在 afterHandshake 里然后困惑为什么没登录也能连上,就是没搞清楚执行顺序。
注意:
beforeHandshake里往 attributes 塞数据时,key 建议用常量定义,别到处写字符串字面量。后面 handler 里取值时一旦拼错,取出来是 null,排查起来很费劲。
2.2 登录凭证的两种取法
登录验证的核心是“凭证从哪来”。Web 项目里常见两种:基于 Session 的和基于 Token 的。拦截器里要针对性地处理。
Session 方式下,握手请求本身带着 Cookie,服务端能通过request.getServletRequest()拿到原始的HttpServletRequest,进而调getSession(false)取会话。注意这里一定要用false参数,表示“没有就别新建”。如果用了无参的getSession(),它会顺手创建一个新会话,那你的登录判断永远为真,等于没校验。这个坑我踩过,当时排查了半天才发现是会话被悄悄创建了。
Token 方式下,凭证通常在请求头(比如Authorization)或者 URL 查询参数里。WebSocket 有个现实约束:浏览器原生的WebSocket构造函数不支持自定义请求头,所以很多前端会把 token 拼在连接 URL 上,形如ws://host/ws?token=xxx。拦截器里从request.getURI().getQuery()解析即可。用 URL 传 token 有暴露在日志里的风险,生产环境建议配合短时效 token 使用,或者改用支持子协议头传递的方式。
// Session 方式取登录用户(示意) HttpSession session = ((ServletServerHttpRequest) request) .getServletRequest().getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("loginUser", session.getAttribute("loginUser")); return true;上面这段是 Session 判断的骨架,核心就是“取不到会话或取不到用户就拒绝”。Token 方式的差别只在于凭证来源,校验完同样往 attributes 里放用户标识。
2.3 用户信息如何贯穿整条连接
拦截器校验通过后,用户信息通过 attributes 传进WebSocketSession。这里有个容易混淆的点:HandshakeInterceptor的 attributes 和WebSocketSession的 attributes 是两套东西,前者在握手阶段用,后者在连接生命周期内用,Spring 会在握手成功时把前者的内容复制到后者。所以你在 handler 的handleMessage里通过session.getAttributes().get("loginUser")就能拿到当初塞进去的用户。
这个机制的价值在于:一条连接从建立到关闭,用户身份始终跟着走,不需要每条消息都重新校验。发消息时想给特定用户推送,也是靠这个标识去路由。我一般会在连接建立时把session.getId()和用户标识做个映射存起来,推送时按用户找 session,连接关闭时清理映射,这套组合拳是实时推送的标配。
2.4 消息处理器与连接生命周期的配合
TextWebSocketHandler是我们最常继承的基类,几个回调方法对应连接的不同阶段。afterConnectionEstablished在连接建立后触发,适合做上线登记;handleTextMessage处理收到的文本消息;afterConnectionClosed在断开时触发,用来清理资源。鉴权虽然不在这几个方法里做,但用户信息的消费全在这儿。
有个细节值得说:afterConnectionClosed里拿到的session.getAttributes()依然有效,所以能根据用户标识做精准的离线清理。但如果你的映射表是用ConcurrentHashMap存的,记得判空再移除,避免并发场景下误删。连接异常断开(比如网络抖动)也会走这个方法,所以清理逻辑要写得足够健壮,不能假设一定是正常关闭。
3. 完整实操流程与代码落地
3.1 依赖引入与基础配置
先搭骨架。pom.xml里引入 WebSocket starter,SpringBoot3 环境下版本由父工程管理,不用手动指定。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>然后写配置类,实现WebSocketConfigurer,用@EnableWebSocket开启支持。核心是registerWebSocketHandlers方法,先addHandler注册处理器并指定路径,再addInterceptors挂上拦截器。顺序问题前面提过,这里再强调一次:拦截器必须加在 handler 之后,否则不生效。
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final AuthHandshakeInterceptor authInterceptor; private final MyWebSocketHandler handler; public WebSocketConfig(AuthHandshakeInterceptor authInterceptor, MyWebSocketHandler handler) { this.authInterceptor = authInterceptor; this.handler = handler; } @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(handler, "/ws/chat") .addInterceptors(authInterceptor) .setAllowedOriginPatterns("*"); } }setAllowedOriginPatterns是跨域配置,开发阶段可以放开,生产环境务必收窄到具体域名。用*在生产上等于给跨站连接开了口子,这个安全习惯要养成。
3.2 拦截器的完整实现
拦截器类实现HandshakeInterceptor,把前面讲的判断逻辑落成代码。下面这版同时兼容 Session 和 Token 两种凭证,实际项目里按需保留一种即可。
@Component public class AuthHandshakeInterceptor implements HandshakeInterceptor { private static final String USER_KEY = "loginUser"; @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { // 优先从请求头取 token String token = request.getHeaders().getFirst("Authorization"); if (token == null) { // 退而从 URL 参数取 String query = request.getURI().getQuery(); token = parseTokenFromQuery(query); } if (token == null || !TokenUtil.verify(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put(USER_KEY, TokenUtil.getUserId(token)); return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 仅做日志,鉴权不在此处 } private String parseTokenFromQuery(String query) { if (query == null) return null; for (String pair : query.split("&")) { String[] kv = pair.split("=", 2); if (kv.length == 2 && "token".equals(kv[0])) { return kv[1]; } } return null; } }TokenUtil是示意用的工具类,实际替换成你项目里的 JWT 或自定义 token 校验逻辑。注意verify失败时除了返回 false,还设置了 401 状态码,这样前端能明确知道是鉴权失败而不是网络问题,便于做跳转登录页的处理。
3.3 消息处理器的编写
处理器继承TextWebSocketHandler,重点是把拦截器塞进来的用户信息用起来。
@Component public class MyWebSocketHandler extends TextWebSocketHandler { private static final Map<String, WebSocketSession> ONLINE = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { String user = (String) session.getAttributes().get("loginUser"); ONLINE.put(user, session); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String user = (String) session.getAttributes().get("loginUser"); // 根据业务处理消息,示例:回显带用户标识 session.sendMessage(new TextMessage(user + " 说: " + message.getPayload())); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { String user = (String) session.getAttributes().get("loginUser"); if (user != null) { ONLINE.remove(user); } } }ONLINE这张表就是在线用户索引,推送时按用户取 session 直接发。生产环境要注意多实例部署时这张表是进程内的,跨实例推送得借助消息中间件或共享存储,这是另一个话题,但心里要有数。
3.4 前端连接与验证
前端用原生WebSocket连接,token 拼在 URL 上。连接成功和失败的回调要分开处理,失败时引导用户重新登录。
const token = localStorage.getItem('token'); const ws = new WebSocket(`ws://localhost:8080/ws/chat?token=${token}`); ws.onopen = () => console.log('连接成功'); ws.onmessage = (e) => console.log('收到消息:', e.data); ws.onerror = () => console.log('连接出错,可能是鉴权失败'); ws.onclose = (e) => console.log('连接关闭,状态码:', e.code);实测下来,鉴权失败时浏览器控制台会报握手失败,onerror和onclose都会触发。前端据此清掉本地 token 并跳登录页,体验比较顺。
3.5 参数与配置的取舍说明
有几个配置项值得单独说。连接超时时间默认值偏短,长连接场景建议在容器层面调大;消息大小上限默认 8KB 左右,如果业务要传大文本得显式放宽,否则超限会直接断连。这些参数没有绝对标准,按业务量估:消息体平均大小乘以并发峰值,留出两三倍余量即可。我一般会把消息大小上限设成业务最大消息的两倍,超时设成心跳间隔的三倍,这样心跳偶尔丢一两次也不会误判断连。
4. 常见问题排查与避坑实录
4.1 拦截器不生效的几种典型原因
这是被问得最多的问题。排查顺序我总结成一张表,按可能性从高到低走一遍,基本能定位。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 未登录也能连上 | 拦截器加在 handler 之前 | 检查 addHandler 与 addInterceptors 顺序 |
| 拦截器完全不执行 | 配置类没加 @EnableWebSocket | 确认注解存在 |
| 校验永远通过 | 用了 getSession() 无参方法 | 改为 getSession(false) |
| 跨域请求被拦 | 未配置 allowedOriginPatterns | 补上跨域配置 |
| 生产环境偶发失败 | 多实例间会话不共享 | 改用无状态 token 校验 |
顺序问题排第一,因为它是纯配置错误,最容易犯也最容易改。@EnableWebSocket漏加的情况多见于从别的项目拷配置类时没拷全。会话被悄悄创建那个坑前面讲过,这里再点一次,因为它最隐蔽——代码看着完全正常,就是校验不生效。
4.2 连接建立后立刻断开的排查
有时候日志显示握手成功,但连接秒断。常见原因是 handler 里抛了未捕获异常,或者消息大小超限。排查时先看服务端日志有没有异常堆栈,再看是不是发了超大消息。还有一种情况是前端在onopen里立刻发消息,而服务端 handler 还没准备好,这种时序问题可以通过加个短暂延迟或者服务端先发一条欢迎消息来对齐。
提示:调试 WebSocket 时浏览器开发者工具的 Network 面板里能看到握手请求和帧数据,比纯看日志直观得多。握手失败时看请求的响应状态码,401 就是鉴权没过,403 多半是跨域问题。
4.3 多实例部署下的鉴权陷阱
单机跑得好好的,一上集群就出问题,这是 Session 方案的经典痛点。用户的握手请求可能落到 A 实例,而登录会话存在 B 实例,A 上取不到会话就判定未登录。解决办法要么做会话共享,要么干脆改用无状态 token。我现在的项目基本都走 token,就是为了绕开这个麻烦。token 自带签名和过期时间,任何实例拿到都能独立校验,扩展性天然更好。
4.4 几个实操心得
第一,拦截器里拒绝握手时一定要设状态码,别只返回 false。不设状态码的话前端拿到的信息很模糊,排查全靠猜。第二,attributes 里的 key 统一用常量管理,散落的字符串是 bug 温床。第三,afterConnectionClosed的清理逻辑要判空,异常断连时 attributes 可能不完整。第四,开发阶段把 WebSocket 的日志级别调到 DEBUG,握手过程和帧收发都能看到,定位问题效率翻倍。这几条都是踩过坑之后刻在脑子里的,写代码时顺手就做了,能省下大量排查时间。
5. 方案扩展与生产化考量
5.1 心跳机制的必要性
长连接不是建完就一劳永逸,中间的网络设备(负载均衡、防火墙)可能因为连接长时间无数据而主动掐断。心跳就是定期发个空消息维持连接活性。服务端可以定时给所有在线连接发 ping,或者由前端定时发心跳包、服务端收到就回。两种都行,我倾向服务端主动发,因为能统一控制节奏。心跳间隔别太短,30 秒左右比较常见,太频繁反而增加无谓开销。
5.2 断线重连的客户端策略
网络抖动导致断连是常态,前端要有重连机制。简单做法是onclose里延时重连,但要加退避——第一次 1 秒,第二次 2 秒,逐次翻倍,设个上限比如 30 秒。不加退避的话,服务端故障时所有客户端同时疯狂重连,反而把服务压垮。重连时记得带上最新的 token,因为旧 token 可能已经过期。
5.3 权限粒度往细里做
登录验证只解决了“是不是合法用户”,业务里往往还要判断“这个用户能不能进这个房间/订阅这个频道”。这层可以在握手拦截器里根据 URL 参数做,也可以在消息处理时做。我的经验是:连接级的权限放拦截器,消息级的权限放 handler,各管一段,逻辑不打架。拦截器里拿到用户标识和目标资源标识,查一次权限表,不通过就拒绝握手,干净利落。
5.4 监控与可观测性
生产环境要能回答几个问题:当前在线多少连接、握手失败率多少、平均消息延迟多少。这些指标可以在拦截器和 handler 的关键节点埋点采集。握手失败率突然升高,往往意味着鉴权逻辑或 token 签发出了问题,是个很有价值的告警信号。在线连接数则直接反映系统负载,配合容量规划使用。这块不用一开始就做得很重,先把连接数和失败率两个指标接上,就能覆盖大部分突发情况。
整套东西跑下来,最深的体会是:WebSocket 的鉴权难点不在技术本身,而在对握手这个特殊环节的理解。把拦截器的执行时机、attributes 的传递链路、会话与 token 的取舍这三件事想透,剩下的就是按部就班写代码。我现在的习惯是每接一个实时功能,先把鉴权链路在纸上画一遍,确认每个环节的输入输出,再动手,返工率能降一大截。